diff --git a/zcash-android-wallet-app/app/build.gradle b/zcash-android-wallet-app/app/build.gradle index 8d2ee68..3a2d97e 100644 --- a/zcash-android-wallet-app/app/build.gradle +++ b/zcash-android-wallet-app/app/build.gradle @@ -6,6 +6,7 @@ apply plugin: 'kotlin-android-extensions' apply plugin: 'kotlin-kapt' apply plugin: 'deploygate' apply plugin: 'com.github.ben-manes.versions' +apply plugin: 'com.google.gms.google-services' android { compileSdkVersion buildConfig.compileSdkVersion @@ -19,19 +20,43 @@ android { vectorDrawables.useSupportLibrary = true multiDexEnabled true } + dataBinding { enabled true } + + flavorDimensions 'network' + productFlavors { + // product flavor names cannot start with the word "test" because they would clash with other targets + ztestnet { + dimension 'network' + applicationId 'cash.z.android.wallet.testnet' + matchingFallbacks = ['debug'] + } + + zmainnet { + dimension 'network' + applicationId 'cash.z.android.wallet.mainnet' + matchingFallbacks = ['release'] + } + } + buildTypes { release { minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } + mock { + initWith debug + matchingFallbacks = ['debug', 'release'] + } } + compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } + lintOptions { lintConfig file("zcash-lint-options.xml") } @@ -59,7 +84,7 @@ dependencies { // Zcash implementation deps.zcash.walletSdk - compile project(path: ':qrecycler') + implementation project(path: ':qrecycler') // TODO: get the AAR to provide these implementation "io.grpc:grpc-okhttp:1.17.1" @@ -79,6 +104,7 @@ dependencies { implementation deps.speeddial implementation deps.lottie debugImplementation deps.stetho + mockImplementation deps.stetho testImplementation deps.junit androidTestImplementation deps.androidx.test.runner diff --git a/zcash-android-wallet-app/app/libs/zcash-android-wallet-sdk-1.6.0.aar b/zcash-android-wallet-app/app/libs/zcash-android-wallet-sdk-1.6.0.aar index 90cc496..c36c550 100644 Binary files a/zcash-android-wallet-app/app/libs/zcash-android-wallet-sdk-1.6.0.aar and b/zcash-android-wallet-app/app/libs/zcash-android-wallet-sdk-1.6.0.aar differ diff --git a/zcash-android-wallet-app/app/src/debug/java/cash/z/android/wallet/di/module/SynchronizerModule.kt b/zcash-android-wallet-app/app/src/debug/java/cash/z/android/wallet/di/module/SynchronizerModule.kt new file mode 100644 index 0000000..f199391 --- /dev/null +++ b/zcash-android-wallet-app/app/src/debug/java/cash/z/android/wallet/di/module/SynchronizerModule.kt @@ -0,0 +1,85 @@ +package cash.z.android.wallet.di.module + +import cash.z.android.wallet.BuildConfig +import cash.z.android.wallet.ZcashWalletApplication +import cash.z.android.wallet.sample.SampleProperties +import cash.z.android.wallet.sample.SampleProperties.COMPACT_BLOCK_PORT +import cash.z.android.wallet.sample.SampleProperties.COMPACT_BLOCK_SERVER +import cash.z.wallet.sdk.data.* +import cash.z.wallet.sdk.jni.JniConverter +import cash.z.wallet.sdk.secure.Wallet +import dagger.Module +import dagger.Provides +import javax.inject.Singleton + +/** + * Module that contributes all the objects necessary for the synchronizer, which is basically everything that has + * application scope. + */ +@Module +internal object SynchronizerModule { + + @JvmStatic + @Provides + @Singleton + fun provideTwig(): Twig = TroubleshootingTwig() // troubleshoot on debug, silent on release + + @JvmStatic + @Provides + @Singleton + fun provideDownloader(twigger: Twig): CompactBlockStream { + return CompactBlockStream(COMPACT_BLOCK_SERVER, COMPACT_BLOCK_PORT, twigger) + } + + @JvmStatic + @Provides + @Singleton + fun provideProcessor(application: ZcashWalletApplication, converter: JniConverter, twigger: Twig): CompactBlockProcessor { + return CompactBlockProcessor(application, converter, SampleProperties.wallet.cacheDbName, SampleProperties.wallet.dataDbName, logger = twigger) + } + + @JvmStatic + @Provides + @Singleton + fun provideRepository(application: ZcashWalletApplication, converter: JniConverter, twigger: Twig): TransactionRepository { + return PollingTransactionRepository(application, SampleProperties.wallet.dataDbName, 10_000L, converter, twigger) + } + + @JvmStatic + @Provides + @Singleton + fun provideWallet(application: ZcashWalletApplication, converter: JniConverter, twigger: Twig): Wallet { + return Wallet(converter, application.getDatabasePath(SampleProperties.wallet.dataDbName).absolutePath, "${application.cacheDir.absolutePath}/params", seedProvider = SampleProperties.wallet.seedProvider, spendingKeyProvider = SampleProperties.wallet.spendingKeyProvider, logger = twigger) + } + + @JvmStatic + @Provides + @Singleton + fun provideManager(wallet: Wallet, repository: TransactionRepository, downloader: CompactBlockStream, twigger: Twig): ActiveTransactionManager { + return ActiveTransactionManager(repository, downloader.connection, wallet, twigger) + } + + @JvmStatic + @Provides + @Singleton + fun provideJniConverter(): JniConverter { + return JniConverter().also { + if (BuildConfig.DEBUG) it.initLogs() + } + } + + @JvmStatic + @Provides + @Singleton + fun provideSynchronizer( + downloader: CompactBlockStream, + processor: CompactBlockProcessor, + repository: TransactionRepository, + manager: ActiveTransactionManager, + wallet: Wallet, + twigger: Twig + ): Synchronizer { + return SdkSynchronizer(downloader, processor, repository, manager, wallet, blockPollFrequency = 500_000L, logger = twigger) + } + +} diff --git a/zcash-android-wallet-app/app/src/main/AndroidManifest.xml b/zcash-android-wallet-app/app/src/main/AndroidManifest.xml index 004c0b2..ce71389 100644 --- a/zcash-android-wallet-app/app/src/main/AndroidManifest.xml +++ b/zcash-android-wallet-app/app/src/main/AndroidManifest.xml @@ -4,6 +4,9 @@ xmlns:tools="http://schemas.android.com/tools" package="cash.z.android.wallet"> + + + @@ -27,6 +31,8 @@ + + \ No newline at end of file diff --git a/zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/di/component/ApplicationComponent.kt b/zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/di/component/ApplicationComponent.kt index 2a3eb65..86b9671 100644 --- a/zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/di/component/ApplicationComponent.kt +++ b/zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/di/component/ApplicationComponent.kt @@ -30,6 +30,7 @@ import javax.inject.Singleton ReceiveFragmentModule::class, RequestFragmentModule::class, SendFragmentModule::class, + ScanFragmentModule::class, SettingsFragmentModule::class ] ) diff --git a/zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/extention/Context.kt b/zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/extention/Context.kt new file mode 100644 index 0000000..5094d7c --- /dev/null +++ b/zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/extention/Context.kt @@ -0,0 +1,18 @@ +package cash.z.android.wallet.extention + +import android.content.Context +import androidx.annotation.StringRes +import androidx.appcompat.app.AlertDialog + +internal inline fun Context.alert(@StringRes messageResId: Int, crossinline block: () -> Unit = {}) { + AlertDialog.Builder(this) + .setMessage(messageResId) + .setPositiveButton(android.R.string.ok) { dialog, _ -> + dialog.dismiss() + block() + } + .setNegativeButton(android.R.string.cancel) { dialog, _ -> + dialog.dismiss() + } + .show() +} \ No newline at end of file diff --git a/zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/extention/Int.kt b/zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/extention/Int.kt index 4b27202..afd6d0f 100644 --- a/zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/extention/Int.kt +++ b/zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/extention/Int.kt @@ -2,6 +2,7 @@ package cash.z.android.wallet.extention import androidx.annotation.ColorInt import androidx.annotation.ColorRes +import androidx.annotation.IntegerRes import androidx.annotation.StringRes import androidx.core.content.res.ResourcesCompat import cash.z.android.wallet.ZcashWalletApplication @@ -20,3 +21,10 @@ internal inline fun @receiver:ColorRes Int.toAppColor(): Int { internal inline fun @receiver:StringRes Int.toAppString(): String { return ZcashWalletApplication.instance.getString(this)} + +/** + * Grab an integer from the application resources + */ +internal inline fun @receiver:IntegerRes Int.toAppInt(): Int { + return ZcashWalletApplication.instance.resources.getInteger(this)} + diff --git a/zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/sample/SampleConfig.kt b/zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/sample/SampleConfig.kt index 49b684c..2beefd7 100644 --- a/zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/sample/SampleConfig.kt +++ b/zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/sample/SampleConfig.kt @@ -8,6 +8,7 @@ object AliceWallet { val spendingKeyProvider = SampleSpendingKeySharedPref(name) const val cacheDbName = "testalice_cache.db" const val dataDbName = "testalice_data8.db" + const val defaultSendAddress = "ztestsapling1snmqdnfqnc407pvqw7sld8w5zxx6nd0523kvlj4jf39uvxvh7vn0hs3q38n07806dwwecqwke3t" // dummyseed //TODO: add bob's address here } object BobWallet { @@ -17,6 +18,7 @@ object BobWallet { val spendingKeyProvider = SampleSpendingKeySharedPref(name) const val cacheDbName = "testbob_cache.db" const val dataDbName = "testbob_data.db" + const val defaultSendAddress = "ztestsapling1snmqdnfqnc407pvqw7sld8w5zxx6nd0523kvlj4jf39uvxvh7vn0hs3q38n07806dwwecqwke3t" // dummyseed //TODO: add alice's address here } object MyWallet { @@ -26,6 +28,7 @@ object MyWallet { val spendingKeyProvider = SampleSpendingKeySharedPref(name) const val cacheDbName = "wallet_cache1202.db" const val dataDbName = "wallet_data1202.db" + const val defaultSendAddress = "ztestsapling1snmqdnfqnc407pvqw7sld8w5zxx6nd0523kvlj4jf39uvxvh7vn0hs3q38n07806dwwecqwke3t" // dummyseed } enum class Servers(val host: String) { diff --git a/zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/sample/SampleQrScanner.kt b/zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/sample/SampleQrScanner.kt new file mode 100644 index 0000000..4ab170e --- /dev/null +++ b/zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/sample/SampleQrScanner.kt @@ -0,0 +1,9 @@ +package cash.z.android.wallet.sample + +import cash.z.android.qrecycler.QScanner + +class SampleQrScanner : QScanner { + override fun scanBarcode(callback: (Result) -> Unit) { + callback(Result.success("sampleqrcode_scan_success")) + } +} \ No newline at end of file diff --git a/zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/ui/fragment/ReceiveFragment.kt b/zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/ui/fragment/ReceiveFragment.kt index 466313c..ec1d16c 100644 --- a/zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/ui/fragment/ReceiveFragment.kt +++ b/zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/ui/fragment/ReceiveFragment.kt @@ -12,6 +12,7 @@ import cash.z.android.qrecycler.QRecycler import cash.z.android.wallet.R import cash.z.android.wallet.ui.activity.MainActivity import cash.z.android.wallet.ui.util.AddressPartNumberSpan +import cash.z.wallet.sdk.data.Synchronizer import cash.z.wallet.sdk.jni.JniConverter import cash.z.wallet.sdk.secure.Wallet import dagger.Module @@ -28,7 +29,7 @@ class ReceiveFragment : BaseFragment() { lateinit var qrecycler: QRecycler @Inject - lateinit var wallet: Wallet + lateinit var synchronizer: Synchronizer lateinit var addressParts: Array @@ -89,7 +90,7 @@ class ReceiveFragment : BaseFragment() { // TODO: replace with tiered load. First check memory reference (textview contents?) then check DB, then load from JNI and write to DB private fun loadAddress(): String { - return wallet.getAddress() + return synchronizer.getAddress() } } diff --git a/zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/ui/fragment/ScanFragment.kt b/zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/ui/fragment/ScanFragment.kt new file mode 100644 index 0000000..736c3df --- /dev/null +++ b/zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/ui/fragment/ScanFragment.kt @@ -0,0 +1,185 @@ +package cash.z.android.wallet.ui.fragment + +import android.content.Context +import android.content.pm.PackageManager +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.content.ContextCompat +import androidx.databinding.DataBindingUtil +import cash.z.android.wallet.R +import cash.z.android.wallet.databinding.FragmentScanBinding +import cash.z.android.wallet.ui.activity.MainActivity +import dagger.Module +import dagger.android.ContributesAndroidInjector + +/** + * Fragment for scanning addresss, hopefully. + */ +class ScanFragment : BaseFragment() { + + lateinit var binding: FragmentScanBinding + +// private var cameraSource: CameraSource? = null + + private val requiredPermissions: Array + get() { + return try { + val info = mainActivity.packageManager + .getPackageInfo(mainActivity.packageName, PackageManager.GET_PERMISSIONS) + val ps = info.requestedPermissions + if (ps != null && ps.isNotEmpty()) { + ps + } else { + arrayOfNulls(0) + } + } catch (e: Exception) { + arrayOfNulls(0) + } + } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + return DataBindingUtil.inflate( + inflater, R.layout.fragment_scan, container, false + ).let { + binding = it + it.root + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + (activity as MainActivity).let { mainActivity -> + mainActivity.setSupportActionBar(view.findViewById(R.id.toolbar)) + mainActivity.supportActionBar?.setDisplayHomeAsUpEnabled(true) + mainActivity.supportActionBar?.setTitle(R.string.destination_title_send) + } +// binding.previewCameraSource.doOnLayout { +// if (allPermissionsGranted()) { +// createCameraSource(it.width, it.height) +// } else { +// getRuntimePermissions() +// } +// } + } + + override fun onActivityCreated(savedInstanceState: Bundle?) { + super.onActivityCreated(savedInstanceState) + if(!allPermissionsGranted()) getRuntimePermissions() + + +// sendPresenter = SendPresenter(this, mainActivity.synchronizer) + } + + override fun onResume() { + super.onResume() + if(allPermissionsGranted()) onStartCamera() +// launch { +// sendPresenter.start() +// } +// startCameraSource() + } + + override fun onPause() { + binding.cameraView.stop() + super.onPause() +// sendPresenter.stop() +// binding.previewCameraSource?.stop() + } + + override fun onDestroy() { + super.onDestroy() +// cameraSource?.release() + } + + /* Camera */ +// private fun createCameraSource(width: Int, height: Int) { +// Toaster.short("w: $width h: $height") +// // If there's no existing cameraSource, create one. +// if (cameraSource == null) { +// cameraSource = CameraSource(mainActivity, binding.graphicOverlay) +// } +// +// try { +// cameraSource?.setMachineLearningFrameProcessor(BarcodeScanningProcessor()) +// } catch (e: FirebaseMLException) { +// Log.e("temporaryBehavior", "can not create camera source") +// } +// } + + /** + * Starts or restarts the camera source, if it exists. If the camera source doesn't exist yet + * (e.g., because onResume was called before the camera source was created), this will be called + * again when the camera source is created. + */ +// private fun startCameraSource() { +// cameraSource?.let { +// try { +// binding.previewCameraSource?.start(cameraSource!!, binding.graphicOverlay) +// } catch (e: IOException) { +// Log.e("temporaryBehavior", "Unable to start camera source.", e) +// cameraSource?.release() +// cameraSource = null +// } +// } +// } + + /* Permissions */ + + private fun allPermissionsGranted(): Boolean { + for (permission in requiredPermissions) { + if (!isPermissionGranted(mainActivity, permission!!)) { + return false + } + } + return true + } + + private fun getRuntimePermissions() { + val allNeededPermissions = arrayListOf() + for (permission in requiredPermissions) { + if (!isPermissionGranted(mainActivity, permission!!)) { + allNeededPermissions.add(permission) + } + } + + if (!allNeededPermissions.isEmpty()) { + requestPermissions(allNeededPermissions.toTypedArray(), CAMERA_PERMISSION_REQUEST) + } + } + + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + if (allPermissionsGranted()) { + onStartCamera() + } + } + + private fun onStartCamera() { + with(binding.cameraView) { + postDelayed({ + start() + }, 1500L) + } + } + + companion object { + // TODO: continue doing permissions here in a more specific, less general way + private const val CAMERA_PERMISSION_REQUEST = 1001 + + private fun isPermissionGranted(context: Context, permission: String): Boolean { + return ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED + } + } +} + + +@Module +abstract class ScanFragmentModule { + @ContributesAndroidInjector + abstract fun contributeScanFragment(): ScanFragment +} diff --git a/zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/ui/fragment/SendFragment.kt b/zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/ui/fragment/SendFragment.kt index f90b866..b9585b0 100644 --- a/zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/ui/fragment/SendFragment.kt +++ b/zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/ui/fragment/SendFragment.kt @@ -1,5 +1,6 @@ package cash.z.android.wallet.ui.fragment +import android.annotation.SuppressLint import android.graphics.Typeface import android.os.Bundle import android.text.Spanned @@ -9,16 +10,19 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.view.inputmethod.InputMethodManager +import androidx.annotation.ColorRes +import androidx.appcompat.widget.TooltipCompat +import androidx.core.content.ContextCompat import androidx.core.content.getSystemService +import androidx.core.graphics.drawable.DrawableCompat import androidx.core.text.toSpannable import androidx.databinding.DataBindingUtil import androidx.navigation.fragment.FragmentNavigatorExtras -import androidx.transition.TransitionInflater +import cash.z.android.qrecycler.QScanner +import cash.z.android.wallet.BuildConfig import cash.z.android.wallet.R import cash.z.android.wallet.databinding.FragmentSendBinding -import cash.z.android.wallet.extention.afterTextChanged -import cash.z.android.wallet.extention.toAppColor -import cash.z.android.wallet.extention.tryIgnore +import cash.z.android.wallet.extention.* import cash.z.android.wallet.sample.SampleProperties import cash.z.android.wallet.sample.SampleProperties.DEV_MODE import cash.z.android.wallet.ui.activity.MainActivity @@ -27,6 +31,8 @@ import dagger.Module import dagger.android.ContributesAndroidInjector import kotlinx.coroutines.launch import java.text.DecimalFormat +import javax.inject.Inject +import kotlin.math.absoluteValue /** @@ -35,12 +41,17 @@ import java.text.DecimalFormat */ class SendFragment : BaseFragment(), SendPresenter.SendView { + @Inject + lateinit var qrCodeScanner: QScanner lateinit var sendPresenter: SendPresenter lateinit var binding: FragmentSendBinding private val zecFormatter = DecimalFormat("#.######") - private val usdFormatter = DecimalFormat("###,###,###.##") - private val zecSelected get() = binding.groupZecSelected.visibility == View.VISIBLE + private val usdFormatter = DecimalFormat("###,###,##0.00") + private val usdSelected get() = binding.groupUsdSelected.visibility == View.VISIBLE + + private val zec = R.string.zec_abbreviation.toAppString() + private val usd = R.string.usd_abbreviation.toAppString() override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -71,35 +82,111 @@ class SendFragment : BaseFragment(), SendPresenter.SendView { mainActivity.supportActionBar?.setDisplayHomeAsUpEnabled(true) mainActivity.supportActionBar?.setTitle(R.string.destination_title_send) } - initPresenter() + init() initDialog() } // temporary function until presenter is setup - private fun initPresenter() { + private fun init() { binding.imageSwapCurrency.setOnClickListener { onToggleCurrency() } - binding.textValueHeader.afterTextChanged { - tryIgnore { - val value = binding.textValueHeader.text.toString().toDouble() - binding.textValueSubheader.text = if (zecSelected) { - usdFormatter.format(value * SampleProperties.USD_PER_ZEC) - } else { - zecFormatter.format(value / SampleProperties.USD_PER_ZEC) + binding.textValueHeader.apply { + afterTextChanged { + tryIgnore { + // only update things if the user is actively editing. in other words, don't update on programmatic changes + if (binding.textValueHeader.hasFocus()) { + val value = binding.textValueHeader.text.toString().toDouble() + binding.textValueSubheader.text = if (usdSelected) { + zecFormatter.format(value / SampleProperties.USD_PER_ZEC) + " $zec" + } else { + if (value == 0.0) "0 $usd" + else usdFormatter.format(value * SampleProperties.USD_PER_ZEC) + " $usd" + } + } } } } binding.textAreaMemo.afterTextChanged { - binding.textMemoCharCount.text = "${binding.textAreaMemo.text.length} / ${resources.getInteger(R.integer.max_memo_length)}" + binding.textMemoCharCount.text = + "${binding.textAreaMemo.text.length} / ${resources.getInteger(R.integer.memo_max_length)}" } binding.buttonSendZec.setOnClickListener { showSendDialog() } + binding.buttonSendZec.isEnabled = false + with(binding.imageScanQr) { + TooltipCompat.setTooltipText(this, context.getString(R.string.send_tooltip_scan_qr)) + } + binding.imageAddressShortcut?.apply { + if (BuildConfig.DEBUG) { + TooltipCompat.setTooltipText(this, context.getString(R.string.send_tooltip_address_shortcut)) + setOnClickListener(::onPasteShortcutAddress) + } else { + visibility = View.GONE + } + } + binding.imageScanQr.setOnClickListener(::onScanQrCode) + binding.textValueHeader.setText("0") + binding.textValueSubheader.text = + mainActivity.resources.getString(R.string.send_subheader_value, if (usdSelected) zec else usd) + + // allow background taps to dismiss the keyboard and clear focus + binding.contentFragmentSend.setOnClickListener { + it?.findFocus()?.clearFocus() + formatUserInput() + hideKeyboard() + } + + setSendEnabled(true) + onToggleCurrency() + } + + private fun setAddressLineColor(@ColorRes colorRes: Int = R.color.zcashBlack_12) { + DrawableCompat.setTint( + binding.inputZcashAddress.background, + ContextCompat.getColor(mainActivity, colorRes) + ) + } + + fun formatUserInput() { + formatAmountInput() + formatAddressInput() + } + + private fun formatAmountInput() { + val value = binding.textValueHeader.text.toString().toDouble().absoluteValue + binding.textValueHeader.setText( + when { + value == 0.0 -> "0" + usdSelected -> usdFormatter.format(value) + else -> zecFormatter.format(value) + } + ) + } + + private fun formatAddressInput() { + val address = binding.inputZcashAddress.text + if(address.isNotEmpty() && address.length < R.integer.z_address_min_length.toAppInt()) setAddressError(R.string.send_error_address_too_short.toAppString()) + else setAddressError(null) + } + + private fun setAddressError(message: String?) { + if (message == null) { + setAddressLineColor() + binding.textAddressError.text = null + binding.textAddressError.visibility = View.GONE + binding.buttonSendZec.isEnabled = true + } else { + setAddressLineColor(R.color.zcashRed) + binding.textAddressError.text = message + binding.textAddressError.visibility = View.VISIBLE + binding.buttonSendZec.isEnabled = false + } } private fun initDialog() { @@ -114,7 +201,6 @@ class SendFragment : BaseFragment(), SendPresenter.SendView { override fun onActivityCreated(savedInstanceState: Bundle?) { super.onActivityCreated(savedInstanceState) sendPresenter = SendPresenter(this, mainActivity.synchronizer) - onToggleCurrency() } override fun onResume() { @@ -162,16 +248,51 @@ class SendFragment : BaseFragment(), SendPresenter.SendView { extras) } + @SuppressLint("SetTextI18n") fun onToggleCurrency() { - val headerValue = binding.textValueHeader.text - binding.textValueHeader.setText(binding.textValueSubheader.text) - binding.textValueSubheader.text = headerValue - if (zecSelected) { - binding.groupZecSelected.visibility = View.GONE - binding.groupUsdSelected.visibility = View.VISIBLE - } else { + view?.findFocus()?.clearFocus() + formatUserInput() + val isInitiallyUsd = usdSelected // hold this value because we modify visibility here and that's what the value is based on + val subHeaderValue = binding.textValueSubheader.text.toString().substringBefore(' ') + val currencyLabelAfterToggle = if (isInitiallyUsd) usd else zec // what is selected is about to move to the subheader where the currency is labelled + + binding.textValueSubheader.post { + binding.textValueSubheader.text = "${binding.textValueHeader.text} $currencyLabelAfterToggle" + binding.textValueHeader.setText(subHeaderValue) + } + if (isInitiallyUsd) { binding.groupZecSelected.visibility = View.VISIBLE binding.groupUsdSelected.visibility = View.GONE + } else { + binding.groupZecSelected.visibility = View.GONE + binding.groupUsdSelected.visibility = View.VISIBLE + } + } + + private fun onScanQrCode(view: View) { + hideKeyboard() + val fragment = ScanFragment() + val ft = childFragmentManager.beginTransaction() + .add(R.id.camera_placeholder, fragment, "camera_fragment") + .commit() +// val intent = Intent(mainActivity, CameraQrScanner::class.java) +// mainActivity.startActivity(intent) +// qrCodeScanner.scanBarcode { barcode: Result -> +// if (barcode.isSuccess) { +// binding.inputZcashAddress.setText(barcode.getOrThrow()) +// formatAddressInput() +// } else { +// Toaster.short("failed to scan QR code") +// } +// } + } + + // TODO: possibly move this behavior to only live in the debug build. Perhaps with a viewholder that I just delegate to. Then inject the holder here. + private fun onPasteShortcutAddress(view: View) { + view.context.alert(R.string.send_alert_shortcut_clicked) { + binding.inputZcashAddress.setText(SampleProperties.wallet.defaultSendAddress) + setAddressError(null) + hideKeyboard() } } @@ -180,7 +301,7 @@ class SendFragment : BaseFragment(), SendPresenter.SendView { val usdBalance = zecBalance * SampleProperties.USD_PER_ZEC val availableZecFormatter = DecimalFormat("#.########") // TODO: use a formatted string resource here - val availableTextSpan = "${availableZecFormatter.format(zecBalance)} ZEC Available".toSpannable() + val availableTextSpan = "${availableZecFormatter.format(zecBalance)} $zec Available".toSpannable() availableTextSpan.setSpan(ForegroundColorSpan(R.color.colorPrimary.toAppColor()), availableTextSpan.length - "Available".length, availableTextSpan.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) availableTextSpan.setSpan(StyleSpan(Typeface.BOLD), 0, 6, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) binding.textZecValueAvailable.text = availableTextSpan @@ -205,14 +326,27 @@ class SendFragment : BaseFragment(), SendPresenter.SendView { // private fun showSendDialog() { - // hide soft keyboard - mainActivity.getSystemService() - ?.hideSoftInputFromWindow(view?.windowToken, InputMethodManager.HIDE_NOT_ALWAYS) + hideKeyboard() + + val address = binding.inputZcashAddress.text + val headerString = binding.textValueHeader.text.toString() + val subheaderString = binding.textValueSubheader.text.toString().substringBefore(' ') + val zecString = if(usdSelected) subheaderString else headerString + val usdString = if(usdSelected) headerString else subheaderString + val memo = binding.textAreaMemo.text.toString().trim() setSendEnabled(false) // partially because we need to lower the button elevation + binding.dialogTextTitle.text = getString(R.string.send_dialog_title, zecString, zec, usdString) + binding.dialogTextAddress.text = address + binding.dialogTextMemoIncluded.visibility = if(memo.isNotEmpty()) View.VISIBLE else View.GONE binding.groupDialogSend.visibility = View.VISIBLE } + private fun hideKeyboard() { + mainActivity.getSystemService() + ?.hideSoftInputFromWindow(view?.windowToken, InputMethodManager.HIDE_NOT_ALWAYS) + } + private fun hideSendDialog() { setSendEnabled(true) binding.groupDialogSend.visibility = View.GONE @@ -221,7 +355,7 @@ class SendFragment : BaseFragment(), SendPresenter.SendView { private fun setSendEnabled(isEnabled: Boolean) { binding.buttonSendZec.isEnabled = isEnabled if (isEnabled) { - binding.buttonSendZec.text = "send zec" + binding.buttonSendZec.text = "send $zec" // binding.progressSend.visibility = View.GONE } else { binding.buttonSendZec.text = "sending..." diff --git a/zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/ui/presenter/HistoryPresenter.kt b/zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/ui/presenter/HistoryPresenter.kt index 99b31bd..f15af14 100644 --- a/zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/ui/presenter/HistoryPresenter.kt +++ b/zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/ui/presenter/HistoryPresenter.kt @@ -26,7 +26,7 @@ class HistoryPresenter( override suspend fun start() { Log.e("@TWIG", "historyPresenter starting!") - launchTransactionBinder(synchronizer.repository.allTransactions()) + launchTransactionBinder(synchronizer.allTransactions()) } override fun stop() { diff --git a/zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/ui/presenter/HomePresenter.kt b/zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/ui/presenter/HomePresenter.kt index eae45c0..5d825d4 100644 --- a/zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/ui/presenter/HomePresenter.kt +++ b/zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/ui/presenter/HomePresenter.kt @@ -30,9 +30,9 @@ class HomePresenter( override suspend fun start() { Log.e("@TWIG-t", "homePresenter starting!") - launchBalanceBinder(synchronizer.repository.balance()) - launchTransactionBinder(synchronizer.repository.allTransactions()) - launchProgressMonitor(synchronizer.downloader.progress()) + launchBalanceBinder(synchronizer.balance()) + launchTransactionBinder(synchronizer.allTransactions()) + launchProgressMonitor(synchronizer.progress()) launchActiveTransactionMonitor(synchronizer.activeTransactions()) } diff --git a/zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/ui/presenter/SendPresenter.kt b/zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/ui/presenter/SendPresenter.kt index 74eb9ae..83b548c 100644 --- a/zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/ui/presenter/SendPresenter.kt +++ b/zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/ui/presenter/SendPresenter.kt @@ -25,7 +25,7 @@ class SendPresenter( override suspend fun start() { Log.e("@TWIG-v", "sendPresenter starting!") with(view) { - balanceJob = launchBalanceBinder(synchronizer.repository.balance()) + balanceJob = launchBalanceBinder(synchronizer.balance()) } } @@ -54,10 +54,6 @@ class SendPresenter( view.submit() } - private suspend fun findTransaction(txId: Long): Transaction? { - return if (txId < 0) null else synchronizer.repository.findTransactionById(txId) - } - fun bind(old: Long?, new: Long) { Log.e("@TWIG-v", "binding balance of $new") view.updateBalance(old ?: 0L, new) diff --git a/zcash-android-wallet-app/app/src/main/res/drawable/background_rounded_corners.xml b/zcash-android-wallet-app/app/src/main/res/drawable/background_rounded_corners.xml index 2a88bc7..6041399 100644 --- a/zcash-android-wallet-app/app/src/main/res/drawable/background_rounded_corners.xml +++ b/zcash-android-wallet-app/app/src/main/res/drawable/background_rounded_corners.xml @@ -1,5 +1,5 @@ - + \ No newline at end of file diff --git a/zcash-android-wallet-app/app/src/main/res/drawable/ic_content_paste_black_24dp.xml b/zcash-android-wallet-app/app/src/main/res/drawable/ic_content_paste_black_24dp.xml new file mode 100644 index 0000000..a902d9a --- /dev/null +++ b/zcash-android-wallet-app/app/src/main/res/drawable/ic_content_paste_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/zcash-android-wallet-app/app/src/main/res/layout/activity_qr_scanner.xml b/zcash-android-wallet-app/app/src/main/res/layout/activity_qr_scanner.xml new file mode 100644 index 0000000..cedddff --- /dev/null +++ b/zcash-android-wallet-app/app/src/main/res/layout/activity_qr_scanner.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/zcash-android-wallet-app/app/src/main/res/layout/fragment_scan.xml b/zcash-android-wallet-app/app/src/main/res/layout/fragment_scan.xml new file mode 100644 index 0000000..0ee4f52 --- /dev/null +++ b/zcash-android-wallet-app/app/src/main/res/layout/fragment_scan.xml @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file diff --git a/zcash-android-wallet-app/app/src/main/res/layout/fragment_scan_transparent.xml b/zcash-android-wallet-app/app/src/main/res/layout/fragment_scan_transparent.xml new file mode 100644 index 0000000..8efc66f --- /dev/null +++ b/zcash-android-wallet-app/app/src/main/res/layout/fragment_scan_transparent.xml @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/zcash-android-wallet-app/app/src/main/res/layout/fragment_send.xml b/zcash-android-wallet-app/app/src/main/res/layout/fragment_send.xml index d6c4429..aa6f85a 100644 --- a/zcash-android-wallet-app/app/src/main/res/layout/fragment_send.xml +++ b/zcash-android-wallet-app/app/src/main/res/layout/fragment_send.xml @@ -8,9 +8,11 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:fillViewport="true" + android:isScrollContainer="true" tools:context=".ui.fragment.SendFragment"> @@ -71,6 +73,7 @@ android:layout_height="0dp" android:layout_marginTop="24dp" android:background="@drawable/background_rounded_corners" + android:backgroundTint="@color/zcashWhite_40" app:layout_constraintBottom_toTopOf="@id/input_zcash_address" app:layout_constraintEnd_toEndOf="@id/guideline_content_end" app:layout_constraintHeight_default="percent" @@ -79,6 +82,15 @@ app:layout_constraintTop_toBottomOf="@id/background_header" app:layout_constraintVertical_chainStyle="spread_inside" /> + + + app:layout_constraintTop_toBottomOf="@id/text_value_header" + tools:text="0 ZEC" /> + + + + + + @@ -371,6 +409,7 @@ android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/divider_background" + app:layout_goneMarginTop="32dp" app:layout_constraintBottom_toTopOf="@+id/dialog_submit_button" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/dialog_text_memo_included" /> @@ -380,7 +419,7 @@ android:layout_width="match_parent" android:layout_height="48dp" android:background="@null" - android:text="swipe right to send" + android:text="send" android:textColor="@color/colorPrimary" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintStart_toStartOf="parent" @@ -410,7 +449,6 @@ android:visibility="gone" app:constraint_referenced_ids="dialog_send_contents, dialog_send_background" /> - \ No newline at end of file diff --git a/zcash-android-wallet-app/app/src/main/res/navigation/mobile_navigation.xml b/zcash-android-wallet-app/app/src/main/res/navigation/mobile_navigation.xml index 6295d1b..b4beaad 100644 --- a/zcash-android-wallet-app/app/src/main/res/navigation/mobile_navigation.xml +++ b/zcash-android-wallet-app/app/src/main/res/navigation/mobile_navigation.xml @@ -24,7 +24,11 @@ android:id="@+id/nav_send_fragment" android:name="cash.z.android.wallet.ui.fragment.SendFragment" android:label="@string/destination_title_send" - tools:layout="@layout/fragment_send" /> + tools:layout="@layout/fragment_send" > + + + #2196F3 #0D364C66 #FFFFFF + #1FFFFFFF + #66FFFFFF + #A3FFFFFF #BFFFFFFF #EDEDED #CACACA + #9B9B9B #2B2B2B #1F000000 #66000000 diff --git a/zcash-android-wallet-app/app/src/main/res/values/integers.xml b/zcash-android-wallet-app/app/src/main/res/values/integers.xml index 68b13bf..a1cb0ba 100644 --- a/zcash-android-wallet-app/app/src/main/res/values/integers.xml +++ b/zcash-android-wallet-app/app/src/main/res/values/integers.xml @@ -2,5 +2,6 @@ 400 100 - 512 + 512 + 78 \ No newline at end of file diff --git a/zcash-android-wallet-app/app/src/main/res/values/strings.xml b/zcash-android-wallet-app/app/src/main/res/values/strings.xml index de7e091..3d3dfb6 100644 --- a/zcash-android-wallet-app/app/src/main/res/values/strings.xml +++ b/zcash-android-wallet-app/app/src/main/res/values/strings.xml @@ -11,12 +11,15 @@ cancel cancelled OK + ZECC + USD Send Zcash Receive Zcash Request Zcash + Scan Address History About Import @@ -60,5 +63,11 @@ To Memo (optional) Send Zec + Scan QR Code + Paste Sample Address + 0 %1$s + Send %1$s %2$s ($%3$s)? + Paste a valid sample address for testing? + Address is too short. diff --git a/zcash-android-wallet-app/app/src/mock/java/cash/z/android/wallet/di/module/SynchronizerModule.kt b/zcash-android-wallet-app/app/src/mock/java/cash/z/android/wallet/di/module/SynchronizerModule.kt new file mode 100644 index 0000000..75c485a --- /dev/null +++ b/zcash-android-wallet-app/app/src/mock/java/cash/z/android/wallet/di/module/SynchronizerModule.kt @@ -0,0 +1,36 @@ +package cash.z.android.wallet.di.module + +import cash.z.android.qrecycler.QScanner +import cash.z.android.wallet.sample.SampleQrScanner +import cash.z.wallet.sdk.data.MockSynchronizer +import cash.z.wallet.sdk.data.Synchronizer +import dagger.Module +import dagger.Provides +import javax.inject.Singleton + +/** + * Module that contributes all the objects necessary for the synchronizer, which is basically everything that has + * application scope. + */ +@Module +internal object SynchronizerModule { + + @JvmStatic + @Provides + @Singleton + fun provideQRScanner(): QScanner { + // TODO: make an MLKit scanner + return SampleQrScanner() + } + + @JvmStatic + @Provides + @Singleton + fun provideSynchronizer(): Synchronizer { + return MockSynchronizer( + transactionInterval = 60_000L, + activeTransactionUpdateFrequency = 18_000L, + isFirstRun = true + ) + } +} diff --git a/zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/di/module/SynchronizerModule.kt b/zcash-android-wallet-app/app/src/release/java/cash/z/android/wallet/di/module/SynchronizerModule.kt similarity index 95% rename from zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/di/module/SynchronizerModule.kt rename to zcash-android-wallet-app/app/src/release/java/cash/z/android/wallet/di/module/SynchronizerModule.kt index 4b3914f..d8144fa 100644 --- a/zcash-android-wallet-app/app/src/main/java/cash/z/android/wallet/di/module/SynchronizerModule.kt +++ b/zcash-android-wallet-app/app/src/release/java/cash/z/android/wallet/di/module/SynchronizerModule.kt @@ -79,7 +79,7 @@ internal object SynchronizerModule { wallet: Wallet, twigger: Twig ): Synchronizer { - return Synchronizer(downloader, processor, repository, manager, wallet, logger = twigger) + return SdkSynchronizer(downloader, processor, repository, manager, wallet, blockPollFrequency = 500_000L, logger = twigger) } } diff --git a/zcash-android-wallet-app/app/src/ztestnet/res/values/integers.xml b/zcash-android-wallet-app/app/src/ztestnet/res/values/integers.xml new file mode 100644 index 0000000..20a4e17 --- /dev/null +++ b/zcash-android-wallet-app/app/src/ztestnet/res/values/integers.xml @@ -0,0 +1,4 @@ + + + 88 + \ No newline at end of file diff --git a/zcash-android-wallet-app/app/src/ztestnet/res/values/strings.xml b/zcash-android-wallet-app/app/src/ztestnet/res/values/strings.xml new file mode 100644 index 0000000..d26233f --- /dev/null +++ b/zcash-android-wallet-app/app/src/ztestnet/res/values/strings.xml @@ -0,0 +1,4 @@ + + + TAZ + \ No newline at end of file diff --git a/zcash-android-wallet-app/build.gradle b/zcash-android-wallet-app/build.gradle index 0ff3659..86261f8 100644 --- a/zcash-android-wallet-app/build.gradle +++ b/zcash-android-wallet-app/build.gradle @@ -1,7 +1,7 @@ buildscript { ext.buildConfig = [ 'compileSdkVersion': 28, - 'minSdkVersion': 16, + 'minSdkVersion': 21, 'targetSdkVersion': 28 ] ext.versions = [ @@ -61,6 +61,7 @@ buildscript { classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${versions.kotlin}" classpath "com.github.ben-manes:gradle-versions-plugin:0.20.0" classpath 'com.deploygate:gradle:1.1.5' + classpath 'com.google.gms:google-services:4.2.0' } } diff --git a/zcash-android-wallet-app/qrecycler/build.gradle b/zcash-android-wallet-app/qrecycler/build.gradle index e7fa069..8433b5f 100644 --- a/zcash-android-wallet-app/qrecycler/build.gradle +++ b/zcash-android-wallet-app/qrecycler/build.gradle @@ -27,9 +27,8 @@ android { dependencies { implementation deps.androidx.coreKtx implementation deps.kotlin.stdlib - + api 'com.google.firebase:firebase-ml-vision:19.0.2' implementation 'com.google.zxing:core:3.2.1' - implementation 'com.android.support:appcompat-v7:28.0.0' testImplementation 'junit:junit:4.12' androidTestImplementation 'com.android.support.test:runner:1.0.2' diff --git a/zcash-android-wallet-app/qrecycler/src/main/AndroidManifest.xml b/zcash-android-wallet-app/qrecycler/src/main/AndroidManifest.xml index 2cbf62f..2ef941f 100644 --- a/zcash-android-wallet-app/qrecycler/src/main/AndroidManifest.xml +++ b/zcash-android-wallet-app/qrecycler/src/main/AndroidManifest.xml @@ -1,2 +1,5 @@ + package="cash.z.android.qrecycler" > + + + diff --git a/zcash-android-wallet-app/qrecycler/src/main/java/cash/z/android/cameraview/CameraView.kt b/zcash-android-wallet-app/qrecycler/src/main/java/cash/z/android/cameraview/CameraView.kt new file mode 100644 index 0000000..7ad15c6 --- /dev/null +++ b/zcash-android-wallet-app/qrecycler/src/main/java/cash/z/android/cameraview/CameraView.kt @@ -0,0 +1,510 @@ +/* + * Copyright (C) 2016 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. + */ + +package cash.z.android.cameraview + +import cash.z.android.qrecycler.R +import android.app.Activity +import android.content.Context +import android.os.Build +import android.os.Parcel +import android.os.Parcelable +import android.util.AttributeSet +import android.view.View +import android.widget.FrameLayout +import androidx.annotation.IntDef +import androidx.annotation.NonNull +import androidx.annotation.Nullable +import androidx.core.os.ParcelableCompat +import androidx.core.os.ParcelableCompatCreatorCallbacks +import androidx.core.view.ViewCompat +import cash.z.android.cameraview.api21.Camera2 +import cash.z.android.cameraview.base.AspectRatio +import cash.z.android.cameraview.base.CameraViewImpl +import cash.z.android.cameraview.base.Constants +import cash.z.android.cameraview.base.PreviewImpl +import com.google.android.cameraview.Camera2Api23 +import com.google.android.cameraview.TextureViewPreview +import java.lang.IllegalStateException +import java.lang.annotation.Retention +import java.lang.annotation.RetentionPolicy +import java.util.* + +open class CameraView(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : + FrameLayout(context, attrs, defStyleAttr) { + + @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : this(context, attrs, 0) + + internal lateinit var mImpl: CameraViewImpl + + private var mCallbacks: CallbackBridge? + + private var mAdjustViewBounds: Boolean = false + + private var mDisplayOrientationDetector: DisplayOrientationDetector? + + /** + * @return `true` if the camera is opened. + */ + val isCameraOpened: Boolean + get() = mImpl.isCameraOpened + + /** + * @return True when this CameraView is adjusting its bounds to preserve the aspect ratio of + * camera. + * @see .setAdjustViewBounds + */ + /** + * @param adjustViewBounds `true` if you want the CameraView to adjust its bounds to + * preserve the aspect ratio of camera. + * @see .getAdjustViewBounds + */ + var adjustViewBounds: Boolean + get() = mAdjustViewBounds + set(adjustViewBounds) { + if (mAdjustViewBounds != adjustViewBounds) { + mAdjustViewBounds = adjustViewBounds + requestLayout() + } + } + + /** + * Gets the direction that the current camera faces. + * + * @return The camera facing. + */ + /** + * Chooses camera by the direction it faces. + * + * @param facing The camera facing. Must be either [.FACING_BACK] or + * [.FACING_FRONT]. + */ + var facing: Int + @Facing + get() = mImpl.facing + set(@Facing facing) { + mImpl.facing = facing + } + + /** + * Gets all the aspect ratios supported by the current camera. + */ + val supportedAspectRatios: Set + get() = mImpl.supportedAspectRatios + + /** + * Gets the current aspect ratio of camera. + * + * @return The current [AspectRatio]. Can be `null` if no camera is opened yet. + */ + /** + * Sets the aspect ratio of camera. + * + * @param ratio The [AspectRatio] to be set. + */ + var aspectRatio: AspectRatio? + @Nullable + get() = mImpl.aspectRatio + set(@NonNull ratio) { + if (mImpl.setAspectRatio(ratio)) { + requestLayout() + } + } + + /** + * Returns whether the continuous auto-focus mode is enabled. + * + * @return `true` if the continuous auto-focus mode is enabled. `false` if it is + * disabled, or if it is not supported by the current camera. + */ + /** + * Enables or disables the continuous auto-focus mode. When the current camera doesn't support + * auto-focus, calling this method will be ignored. + * + * @param autoFocus `true` to enable continuous auto-focus mode. `false` to + * disable it. + */ + var autoFocus: Boolean + get() = mImpl.autoFocus + set(autoFocus) { + mImpl.autoFocus = autoFocus + } + + /** + * Gets the current flash mode. + * + * @return The current flash mode. + */ + /** + * Sets the flash mode. + * + * @param flash The desired flash mode. + */ + var flash: Int + @Flash + get() = mImpl.flash + set(@Flash flash) { + mImpl.flash = flash + } + + /** Direction the camera faces relative to device screen. */ + @IntDef(FACING_BACK, FACING_FRONT) + @kotlin.annotation.Retention(AnnotationRetention.SOURCE) + annotation class Facing + + /** The mode for for the camera device's flash control */ + @IntDef(FLASH_OFF, FLASH_ON, FLASH_TORCH, FLASH_AUTO, FLASH_RED_EYE) + annotation class Flash + + init { + if (isInEditMode) { + mCallbacks = null + mDisplayOrientationDetector = null + } else { + // Internal setup + val preview = createPreviewImpl(context) + mCallbacks = CallbackBridge() + if (Build.VERSION.SDK_INT < 23) { + mImpl = Camera2(mCallbacks!!, preview, context) + } else { + mImpl = Camera2Api23(mCallbacks!!, preview, context) + } + // Attributes + val a = context.obtainStyledAttributes( + attrs, R.styleable.CameraView, defStyleAttr, + R.style.Widget_CameraView + ) + mAdjustViewBounds = a.getBoolean(R.styleable.CameraView_android_adjustViewBounds, false) + facing = a.getInt(R.styleable.CameraView_facing, FACING_BACK) + var aspectRatioString = a.getString(R.styleable.CameraView_aspectRatio) + if (aspectRatioString != null) { + aspectRatio = AspectRatio.parse(aspectRatioString) + } else { + aspectRatio = Constants.DEFAULT_ASPECT_RATIO + } + autoFocus = a.getBoolean(R.styleable.CameraView_autoFocus, true) + flash = a.getInt(R.styleable.CameraView_flash, Constants.FLASH_AUTO) + a.recycle() + // Display orientation detector + mDisplayOrientationDetector = object : DisplayOrientationDetector(context) { + override fun onDisplayOrientationChanged(displayOrientation: Int) { + mImpl.setDisplayOrientation(displayOrientation) + } + } + } + } + + @NonNull + private fun createPreviewImpl(context: Context): PreviewImpl { + return TextureViewPreview(context, this) + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + if (!isInEditMode) { + mDisplayOrientationDetector!!.enable(ViewCompat.getDisplay(this)!!) + } + } + + override fun onDetachedFromWindow() { + if (!isInEditMode) { + mDisplayOrientationDetector!!.disable() + } + super.onDetachedFromWindow() + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + if (isInEditMode) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + return + } + // Handle android:adjustViewBounds + if (mAdjustViewBounds) { + if (!isCameraOpened) { + mCallbacks!!.reserveRequestLayoutOnOpen() + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + return + } + val widthMode = View.MeasureSpec.getMode(widthMeasureSpec) + val heightMode = View.MeasureSpec.getMode(heightMeasureSpec) + if (widthMode == View.MeasureSpec.EXACTLY && heightMode != View.MeasureSpec.EXACTLY) { + val ratio = aspectRatio!! + var height = (View.MeasureSpec.getSize(widthMeasureSpec) * ratio!!.toFloat()) as Int + if (heightMode == View.MeasureSpec.AT_MOST) { + height = Math.min(height, View.MeasureSpec.getSize(heightMeasureSpec)) + } + super.onMeasure( + widthMeasureSpec, + View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY) + ) + } else if (widthMode != View.MeasureSpec.EXACTLY && heightMode == View.MeasureSpec.EXACTLY) { + val ratio = aspectRatio!! + var width = (View.MeasureSpec.getSize(heightMeasureSpec) * ratio!!.toFloat()) as Int + if (widthMode == View.MeasureSpec.AT_MOST) { + width = Math.min(width, View.MeasureSpec.getSize(widthMeasureSpec)) + } + super.onMeasure( + View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY), + heightMeasureSpec + ) + } else { + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + } + } else { + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + } + // Measure the TextureView + val width = measuredWidth + val height = measuredHeight + var ratio = aspectRatio + if (mDisplayOrientationDetector!!.lastKnownDisplayOrientation % 180 == 0) { + ratio = ratio!!.inverse() + } + assert(ratio != null) + if (height < width * ratio!!.y / ratio!!.x) { + mImpl.view.measure( + View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec( + width * ratio!!.y / ratio!!.x, + View.MeasureSpec.EXACTLY + ) + ) + } else { + mImpl.view.measure( + View.MeasureSpec.makeMeasureSpec( + height * ratio!!.x / ratio!!.y, + View.MeasureSpec.EXACTLY + ), + View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY) + ) + } + } + + override fun onSaveInstanceState(): Parcelable? { + val state = SavedState(super.onSaveInstanceState()) + state.facing = facing + state.ratio = aspectRatio + state.autoFocus = autoFocus + state.flash = flash + return state + } + + override fun onRestoreInstanceState(state: Parcelable?) { + if (state !is SavedState) { + super.onRestoreInstanceState(state) + return + } + val ss = state as SavedState? + super.onRestoreInstanceState(ss!!.getSuperState()) + facing = ss.facing + aspectRatio = ss.ratio + autoFocus = ss.autoFocus + flash = ss.flash + } + + /** + * Open a camera device and start showing camera preview. This is typically called from + * [Activity.onResume]. + */ + fun start() { + if (!mImpl.start()) { + throw IllegalStateException("failed to start even though we're on API 21+") +// //store the state ,and restore this state after fall back o Camera1 +// val state = onSaveInstanceState() +// // Camera2 uses legacy hardware layer; fall back to Camera1 +// mImpl = Camera1(mCallbacks, createPreviewImpl(context)) +// onRestoreInstanceState(state) +// mImpl.start() + } + } + + /** + * Stop camera preview and close the device. This is typically called from + * [Activity.onPause]. + */ + fun stop() { + mImpl.stop() + } + + /** + * Add a new callback. + * + * @param callback The [Callback] to add. + * @see .removeCallback + */ + fun addCallback(@NonNull callback: Callback) { + mCallbacks!!.add(callback) + } + + /** + * Remove a callback. + * + * @param callback The [Callback] to remove. + * @see .addCallback + */ + fun removeCallback(@NonNull callback: Callback) { + mCallbacks!!.remove(callback) + } + + /** + * Take a picture. The result will be returned to + * [Callback.onPictureTaken]. + */ + fun takePicture() { + mImpl.takePicture() + } + + private inner class CallbackBridge internal constructor() : CameraViewImpl.Callback { + + private val mCallbacks = ArrayList() + + private var mRequestLayoutOnOpen: Boolean = false + + fun add(callback: Callback) { + mCallbacks.add(callback) + } + + fun remove(callback: Callback) { + mCallbacks.remove(callback) + } + + override fun onCameraOpened() { + if (mRequestLayoutOnOpen) { + mRequestLayoutOnOpen = false + requestLayout() + } + for (callback in mCallbacks) { + callback.onCameraOpened(this@CameraView) + } + } + + override fun onCameraClosed() { + for (callback in mCallbacks) { + callback.onCameraClosed(this@CameraView) + } + } + + override fun onPictureTaken(data: ByteArray) { + for (callback in mCallbacks) { + callback.onPictureTaken(this@CameraView, data) + } + } + + fun reserveRequestLayoutOnOpen() { + mRequestLayoutOnOpen = true + } + } + + protected class SavedState : View.BaseSavedState { + + @Facing + internal var facing: Int = 0 + + internal var ratio: AspectRatio? = null + + internal var autoFocus: Boolean = false + + @Flash + internal var flash: Int = 0 + + constructor(source: Parcel) : this(source, AspectRatio::class.java.classLoader!!) + + constructor(source: Parcel, loader: ClassLoader) : super(source) { + facing = source.readInt() + ratio = source.readParcelable(loader) + autoFocus = source.readByte().toInt() != 0 + flash = source.readInt() + } + + constructor(parcelable: Parcelable) : super(parcelable) + + override fun writeToParcel(out: Parcel, flags: Int) { + super.writeToParcel(out, flags) + out.writeInt(facing) + out.writeParcelable(ratio, 0) + out.writeByte((if (autoFocus) 1 else 0).toByte()) + out.writeInt(flash) + } + + override fun describeContents(): Int { + return 0 + } + + companion object CREATOR : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel): SavedState { + return SavedState(parcel) + } + + override fun newArray(size: Int): Array { + return arrayOfNulls(size) + } + } + + } + + /** + * Callback for monitoring events about [CameraView]. + */ + abstract class Callback { + + /** + * Called when camera is opened. + * + * @param cameraView The associated [CameraView]. + */ + fun onCameraOpened(cameraView: CameraView) {} + + /** + * Called when camera is closed. + * + * @param cameraView The associated [CameraView]. + */ + fun onCameraClosed(cameraView: CameraView) {} + + /** + * Called when a picture is taken. + * + * @param cameraView The associated [CameraView]. + * @param data JPEG data. + */ + fun onPictureTaken(cameraView: CameraView, data: ByteArray) {} + } + + companion object { + + /** The camera device faces the opposite direction as the device's screen. */ + const val FACING_BACK = Constants.FACING_BACK + + /** The camera device faces the same direction as the device's screen. */ + const val FACING_FRONT = Constants.FACING_FRONT + + /** Flash will not be fired. */ + const val FLASH_OFF = Constants.FLASH_OFF + + /** Flash will always be fired during snapshot. */ + const val FLASH_ON = Constants.FLASH_ON + + /** Constant emission of light during preview, auto-focus and snapshot. */ + const val FLASH_TORCH = Constants.FLASH_TORCH + + /** Flash will be fired automatically when required. */ + const val FLASH_AUTO = Constants.FLASH_AUTO + + /** Flash will be fired in red-eye reduction mode. */ + const val FLASH_RED_EYE = Constants.FLASH_RED_EYE + } + +} diff --git a/zcash-android-wallet-app/qrecycler/src/main/java/cash/z/android/cameraview/DisplayOrientationDetector.kt b/zcash-android-wallet-app/qrecycler/src/main/java/cash/z/android/cameraview/DisplayOrientationDetector.kt new file mode 100644 index 0000000..9d1b4cf --- /dev/null +++ b/zcash-android-wallet-app/qrecycler/src/main/java/cash/z/android/cameraview/DisplayOrientationDetector.kt @@ -0,0 +1,94 @@ +/* + * Copyright (C) 2016 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. + */ + +package cash.z.android.cameraview + +import android.content.Context +import android.util.SparseIntArray +import android.view.Display +import android.view.OrientationEventListener +import android.view.Surface + + +/** + * Monitors the value returned from [Display.getRotation]. + */ +internal abstract class DisplayOrientationDetector(context: Context) { + + private val mOrientationEventListener: OrientationEventListener + + var mDisplay: Display? = null + + var lastKnownDisplayOrientation = 0 + private set + + init { + mOrientationEventListener = object : OrientationEventListener(context) { + + /** This is either Surface.Rotation_0, _90, _180, _270, or -1 (invalid). */ + private var mLastKnownRotation = -1 + + override fun onOrientationChanged(orientation: Int) { + if (orientation == OrientationEventListener.ORIENTATION_UNKNOWN || mDisplay == null) { + return + } + val rotation = mDisplay!!.rotation + if (mLastKnownRotation != rotation) { + mLastKnownRotation = rotation + dispatchOnDisplayOrientationChanged(DISPLAY_ORIENTATIONS.get(rotation)) + } + } + } + } + + fun enable(display: Display) { + mDisplay = display + mOrientationEventListener.enable() + // Immediately dispatch the first callback + dispatchOnDisplayOrientationChanged(DISPLAY_ORIENTATIONS.get(display.rotation)) + } + + fun disable() { + mOrientationEventListener.disable() + mDisplay = null + } + + fun dispatchOnDisplayOrientationChanged(displayOrientation: Int) { + lastKnownDisplayOrientation = displayOrientation + onDisplayOrientationChanged(displayOrientation) + } + + /** + * Called when display orientation is changed. + * + * @param displayOrientation One of 0, 90, 180, and 270. + */ + abstract fun onDisplayOrientationChanged(displayOrientation: Int) + + companion object { + + /** Mapping from Surface.Rotation_n to degrees. */ + val DISPLAY_ORIENTATIONS = SparseIntArray() + + init { + DISPLAY_ORIENTATIONS.put(Surface.ROTATION_0, 0) + DISPLAY_ORIENTATIONS.put(Surface.ROTATION_90, 90) + DISPLAY_ORIENTATIONS.put(Surface.ROTATION_180, 180) + DISPLAY_ORIENTATIONS.put(Surface.ROTATION_270, 270) + } + } + +} diff --git a/zcash-android-wallet-app/qrecycler/src/main/java/cash/z/android/cameraview/TextureViewPreview.kt b/zcash-android-wallet-app/qrecycler/src/main/java/cash/z/android/cameraview/TextureViewPreview.kt new file mode 100644 index 0000000..40cd505 --- /dev/null +++ b/zcash-android-wallet-app/qrecycler/src/main/java/cash/z/android/cameraview/TextureViewPreview.kt @@ -0,0 +1,134 @@ +/* + * Copyright (C) 2016 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. + */ + +package com.google.android.cameraview + + +import cash.z.android.qrecycler.R +import android.annotation.TargetApi +import android.content.Context +import android.graphics.Matrix +import android.graphics.SurfaceTexture +import android.view.Surface +import android.view.TextureView +import android.view.View +import android.view.ViewGroup +import cash.z.android.cameraview.base.PreviewImpl + +@TargetApi(14) +internal class TextureViewPreview(context: Context, parent: ViewGroup) : PreviewImpl() { + + private val mTextureView: TextureView + + private var mDisplayOrientation: Int = 0 + + override val surface: Surface + get() = Surface(mTextureView.surfaceTexture) + + override val surfaceTexture: SurfaceTexture + get() = mTextureView.surfaceTexture + + override val view: View + get() = mTextureView + + override val outputClass: Class<*> + get() = SurfaceTexture::class.java + + override val isReady: Boolean + get() = mTextureView.surfaceTexture != null + + init { + val view = View.inflate(context, R.layout.texture_view, parent) + mTextureView = view.findViewById(R.id.texture_view) as TextureView + mTextureView.surfaceTextureListener = object : TextureView.SurfaceTextureListener { + + override fun onSurfaceTextureAvailable(surface: SurfaceTexture, width: Int, height: Int) { + setSize(width, height) + configureTransform() + dispatchSurfaceChanged() + } + + override fun onSurfaceTextureSizeChanged(surface: SurfaceTexture, width: Int, height: Int) { + setSize(width, height) + configureTransform() + dispatchSurfaceChanged() + } + + override fun onSurfaceTextureDestroyed(surface: SurfaceTexture): Boolean { + setSize(0, 0) + return true + } + + override fun onSurfaceTextureUpdated(surface: SurfaceTexture) {} + } + } + + // This method is called only from Camera2. + @TargetApi(15) + override fun setBufferSize(width: Int, height: Int) { + mTextureView.surfaceTexture.setDefaultBufferSize(width, height) + } + + override fun setDisplayOrientation(displayOrientation: Int) { + mDisplayOrientation = displayOrientation + configureTransform() + } + + /** + * Configures the transform matrix for TextureView based on [.mDisplayOrientation] and + * the surface size. + */ + fun configureTransform() { + val matrix = Matrix() + if (mDisplayOrientation % 180 == 90) { + val width = width.toFloat() + val height = height.toFloat() + // Rotate the camera preview when the screen is landscape. + matrix.setPolyToPoly( + floatArrayOf( + 0f, 0f, // top left + width, 0f, // top right + 0f, height, // bottom left + width, height + )// bottom right + , 0, + if (mDisplayOrientation == 90) + // Clockwise + floatArrayOf( + 0f, height, // top left + 0f, 0f, // top right + width, height, // bottom left + width, 0f + )// bottom right + else + // mDisplayOrientation == 270 + // Counter-clockwise + floatArrayOf( + width, 0f, // top left + width, height, // top right + 0f, 0f, // bottom left + 0f, height + )// bottom right + , 0, + 4 + ) + } else if (mDisplayOrientation == 180) { + matrix.postRotate(180f, width / 2f, height / 2f) + } + mTextureView.setTransform(matrix) + } + +} diff --git a/zcash-android-wallet-app/qrecycler/src/main/java/cash/z/android/cameraview/api21/Camera2.kt b/zcash-android-wallet-app/qrecycler/src/main/java/cash/z/android/cameraview/api21/Camera2.kt new file mode 100644 index 0000000..3a6e39a --- /dev/null +++ b/zcash-android-wallet-app/qrecycler/src/main/java/cash/z/android/cameraview/api21/Camera2.kt @@ -0,0 +1,788 @@ +/* + * Copyright (C) 2016 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. + */ + +package cash.z.android.cameraview.api21 + +import android.Manifest +import android.annotation.TargetApi +import android.content.Context +import android.graphics.ImageFormat +import android.hardware.camera2.* +import android.hardware.camera2.params.StreamConfigurationMap +import android.media.ImageReader +import android.util.Log +import android.util.SparseIntArray +import androidx.annotation.NonNull +import androidx.annotation.RequiresPermission +import cash.z.android.cameraview.base.* +import java.util.* + +@TargetApi(21) +internal open class Camera2(callback: CameraViewImpl.Callback, preview: PreviewImpl, context: Context) : CameraViewImpl(callback, preview) { + + private val mCameraManager: CameraManager + + private val mCameraDeviceCallback = object : CameraDevice.StateCallback() { + + override fun onOpened(@NonNull camera: CameraDevice) { + mCamera = camera + mCallback.onCameraOpened() + startCaptureSession() + } + + override fun onClosed(@NonNull camera: CameraDevice) { + mCallback.onCameraClosed() + } + + override fun onDisconnected(@NonNull camera: CameraDevice) { + mCamera = null + } + + override fun onError(@NonNull camera: CameraDevice, error: Int) { + Log.e(TAG, "onError: " + camera.id + " (" + error + ")") + mCamera = null + } + + } + + private val mSessionCallback = object : CameraCaptureSession.StateCallback() { + + override fun onConfigured(@NonNull session: CameraCaptureSession) { + if (mCamera == null) { + return + } + mCaptureSession = session + updateAutoFocus() + updateFlash() + try { + mCaptureSession!!.setRepeatingRequest( + mPreviewRequestBuilder!!.build(), + mCaptureCallback, null + ) + } catch (e: CameraAccessException) { + Log.e(TAG, "Failed to start camera preview because it couldn't access camera", e) + } catch (e: IllegalStateException) { + Log.e(TAG, "Failed to start camera preview.", e) + } + + } + + override fun onConfigureFailed(@NonNull session: CameraCaptureSession) { + Log.e(TAG, "Failed to configure capture session.") + } + + override fun onClosed(@NonNull session: CameraCaptureSession) { + if (mCaptureSession != null && mCaptureSession == session) { + mCaptureSession = null + } + } + + } + + private var mCaptureCallback: PictureCaptureCallback = object : PictureCaptureCallback() { + + override fun onPrecaptureRequired() { + mPreviewRequestBuilder!!.set( + CaptureRequest.CONTROL_AE_PRECAPTURE_TRIGGER, + CaptureRequest.CONTROL_AE_PRECAPTURE_TRIGGER_START + ) + setState(Camera2.PictureCaptureCallback.STATE_PRECAPTURE) + try { + mCaptureSession!!.capture(mPreviewRequestBuilder!!.build(), this, null) + mPreviewRequestBuilder!!.set( + CaptureRequest.CONTROL_AE_PRECAPTURE_TRIGGER, + CaptureRequest.CONTROL_AE_PRECAPTURE_TRIGGER_IDLE + ) + } catch (e: CameraAccessException) { + Log.e(TAG, "Failed to run precapture sequence.", e) + } + + } + + override fun onReady() { + captureStillPicture() + } + + } + + private val mOnImageAvailableListener = ImageReader.OnImageAvailableListener { reader -> + reader.acquireNextImage().use { image -> + val planes = image.planes + if (planes.size > 0) { + val buffer = planes[0].buffer + val data = ByteArray(buffer.remaining()) + buffer.get(data) + mCallback.onPictureTaken(data) + } + } + } + + + private var mCameraId: String? = null + + private var mCameraCharacteristics: CameraCharacteristics? = null + + var mCamera: CameraDevice? = null + + var mCaptureSession: CameraCaptureSession? = null + + var mPreviewRequestBuilder: CaptureRequest.Builder? = null + + private var mImageReader: ImageReader? = null + + private val mPreviewSizes = SizeMap() + + private val mPictureSizes = SizeMap() + + private var mFacing: Int = 0 + + private var mAspectRatio = Constants.DEFAULT_ASPECT_RATIO + + private var mAutoFocus: Boolean = false + + // Revert + override var flash: Int = 0 + set(flash) { + if (this.flash == flash) { + return + } + val saved = this.flash + field = flash + if (mPreviewRequestBuilder != null) { + updateFlash() + if (mCaptureSession != null) { + try { + mCaptureSession!!.setRepeatingRequest( + mPreviewRequestBuilder!!.build(), + mCaptureCallback, null + ) + } catch (e: CameraAccessException) { + field = saved + } + + } + } + } + + private var mDisplayOrientation: Int = 0 + + override val isCameraOpened: Boolean + get() = mCamera != null + + override var facing: Int + get() = mFacing + @RequiresPermission(Manifest.permission.CAMERA) set(facing) { + if (mFacing == facing) { + return + } + mFacing = facing + if (isCameraOpened) { + stop() + start() + } + } + + override val supportedAspectRatios: Set + get() = mPreviewSizes.ratios() + + // Revert + override var autoFocus: Boolean + get() = mAutoFocus + set(autoFocus) { + if (mAutoFocus == autoFocus) { + return + } + mAutoFocus = autoFocus + if (mPreviewRequestBuilder != null) { + updateAutoFocus() + if (mCaptureSession != null) { + try { + mCaptureSession!!.setRepeatingRequest( + mPreviewRequestBuilder!!.build(), + mCaptureCallback, null + ) + } catch (e: CameraAccessException) { + mAutoFocus = !mAutoFocus + } + + } + } + } + + init { + mCameraManager = context.getSystemService(Context.CAMERA_SERVICE) as CameraManager + mPreview.setCallback(object : PreviewImpl.Callback { + override fun onSurfaceChanged() { + startCaptureSession() + } + }) + } + + @RequiresPermission(Manifest.permission.CAMERA) + override fun start(): Boolean { + if (!chooseCameraIdByFacing()) { + return false + } + collectCameraInfo() + prepareImageReader() + startOpeningCamera() + return true + } + + override fun stop() { + if (mCaptureSession != null) { + mCaptureSession!!.close() + mCaptureSession = null + } + if (mCamera != null) { + mCamera!!.close() + mCamera = null + } + if (mImageReader != null) { + mImageReader!!.close() + mImageReader = null + } + } + + override val aspectRatio: AspectRatio get() = mAspectRatio + + override fun setAspectRatio(ratio: AspectRatio?): Boolean { + if (ratio == null || ratio == mAspectRatio || + !mPreviewSizes.ratios().contains(ratio) + ) { + // TODO: Better error handling + return false + } + mAspectRatio = ratio + prepareImageReader() + if (mCaptureSession != null) { + mCaptureSession!!.close() + mCaptureSession = null + startCaptureSession() + } + return true + } + + override fun takePicture() { + if (mAutoFocus) { + lockFocus() + } else { + captureStillPicture() + } + } + + override fun setDisplayOrientation(displayOrientation: Int) { + mDisplayOrientation = displayOrientation + mPreview.setDisplayOrientation(mDisplayOrientation) + } + + /** + * + * Chooses a camera ID by the specified camera facing ([.mFacing]). + * + * This rewrites [.mCameraId], [.mCameraCharacteristics], and optionally + * [.mFacing]. + */ + private fun chooseCameraIdByFacing(): Boolean { + try { + val internalFacing = INTERNAL_FACINGS.get(mFacing) + val ids = mCameraManager.cameraIdList + if (ids.size == 0) { // No camera + throw RuntimeException("No camera available.") + } + for (id in ids) { + val characteristics = mCameraManager.getCameraCharacteristics(id) + val level = characteristics.get( + CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL + ) + if (level == null || level == CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY) { + continue + } + val internal = characteristics.get(CameraCharacteristics.LENS_FACING) + ?: throw NullPointerException("Unexpected state: LENS_FACING null") + if (internal == internalFacing) { + mCameraId = id + mCameraCharacteristics = characteristics + return true + } + } + // Not found + mCameraId = ids[0] + mCameraCharacteristics = mCameraManager.getCameraCharacteristics(mCameraId!!) + val level = mCameraCharacteristics!!.get( + CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL + ) + if (level == null || level == CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY) { + return false + } + val internal = mCameraCharacteristics!!.get(CameraCharacteristics.LENS_FACING) + ?: throw NullPointerException("Unexpected state: LENS_FACING null") + var i = 0 + val count = INTERNAL_FACINGS.size() + while (i < count) { + if (INTERNAL_FACINGS.valueAt(i) == internal) { + mFacing = INTERNAL_FACINGS.keyAt(i) + return true + } + i++ + } + // The operation can reach here when the only camera device is an external one. + // We treat it as facing back. + mFacing = Constants.FACING_BACK + return true + } catch (e: CameraAccessException) { + throw RuntimeException("Failed to get a list of camera devices", e) + } + + } + + /** + * + * Collects some information from [.mCameraCharacteristics]. + * + * This rewrites [.mPreviewSizes], [.mPictureSizes], and optionally, + * [.mAspectRatio]. + */ + private fun collectCameraInfo() { + val map = mCameraCharacteristics!!.get( + CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP + ) ?: throw IllegalStateException("Failed to get configuration map: " + mCameraId!!) + mPreviewSizes.clear() + for (size in map.getOutputSizes(mPreview.outputClass)) { + val width = size.width + val height = size.height + if (width <= MAX_PREVIEW_WIDTH && height <= MAX_PREVIEW_HEIGHT) { + mPreviewSizes.add(Size(width, height)) + } + } + mPictureSizes.clear() + collectPictureSizes(mPictureSizes, map) + for (ratio in mPreviewSizes.ratios()) { + if (!mPictureSizes.ratios().contains(ratio)) { + mPreviewSizes.remove(ratio) + } + } + + if (!mPreviewSizes.ratios().contains(mAspectRatio)) { + mAspectRatio = mPreviewSizes.ratios().iterator().next() + } + } + + protected open fun collectPictureSizes(sizes: SizeMap, map: StreamConfigurationMap) { + val outputSizes = map.getOutputSizes(ImageFormat.JPEG) + for (size in outputSizes) { + mPictureSizes.add(Size(size.width, size.height)) + } + } + + private fun prepareImageReader() { + if (mImageReader != null) { + mImageReader!!.close() + } + val largest = mPictureSizes.sizes(mAspectRatio).last() + mImageReader = ImageReader.newInstance( + largest.width, largest.height, + ImageFormat.JPEG, /* maxImages */ 2 + ) + mImageReader!!.setOnImageAvailableListener(mOnImageAvailableListener, null) + } + + /** + * + * Starts opening a camera device. + * + * The result will be processed in [.mCameraDeviceCallback]. + */ + @RequiresPermission(Manifest.permission.CAMERA) + private fun startOpeningCamera() { + try { + mCameraManager.openCamera(mCameraId!!, mCameraDeviceCallback, null) + } catch (e: CameraAccessException) { + throw RuntimeException("Failed to open camera: " + mCameraId!!, e) + } + + } + + /** + * + * Starts a capture session for camera preview. + * + * This rewrites [.mPreviewRequestBuilder]. + * + * The result will be continuously processed in [.mSessionCallback]. + */ + fun startCaptureSession() { + if (!isCameraOpened || !mPreview.isReady || mImageReader == null) { + return + } + val previewSize = chooseOptimalSize() + mPreview.setBufferSize(previewSize.width, previewSize.height) + val surface = mPreview.surface + try { + mPreviewRequestBuilder = mCamera!!.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW) + mPreviewRequestBuilder!!.addTarget(surface) + mCamera!!.createCaptureSession( + Arrays.asList(surface, mImageReader!!.surface), + mSessionCallback, null + ) + } catch (e: CameraAccessException) { + throw RuntimeException("Failed to start camera session") + } + + } + + /** + * Chooses the optimal preview size based on [.mPreviewSizes] and the surface size. + * + * @return The picked size for camera preview. + */ + private fun chooseOptimalSize(): Size { + val surfaceLonger: Int + val surfaceShorter: Int + val surfaceWidth = mPreview.width + val surfaceHeight = mPreview.height + if (surfaceWidth < surfaceHeight) { + surfaceLonger = surfaceHeight + surfaceShorter = surfaceWidth + } else { + surfaceLonger = surfaceWidth + surfaceShorter = surfaceHeight + } + val candidates = mPreviewSizes.sizes(mAspectRatio) + + // Pick the smallest of those big enough + for (size in candidates) { + if (size.width >= surfaceLonger && size.height >= surfaceShorter) { + return size + } + } + // If no size is big enough, pick the largest one. + return candidates.last() + } + + /** + * Updates the internal state of auto-focus to [.mAutoFocus]. + */ + fun updateAutoFocus() { + if (mAutoFocus) { + val modes = mCameraCharacteristics!!.get( + CameraCharacteristics.CONTROL_AF_AVAILABLE_MODES + ) + // Auto focus is not supported + if (modes == null || modes.size == 0 || + modes.size == 1 && modes[0] == CameraCharacteristics.CONTROL_AF_MODE_OFF + ) { + mAutoFocus = false + mPreviewRequestBuilder!!.set( + CaptureRequest.CONTROL_AF_MODE, + CaptureRequest.CONTROL_AF_MODE_OFF + ) + } else { + mPreviewRequestBuilder!!.set( + CaptureRequest.CONTROL_AF_MODE, + CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE + ) + } + } else { + mPreviewRequestBuilder!!.set( + CaptureRequest.CONTROL_AF_MODE, + CaptureRequest.CONTROL_AF_MODE_OFF + ) + } + } + + /** + * Updates the internal state of flash to [.mFlash]. + */ + fun updateFlash() { + when (flash) { + Constants.FLASH_OFF -> { + mPreviewRequestBuilder!!.set( + CaptureRequest.CONTROL_AE_MODE, + CaptureRequest.CONTROL_AE_MODE_ON + ) + mPreviewRequestBuilder!!.set( + CaptureRequest.FLASH_MODE, + CaptureRequest.FLASH_MODE_OFF + ) + } + Constants.FLASH_ON -> { + mPreviewRequestBuilder!!.set( + CaptureRequest.CONTROL_AE_MODE, + CaptureRequest.CONTROL_AE_MODE_ON_ALWAYS_FLASH + ) + mPreviewRequestBuilder!!.set( + CaptureRequest.FLASH_MODE, + CaptureRequest.FLASH_MODE_OFF + ) + } + Constants.FLASH_TORCH -> { + mPreviewRequestBuilder!!.set( + CaptureRequest.CONTROL_AE_MODE, + CaptureRequest.CONTROL_AE_MODE_ON + ) + mPreviewRequestBuilder!!.set( + CaptureRequest.FLASH_MODE, + CaptureRequest.FLASH_MODE_TORCH + ) + } + Constants.FLASH_AUTO -> { + mPreviewRequestBuilder!!.set( + CaptureRequest.CONTROL_AE_MODE, + CaptureRequest.CONTROL_AE_MODE_ON_AUTO_FLASH + ) + mPreviewRequestBuilder!!.set( + CaptureRequest.FLASH_MODE, + CaptureRequest.FLASH_MODE_OFF + ) + } + Constants.FLASH_RED_EYE -> { + mPreviewRequestBuilder!!.set( + CaptureRequest.CONTROL_AE_MODE, + CaptureRequest.CONTROL_AE_MODE_ON_AUTO_FLASH_REDEYE + ) + mPreviewRequestBuilder!!.set( + CaptureRequest.FLASH_MODE, + CaptureRequest.FLASH_MODE_OFF + ) + } + } + } + + /** + * Locks the focus as the first step for a still image capture. + */ + private fun lockFocus() { + mPreviewRequestBuilder!!.set( + CaptureRequest.CONTROL_AF_TRIGGER, + CaptureRequest.CONTROL_AF_TRIGGER_START + ) + try { + mCaptureCallback.setState(PictureCaptureCallback.STATE_LOCKING) + mCaptureSession!!.capture(mPreviewRequestBuilder!!.build(), mCaptureCallback, null) + } catch (e: CameraAccessException) { + Log.e(TAG, "Failed to lock focus.", e) + } + + } + + /** + * Captures a still picture. + */ + fun captureStillPicture() { + try { + val captureRequestBuilder = mCamera!!.createCaptureRequest( + CameraDevice.TEMPLATE_STILL_CAPTURE + ) + captureRequestBuilder.addTarget(mImageReader!!.surface) + captureRequestBuilder.set( + CaptureRequest.CONTROL_AF_MODE, + mPreviewRequestBuilder!!.get(CaptureRequest.CONTROL_AF_MODE) + ) + when (flash) { + Constants.FLASH_OFF -> { + captureRequestBuilder.set( + CaptureRequest.CONTROL_AE_MODE, + CaptureRequest.CONTROL_AE_MODE_ON + ) + captureRequestBuilder.set( + CaptureRequest.FLASH_MODE, + CaptureRequest.FLASH_MODE_OFF + ) + } + Constants.FLASH_ON -> captureRequestBuilder.set( + CaptureRequest.CONTROL_AE_MODE, + CaptureRequest.CONTROL_AE_MODE_ON_ALWAYS_FLASH + ) + Constants.FLASH_TORCH -> { + captureRequestBuilder.set( + CaptureRequest.CONTROL_AE_MODE, + CaptureRequest.CONTROL_AE_MODE_ON + ) + captureRequestBuilder.set( + CaptureRequest.FLASH_MODE, + CaptureRequest.FLASH_MODE_TORCH + ) + } + Constants.FLASH_AUTO -> captureRequestBuilder.set( + CaptureRequest.CONTROL_AE_MODE, + CaptureRequest.CONTROL_AE_MODE_ON_AUTO_FLASH + ) + Constants.FLASH_RED_EYE -> captureRequestBuilder.set( + CaptureRequest.CONTROL_AE_MODE, + CaptureRequest.CONTROL_AE_MODE_ON_AUTO_FLASH + ) + } + // Calculate JPEG orientation. + val sensorOrientation = mCameraCharacteristics!!.get( + CameraCharacteristics.SENSOR_ORIENTATION + )!! + captureRequestBuilder.set( + CaptureRequest.JPEG_ORIENTATION, + (sensorOrientation + + mDisplayOrientation * (if (mFacing == Constants.FACING_FRONT) 1 else -1) + + 360) % 360 + ) + // Stop preview and capture a still picture. + mCaptureSession!!.stopRepeating() + mCaptureSession!!.capture(captureRequestBuilder.build(), + object : CameraCaptureSession.CaptureCallback() { + override fun onCaptureCompleted( + @NonNull session: CameraCaptureSession, + @NonNull request: CaptureRequest, + @NonNull result: TotalCaptureResult + ) { + unlockFocus() + } + }, null + ) + } catch (e: CameraAccessException) { + Log.e(TAG, "Cannot capture a still picture.", e) + } + + } + + /** + * Unlocks the auto-focus and restart camera preview. This is supposed to be called after + * capturing a still picture. + */ + fun unlockFocus() { + mPreviewRequestBuilder!!.set( + CaptureRequest.CONTROL_AF_TRIGGER, + CaptureRequest.CONTROL_AF_TRIGGER_CANCEL + ) + try { + mCaptureSession!!.capture(mPreviewRequestBuilder!!.build(), mCaptureCallback, null) + updateAutoFocus() + updateFlash() + mPreviewRequestBuilder!!.set( + CaptureRequest.CONTROL_AF_TRIGGER, + CaptureRequest.CONTROL_AF_TRIGGER_IDLE + ) + mCaptureSession!!.setRepeatingRequest(mPreviewRequestBuilder!!.build(), mCaptureCallback, null) + mCaptureCallback.setState(PictureCaptureCallback.STATE_PREVIEW) + } catch (e: CameraAccessException) { + Log.e(TAG, "Failed to restart camera preview.", e) + } + + } + + /** + * A [CameraCaptureSession.CaptureCallback] for capturing a still picture. + */ + private abstract class PictureCaptureCallback internal constructor() : CameraCaptureSession.CaptureCallback() { + + private var mState: Int = 0 + + internal fun setState(state: Int) { + mState = state + } + + override fun onCaptureProgressed( + @NonNull session: CameraCaptureSession, + @NonNull request: CaptureRequest, @NonNull partialResult: CaptureResult + ) { + process(partialResult) + } + + override fun onCaptureCompleted( + @NonNull session: CameraCaptureSession, + @NonNull request: CaptureRequest, @NonNull result: TotalCaptureResult + ) { + process(result) + } + + private fun process(@NonNull result: CaptureResult) { + when (mState) { + STATE_LOCKING -> { + val af = result.get(CaptureResult.CONTROL_AF_STATE) + if (af != null) { + if (af == CaptureResult.CONTROL_AF_STATE_FOCUSED_LOCKED || af == CaptureResult.CONTROL_AF_STATE_NOT_FOCUSED_LOCKED) { + val ae = result.get(CaptureResult.CONTROL_AE_STATE) + if (ae == null || ae == CaptureResult.CONTROL_AE_STATE_CONVERGED) { + setState(STATE_CAPTURING) + onReady() + } else { + setState(STATE_LOCKED) + onPrecaptureRequired() + } + } + } + } + STATE_PRECAPTURE -> { + val ae = result.get(CaptureResult.CONTROL_AE_STATE) + if (ae == null || ae == CaptureResult.CONTROL_AE_STATE_PRECAPTURE || + ae == CaptureRequest.CONTROL_AE_STATE_FLASH_REQUIRED || + ae == CaptureResult.CONTROL_AE_STATE_CONVERGED + ) { + setState(STATE_WAITING) + } + } + STATE_WAITING -> { + val ae = result.get(CaptureResult.CONTROL_AE_STATE) + if (ae == null || ae != CaptureResult.CONTROL_AE_STATE_PRECAPTURE) { + setState(STATE_CAPTURING) + onReady() + } + } + } + } + + /** + * Called when it is ready to take a still picture. + */ + abstract fun onReady() + + /** + * Called when it is necessary to run the precapture sequence. + */ + abstract fun onPrecaptureRequired() + + companion object { + + internal val STATE_PREVIEW = 0 + internal val STATE_LOCKING = 1 + internal val STATE_LOCKED = 2 + internal val STATE_PRECAPTURE = 3 + internal val STATE_WAITING = 4 + internal val STATE_CAPTURING = 5 + } + + } + + companion object { + + private val TAG = "Camera2" + + private val INTERNAL_FACINGS = SparseIntArray() + + init { + INTERNAL_FACINGS.put(Constants.FACING_BACK, CameraCharacteristics.LENS_FACING_BACK) + INTERNAL_FACINGS.put(Constants.FACING_FRONT, CameraCharacteristics.LENS_FACING_FRONT) + } + + /** + * Max preview width that is guaranteed by Camera2 API + */ + private val MAX_PREVIEW_WIDTH = 1920 + + /** + * Max preview height that is guaranteed by Camera2 API + */ + private val MAX_PREVIEW_HEIGHT = 1080 + } + +} diff --git a/zcash-android-wallet-app/qrecycler/src/main/java/cash/z/android/cameraview/api23/Camera2Api23.kt b/zcash-android-wallet-app/qrecycler/src/main/java/cash/z/android/cameraview/api23/Camera2Api23.kt new file mode 100644 index 0000000..42ed7a1 --- /dev/null +++ b/zcash-android-wallet-app/qrecycler/src/main/java/cash/z/android/cameraview/api23/Camera2Api23.kt @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2016 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. + */ + +package com.google.android.cameraview + +import android.annotation.TargetApi +import android.content.Context +import android.graphics.ImageFormat +import android.hardware.camera2.params.StreamConfigurationMap +import cash.z.android.cameraview.CameraView +import cash.z.android.cameraview.api21.Camera2 +import cash.z.android.cameraview.base.CameraViewImpl +import cash.z.android.cameraview.base.PreviewImpl +import cash.z.android.cameraview.base.Size +import cash.z.android.cameraview.base.SizeMap + + +@TargetApi(23) +internal class Camera2Api23(callback: CameraViewImpl.Callback, preview: PreviewImpl, context: Context) : + Camera2(callback, preview, context) { + + protected override fun collectPictureSizes(sizes: SizeMap, map: StreamConfigurationMap) { + // Try to get hi-res output sizes + val outputSizes = map.getHighResolutionOutputSizes(ImageFormat.JPEG) + if (outputSizes != null) { + for (size in map.getHighResolutionOutputSizes(ImageFormat.JPEG)) { + sizes.add(Size(size.width, size.height)) + } + } + if (sizes.isEmpty) { + super.collectPictureSizes(sizes, map) + } + } + +} diff --git a/zcash-android-wallet-app/qrecycler/src/main/java/cash/z/android/cameraview/base/AspectRatio.kt b/zcash-android-wallet-app/qrecycler/src/main/java/cash/z/android/cameraview/base/AspectRatio.kt new file mode 100644 index 0000000..fcf7681 --- /dev/null +++ b/zcash-android-wallet-app/qrecycler/src/main/java/cash/z/android/cameraview/base/AspectRatio.kt @@ -0,0 +1,169 @@ +/* + * Copyright (C) 2016 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. + */ + +package cash.z.android.cameraview.base + +import android.os.Parcel +import android.os.Parcelable +import androidx.annotation.NonNull +import androidx.collection.SparseArrayCompat + +/** + * Immutable class for describing proportional relationship between width and height. + */ +class AspectRatio private constructor(val x: Int, val y: Int) : Comparable, Parcelable { + + constructor(parcel: Parcel) : this( + parcel.readInt(), + parcel.readInt() + ) { + } + + fun matches(size: Size): Boolean { + val gcd = gcd(size.width, size.height) + val x = size.width / gcd + val y = size.height / gcd + return this.x == x && this.y == y + } + + override fun equals(o: Any?): Boolean { + if (o == null) { + return false + } + if (this === o) { + return true + } + if (o is AspectRatio) { + val ratio = o as AspectRatio? + return x == ratio!!.x && y == ratio.y + } + return false + } + + override fun toString(): String { + return x.toString() + ":" + y + } + + fun toFloat(): Float { + return x.toFloat() / y + } + + override fun hashCode(): Int { + // assuming most sizes are <2^16, doing a rotate will give us perfect hashing + return y xor (x shl Integer.SIZE / 2 or x.ushr(Integer.SIZE / 2)) + } + + override fun compareTo(@NonNull another: AspectRatio): Int { + if (equals(another)) { + return 0 + } else if (toFloat() - another.toFloat() > 0) { + return 1 + } + return -1 + } + + /** + * @return The inverse of this [AspectRatio]. + */ + fun inverse(): AspectRatio { + + return AspectRatio.of(y, x) + } + + override fun describeContents(): Int { + return 0 + } + + override fun writeToParcel(dest: Parcel, flags: Int) { + dest.writeInt(x) + dest.writeInt(y) + } + companion object CREATOR : Parcelable.Creator { + + private val sCache = SparseArrayCompat>(16) + + /** + * Returns an instance of [AspectRatio] specified by `x` and `y` values. + * The values `x` and `` will be reduced by their greatest common divider. + * + * @param x The width + * @param y The height + * @return An instance of [AspectRatio] + */ + fun of(inX: Int, inY: Int): AspectRatio { + var x = inX + var y = inY + val gcd = gcd(x, y) + x /= gcd + y /= gcd + var arrayX: SparseArrayCompat? = sCache.get(x) + if (arrayX == null) { + val ratio = AspectRatio(x, y) + arrayX = SparseArrayCompat() + arrayX!!.put(y, ratio) + sCache.put(x, arrayX) + return ratio + } else { + var ratio = arrayX!!.get(y) + if (ratio == null) { + ratio = AspectRatio(x, y) + arrayX!!.put(y, ratio) + } + return ratio + } + } + + /** + * Parse an [AspectRatio] from a [String] formatted like "4:3". + * + * @param s The string representation of the aspect ratio + * @return The aspect ratio + * @throws IllegalArgumentException when the format is incorrect. + */ + fun parse(s: String): AspectRatio { + val position = s.indexOf(':') + if (position == -1) { + throw IllegalArgumentException("Malformed aspect ratio: $s") + } + try { + val x = Integer.parseInt(s.substring(0, position)) + val y = Integer.parseInt(s.substring(position + 1)) + return AspectRatio.of(x, y) + } catch (e: NumberFormatException) { + throw IllegalArgumentException("Malformed aspect ratio: $s", e) + } + + } + + private fun gcd(a: Int, b: Int): Int { + var a = a + var b = b + while (b != 0) { + val c = b + b = a % b + a = c + } + return a + } + override fun createFromParcel(parcel: Parcel): AspectRatio { + return AspectRatio(parcel) + } + + override fun newArray(size: Int): Array { + return arrayOfNulls(size) + } + } +} diff --git a/zcash-android-wallet-app/qrecycler/src/main/java/cash/z/android/cameraview/base/CameraViewImpl.kt b/zcash-android-wallet-app/qrecycler/src/main/java/cash/z/android/cameraview/base/CameraViewImpl.kt new file mode 100644 index 0000000..4e42a99 --- /dev/null +++ b/zcash-android-wallet-app/qrecycler/src/main/java/cash/z/android/cameraview/base/CameraViewImpl.kt @@ -0,0 +1,64 @@ +/* + * Copyright (C) 2016 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. + */ + +package cash.z.android.cameraview.base + +import android.view.View + +internal abstract class CameraViewImpl(protected val mCallback: Callback, protected val mPreview: PreviewImpl) { + + val view: View + get() = mPreview.view + + internal abstract val isCameraOpened: Boolean + + internal abstract var facing: Int + + internal abstract val supportedAspectRatios: Set + + internal abstract val aspectRatio: AspectRatio + + internal abstract var autoFocus: Boolean + + internal abstract var flash: Int + + /** + * @return `true` if the implementation was able to start the camera session. + */ + internal abstract fun start(): Boolean + + internal abstract fun stop() + + /** + * @return `true` if the aspect ratio was changed. + */ + internal abstract fun setAspectRatio(ratio: AspectRatio?): Boolean + + internal abstract fun takePicture() + + internal abstract fun setDisplayOrientation(displayOrientation: Int) + + internal interface Callback { + + fun onCameraOpened() + + fun onCameraClosed() + + fun onPictureTaken(data: ByteArray) + + } + +} diff --git a/zcash-android-wallet-app/qrecycler/src/main/java/cash/z/android/cameraview/base/Constants.kt b/zcash-android-wallet-app/qrecycler/src/main/java/cash/z/android/cameraview/base/Constants.kt new file mode 100644 index 0000000..5911b0d --- /dev/null +++ b/zcash-android-wallet-app/qrecycler/src/main/java/cash/z/android/cameraview/base/Constants.kt @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2016 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. + */ + +package cash.z.android.cameraview.base + + +interface Constants { + companion object { + + val DEFAULT_ASPECT_RATIO = AspectRatio.of(4, 3) + + const val FACING_BACK = 0 + const val FACING_FRONT = 1 + + const val FLASH_OFF = 0 + const val FLASH_ON = 1 + const val FLASH_TORCH = 2 + const val FLASH_AUTO = 3 + const val FLASH_RED_EYE = 4 + + const val LANDSCAPE_90 = 90 + const val LANDSCAPE_270 = 270 + } +} diff --git a/zcash-android-wallet-app/qrecycler/src/main/java/cash/z/android/cameraview/base/PreviewImpl.kt b/zcash-android-wallet-app/qrecycler/src/main/java/cash/z/android/cameraview/base/PreviewImpl.kt new file mode 100644 index 0000000..815a3fd --- /dev/null +++ b/zcash-android-wallet-app/qrecycler/src/main/java/cash/z/android/cameraview/base/PreviewImpl.kt @@ -0,0 +1,72 @@ +/* + * Copyright (C) 2016 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. + */ + +package cash.z.android.cameraview.base + +import android.view.Surface +import android.view.SurfaceHolder +import android.view.View + + +/** + * Encapsulates all the operations related to camera preview in a backward-compatible manner. + */ +internal abstract class PreviewImpl { + + private var mCallback: Callback? = null + + var width: Int = 0 + private set + + var height: Int = 0 + private set + + internal abstract val surface: Surface + + internal abstract val view: View + + internal abstract val outputClass: Class<*> + + internal abstract val isReady: Boolean + + val surfaceHolder: SurfaceHolder? + get() = null + + open val surfaceTexture: Any? + get() = null + + internal interface Callback { + fun onSurfaceChanged() + } + + fun setCallback(callback: Callback) { + mCallback = callback + } + + internal abstract fun setDisplayOrientation(displayOrientation: Int) + + protected fun dispatchSurfaceChanged() { + mCallback!!.onSurfaceChanged() + } + + open fun setBufferSize(width: Int, height: Int) {} + + fun setSize(width: Int, height: Int) { + this.width = width + this.height = height + } + +} diff --git a/zcash-android-wallet-app/qrecycler/src/main/java/cash/z/android/cameraview/base/Size.kt b/zcash-android-wallet-app/qrecycler/src/main/java/cash/z/android/cameraview/base/Size.kt new file mode 100644 index 0000000..d1c97bc --- /dev/null +++ b/zcash-android-wallet-app/qrecycler/src/main/java/cash/z/android/cameraview/base/Size.kt @@ -0,0 +1,61 @@ +/* + * Copyright (C) 2016 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. + */ + +package cash.z.android.cameraview.base + +import androidx.annotation.NonNull + + +/** + * Immutable class for describing width and height dimensions in pixels. + */ + +/** + * Create a new immutable Size instance. + * + * @param width The width of the size, in pixels + * @param height The height of the size, in pixels + */ +class Size(val width: Int, val height: Int) : Comparable { + + override fun equals(o: Any?): Boolean { + if (o == null) { + return false + } + if (this === o) { + return true + } + if (o is Size) { + val size = o as Size? + return width == size!!.width && height == size.height + } + return false + } + + override fun toString(): String { + return width.toString() + "x" + height + } + + override fun hashCode(): Int { + // assuming most sizes are <2^16, doing a rotate will give us perfect hashing + return height xor (width shl Integer.SIZE / 2 or width.ushr(Integer.SIZE / 2)) + } + + override fun compareTo(@NonNull another: Size): Int { + return width * height - another.width * another.height + } + +} diff --git a/zcash-android-wallet-app/qrecycler/src/main/java/cash/z/android/cameraview/base/SizeMap.kt b/zcash-android-wallet-app/qrecycler/src/main/java/cash/z/android/cameraview/base/SizeMap.kt new file mode 100644 index 0000000..282d079 --- /dev/null +++ b/zcash-android-wallet-app/qrecycler/src/main/java/cash/z/android/cameraview/base/SizeMap.kt @@ -0,0 +1,81 @@ +/* + * Copyright (C) 2016 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. + */ + +package cash.z.android.cameraview.base + + +import androidx.collection.ArrayMap +import java.util.SortedSet +import java.util.TreeSet + +/** + * A collection class that automatically groups [Size]s by their [AspectRatio]s. + */ +internal class SizeMap { + + private val mRatios = ArrayMap>() + + val isEmpty: Boolean + get() = mRatios.isEmpty() + + /** + * Add a new [Size] to this collection. + * + * @param size The size to add. + * @return `true` if it is added, `false` if it already exists and is not added. + */ + fun add(size: Size): Boolean { + for (ratio in mRatios.keys) { + if (ratio.matches(size)) { + val sizes = mRatios[ratio] + if (sizes?.contains(size) == true) { + return false + } else { + if(sizes == null) mRatios[ratio] = TreeSet().let { it.add(size); it } + else sizes.add(size) + return true + } + } + } + // None of the existing ratio matches the provided size; add a new key + val sizes = TreeSet() + sizes.add(size) + mRatios.put(AspectRatio.of(size.width, size.height), sizes) + return true + } + + /** + * Removes the specified aspect ratio and all sizes associated with it. + * + * @param ratio The aspect ratio to be removed. + */ + fun remove(ratio: AspectRatio) { + mRatios.remove(ratio) + } + + fun ratios(): Set { + return mRatios.keys + } + + fun sizes(ratio: AspectRatio): SortedSet { + return mRatios[ratio]!! + } + + fun clear() { + mRatios.clear() + } + +} diff --git a/zcash-android-wallet-app/qrecycler/src/main/java/cash/z/android/qrecycler/QScanner.kt b/zcash-android-wallet-app/qrecycler/src/main/java/cash/z/android/qrecycler/QScanner.kt new file mode 100644 index 0000000..738c330 --- /dev/null +++ b/zcash-android-wallet-app/qrecycler/src/main/java/cash/z/android/qrecycler/QScanner.kt @@ -0,0 +1,8 @@ +package cash.z.android.qrecycler + +/** + * An interface to allow for plugging in any scanner + */ +interface QScanner { + fun scanBarcode(callback: (Result) -> Unit) +} \ No newline at end of file diff --git a/zcash-android-wallet-app/qrecycler/src/main/java/cash/z/android/vision/BarcodeGraphic.kt b/zcash-android-wallet-app/qrecycler/src/main/java/cash/z/android/vision/BarcodeGraphic.kt new file mode 100644 index 0000000..5c7bca9 --- /dev/null +++ b/zcash-android-wallet-app/qrecycler/src/main/java/cash/z/android/vision/BarcodeGraphic.kt @@ -0,0 +1,47 @@ +package cash.z.android.wallet.ui.util.vision + + +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.RectF +import com.google.firebase.ml.vision.barcode.FirebaseVisionBarcode + +class BarcodeGraphic(overlay: GraphicOverlay, private val barcode: FirebaseVisionBarcode) : + GraphicOverlay.Graphic(overlay) { + + private var rectPaint = Paint().apply { + color = TEXT_COLOR + style = Paint.Style.STROKE + strokeWidth = STROKE_WIDTH + } + + private var barcodePaint = Paint().apply { + color = TEXT_COLOR + textSize = TEXT_SIZE + } + + /** + * Draws the barcode block annotations for position, size, and raw value on the supplied canvas. + */ + override fun draw(canvas: Canvas) { + // Draws the bounding box around the BarcodeBlock. + val rect = RectF(barcode.boundingBox) + rect.left = translateX(rect.left) + rect.top = translateY(rect.top) + rect.right = translateX(rect.right) + rect.bottom = translateY(rect.bottom) + canvas.drawRect(rect, rectPaint) + + // Renders the barcode at the bottom of the box. + barcode.rawValue?.let { value -> + canvas.drawText(value, rect.left, rect.bottom, barcodePaint) + } + } + + companion object { + private const val TEXT_COLOR = Color.WHITE + private const val TEXT_SIZE = 54.0f + private const val STROKE_WIDTH = 4.0f + } +} \ No newline at end of file diff --git a/zcash-android-wallet-app/qrecycler/src/main/java/cash/z/android/vision/BarcodeScanningProcessor.kt b/zcash-android-wallet-app/qrecycler/src/main/java/cash/z/android/vision/BarcodeScanningProcessor.kt new file mode 100644 index 0000000..723af1c --- /dev/null +++ b/zcash-android-wallet-app/qrecycler/src/main/java/cash/z/android/vision/BarcodeScanningProcessor.kt @@ -0,0 +1,66 @@ +package cash.z.android.wallet.ui.util.vision + +import android.graphics.Bitmap +import android.util.Log +import cash.z.android.wallet.ui.util.VisionProcessorBase +import com.google.android.gms.tasks.Task +import com.google.firebase.ml.vision.FirebaseVision +import com.google.firebase.ml.vision.barcode.FirebaseVisionBarcode +import com.google.firebase.ml.vision.barcode.FirebaseVisionBarcodeDetector +import com.google.firebase.ml.vision.common.FirebaseVisionImage +import java.io.IOException + +/** Barcode Detector Demo. */ +class BarcodeScanningProcessor : VisionProcessorBase>() { + + // 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. + // FirebaseVisionBarcodeDetectorOptions.Builder() + // .setBarcodeFormats(FirebaseVisionBarcode.FORMAT_QR_CODE) + // .build() + public val detector: FirebaseVisionBarcodeDetector by lazy { + FirebaseVision.getInstance().visionBarcodeDetector + } + + + override fun stop() { + try { + detector.close() + } catch (e: IOException) { + Log.e(TAG, "Exception thrown while trying to close Barcode Detector: $e") + } + } + + override fun detectInImage(image: FirebaseVisionImage): Task> { + return detector.detectInImage(image) + } + + override fun onSuccess( + originalCameraImage: Bitmap?, + barcodes: List, + frameMetadata: FrameMetadata, + graphicOverlay: GraphicOverlay + ) { + graphicOverlay.clear() + + originalCameraImage?.let { + val imageGraphic = CameraImageGraphic(graphicOverlay, it) + graphicOverlay.add(imageGraphic) + } + + barcodes.forEach { + val barcodeGraphic = BarcodeGraphic(graphicOverlay, it) + graphicOverlay.add(barcodeGraphic) + } + graphicOverlay.postInvalidate() + } + + override fun onFailure(e: Exception) { + Log.e(TAG, "Barcode detection failed $e") + } + + companion object { + + private const val TAG = "BarcodeScanProc" + } +} \ No newline at end of file diff --git a/zcash-android-wallet-app/qrecycler/src/main/java/cash/z/android/vision/BitmapUtils.kt b/zcash-android-wallet-app/qrecycler/src/main/java/cash/z/android/vision/BitmapUtils.kt new file mode 100644 index 0000000..c34f5e5 --- /dev/null +++ b/zcash-android-wallet-app/qrecycler/src/main/java/cash/z/android/vision/BitmapUtils.kt @@ -0,0 +1,75 @@ +package cash.z.android.wallet.ui.util.vision + +// Copyright 2018 Google LLC +// +// 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. + + +import android.graphics.* +import android.hardware.Camera.CameraInfo +import android.util.Log +import androidx.annotation.Nullable +import com.google.firebase.ml.vision.common.FirebaseVisionImageMetadata +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer + +/** Utils functions for bitmap conversions. */ +object BitmapUtils { + + // Convert NV21 format byte buffer to bitmap. + @Nullable + fun getBitmap(data: ByteBuffer, metadata: FrameMetadata): Bitmap? { + data.rewind() + val imageInBuffer = ByteArray(data.limit()) + data.get(imageInBuffer, 0, imageInBuffer.size) + try { + val image = YuvImage( + imageInBuffer, ImageFormat.NV21, metadata.width, metadata.height, null + ) + if (image != null) { + val stream = ByteArrayOutputStream() + image.compressToJpeg(Rect(0, 0, metadata.width, metadata.height), 80, stream) + + val bmp = BitmapFactory.decodeByteArray(stream.toByteArray(), 0, stream.size()) + + stream.close() + return rotateBitmap( + bmp, + metadata.rotation, + metadata.cameraFacing + ) + } + } catch (e: Exception) { + Log.e("VisionProcessorBase", "Error: " + e.message) + } + + return null + } + + // Rotates a bitmap if it is converted from a bytebuffer. + private fun rotateBitmap(bitmap: Bitmap, rotation: Int, facing: Int): Bitmap { + val matrix = Matrix() + var rotationDegree = 0 + when (rotation) { + FirebaseVisionImageMetadata.ROTATION_90 -> rotationDegree = 90 + FirebaseVisionImageMetadata.ROTATION_180 -> rotationDegree = 180 + FirebaseVisionImageMetadata.ROTATION_270 -> rotationDegree = 270 + else -> { + } + } + + // Rotate the image back to straight.} + matrix.postRotate(rotationDegree.toFloat()) + return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true) + } +} diff --git a/zcash-android-wallet-app/qrecycler/src/main/java/cash/z/android/vision/CameraImageGraphic.kt b/zcash-android-wallet-app/qrecycler/src/main/java/cash/z/android/vision/CameraImageGraphic.kt new file mode 100644 index 0000000..607cd09 --- /dev/null +++ b/zcash-android-wallet-app/qrecycler/src/main/java/cash/z/android/vision/CameraImageGraphic.kt @@ -0,0 +1,15 @@ +package cash.z.android.wallet.ui.util.vision + + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Rect +import cash.z.android.wallet.ui.util.vision.GraphicOverlay.Graphic + +/** Draw camera image to background. */ +class CameraImageGraphic(overlay: GraphicOverlay, private val bitmap: Bitmap) : Graphic(overlay) { + + override fun draw(canvas: Canvas) { + canvas.drawBitmap(bitmap, null, Rect(0, 0, canvas.width, canvas.height), null) + } +} diff --git a/zcash-android-wallet-app/qrecycler/src/main/java/cash/z/android/vision/CameraSource.kt b/zcash-android-wallet-app/qrecycler/src/main/java/cash/z/android/vision/CameraSource.kt new file mode 100644 index 0000000..bd8328e --- /dev/null +++ b/zcash-android-wallet-app/qrecycler/src/main/java/cash/z/android/vision/CameraSource.kt @@ -0,0 +1,723 @@ +package cash.z.android.wallet.ui.util.vision + +// Copyright 2019 Electric Coin Company +// +// Copyright 2018 Google LLC +// +// 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 +// +// This file has been modified by Electric Coin Company to allow for +// better fitting previews. +// +// 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. + +import android.Manifest +import android.annotation.SuppressLint +import android.app.Activity +import android.content.Context +import android.graphics.ImageFormat +import android.graphics.SurfaceTexture +import android.hardware.Camera +import android.util.Log +import android.view.Surface +import android.view.SurfaceHolder +import android.view.WindowManager +import androidx.annotation.Nullable +import androidx.annotation.RequiresPermission +import cash.z.android.wallet.ui.util.VisionImageProcessor +import com.google.android.gms.common.images.Size +import java.io.IOException +import java.nio.ByteBuffer +import java.util.* +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.withLock + +@SuppressLint("MissingPermission") +class CameraSource(protected var activity: Activity, private val graphicOverlay: GraphicOverlay, private val requestedPreviewWidth: Int = 480, private val requestedPreviewHeight: Int = 360) { + + private var camera: Camera? = null + + /** + * Returns the selected camera; one of [.CAMERA_FACING_BACK] or [ ][.CAMERA_FACING_FRONT]. + */ + var cameraFacing = CAMERA_FACING_BACK + protected set + + /** + * Rotation of the device, and thus the associated preview images captured from the device. See + * Frame.Metadata#getRotation(). + */ + private var rotation: Int = 0 + + /** Returns the preview size that is currently in use by the underlying camera. */ + var previewSize: Size? = null + private set + + // These values may be requested by the caller. Due to hardware limitations, we may need to + // select close, but not exactly the same values for these. + private val requestedFps = 20.0f + private val requestedAutoFocus = true + + // These instances need to be held onto to avoid GC of their underlying resources. Even though + // these aren't used outside of the method that creates them, they still must have hard + // references maintained to them. + private var dummySurfaceTexture: SurfaceTexture? = null + + // True if a SurfaceTexture is being used for the preview, false if a SurfaceHolder is being + // used for the preview. We want to be compatible back to Gingerbread, but SurfaceTexture + // wasn't introduced until Honeycomb. Since the interface cannot use a SurfaceTexture, if the + // developer wants to display a preview we must use a SurfaceHolder. If the developer doesn't + // want to display a preview we use a SurfaceTexture if we are running at least Honeycomb. + private var usingSurfaceTexture: Boolean = false + + /** + * Dedicated thread and associated runnable for calling into the detector with frames, as the + * frames become available from the camera. + */ + private var processingThread: Thread? = null + + private val processingRunnable: FrameProcessingRunnable + + private val processorLock = Any() + // @GuardedBy("processorLock") + private var frameProcessor: VisionImageProcessor? = null + + /** + * Map to convert between a byte array, received from the camera, and its associated byte buffer. + * We use byte buffers internally because this is a more efficient way to call into native code + * later (avoids a potential copy). + * + * + * **Note:** uses IdentityHashMap here instead of HashMap because the behavior of an array's + * equals, hashCode and toString methods is both useless and unexpected. IdentityHashMap enforces + * identity ('==') check on the keys. + */ + private val bytesToByteBuffer = IdentityHashMap() + + init { + graphicOverlay.clear() + processingRunnable = FrameProcessingRunnable() + + if (Camera.getNumberOfCameras() == 1) { + val cameraInfo = Camera.CameraInfo() + Camera.getCameraInfo(0, cameraInfo) + cameraFacing = cameraInfo.facing + } + } + + // ============================================================================================== + // Public + // ============================================================================================== + + /** Stops the camera and releases the resources of the camera and underlying detector. */ + fun release() { + synchronized(processorLock) { + stop() + processingRunnable.release() + cleanScreen() + + if (frameProcessor != null) { + frameProcessor!!.stop() + } + } + } + + /** + * Opens the camera and starts sending preview frames to the underlying detector. The preview + * frames are not displayed. + * + * @throws IOException if the camera's preview texture or display could not be initialized + */ + @SuppressLint("MissingPermission") + @RequiresPermission(Manifest.permission.CAMERA) + @Synchronized + @Throws(IOException::class) + fun start(): CameraSource { + if (camera != null) { + return this + } + + camera = createCamera() + dummySurfaceTexture = SurfaceTexture(DUMMY_TEXTURE_NAME) + camera!!.setPreviewTexture(dummySurfaceTexture) + usingSurfaceTexture = true + camera!!.startPreview() + + processingThread = Thread(processingRunnable) + processingRunnable.setActive(true) + processingThread!!.start() + return this + } + + /** + * Opens the camera and starts sending preview frames to the underlying detector. The supplied + * surface holder is used for the preview so frames can be displayed to the user. + * + * @param surfaceHolder the surface holder to use for the preview frames + * @throws IOException if the supplied surface holder could not be used as the preview display + */ + @RequiresPermission(Manifest.permission.CAMERA) + @Synchronized + @Throws(IOException::class) + fun start(surfaceHolder: SurfaceHolder): CameraSource { + if (camera != null) { + return this + } + + camera = createCamera() + camera!!.setPreviewDisplay(surfaceHolder) + camera!!.startPreview() + + processingThread = Thread(processingRunnable) + processingRunnable.setActive(true) + processingThread!!.start() + + usingSurfaceTexture = false + return this + } + + /** + * Closes the camera and stops sending frames to the underlying frame detector. + * + * + * This camera source may be restarted again by calling [.start] or [ ][.start]. + * + * + * Call [.release] instead to completely shut down this camera source and release the + * resources of the underlying detector. + */ + @Synchronized + fun stop() { + processingRunnable.setActive(false) + if (processingThread != null) { + try { + // Wait for the thread to complete to ensure that we can't have multiple threads + // executing at the same time (i.e., which would happen if we called start too + // quickly after stop). + processingThread!!.join() + } catch (e: InterruptedException) { + Log.d(TAG, "Frame processing thread interrupted on release.") + } + + processingThread = null + } + + if (camera != null) { + camera!!.stopPreview() + camera!!.setPreviewCallbackWithBuffer(null) + try { + if (usingSurfaceTexture) { + camera!!.setPreviewTexture(null) + } else { + camera!!.setPreviewDisplay(null) + } + } catch (e: Exception) { + Log.e(TAG, "Failed to clear camera preview: $e") + } + + camera!!.release() + camera = null + } + + // Release the reference to any image buffers, since these will no longer be in use. + bytesToByteBuffer.clear() + } + + /** Changes the facing of the camera. */ + @Synchronized + fun setFacing(facing: Int) { + if (facing != CAMERA_FACING_BACK && facing != CAMERA_FACING_FRONT) { + throw IllegalArgumentException("Invalid camera: $facing") + } + this.cameraFacing = facing + } + + /** + * Opens the camera and applies the user settings. + * + * @throws IOException if camera cannot be found or preview cannot be processed + */ + @SuppressLint("InlinedApi") + @Throws(IOException::class) + private fun createCamera(): Camera { + val requestedCameraId = getIdForRequestedCamera(cameraFacing) + if (requestedCameraId == -1) { + throw IOException("Could not find requested camera.") + } + val camera = Camera.open(requestedCameraId) + + val sizePair = selectSizePair(camera, requestedPreviewWidth, requestedPreviewHeight) + ?: throw IOException("Could not find suitable preview size.") + val pictureSize = sizePair.pictureSize() + previewSize = sizePair.previewSize() + + val previewFpsRange = selectPreviewFpsRange(camera, requestedFps) + ?: throw IOException("Could not find suitable preview frames per second range.") + + val parameters = camera.parameters + + if (pictureSize != null) { + parameters.setPictureSize(pictureSize.width, pictureSize.height) + } + parameters.setPreviewSize(previewSize!!.width, previewSize!!.height) + parameters.setPreviewFpsRange( + previewFpsRange[Camera.Parameters.PREVIEW_FPS_MIN_INDEX], + previewFpsRange[Camera.Parameters.PREVIEW_FPS_MAX_INDEX] + ) + parameters.previewFormat = ImageFormat.NV21 + + setRotation(camera, parameters, requestedCameraId) + + if (requestedAutoFocus) { + if (parameters + .supportedFocusModes + .contains(Camera.Parameters.FOCUS_MODE_CONTINUOUS_VIDEO) + ) { + parameters.focusMode = Camera.Parameters.FOCUS_MODE_CONTINUOUS_VIDEO + } else { + Log.i(TAG, "Camera auto focus is not supported on this device.") + } + } + + camera.parameters = parameters + + // Four frame buffers are needed for working with the camera: + // + // one for the frame that is currently being executed upon in doing detection + // one for the next pending frame to process immediately upon completing detection + // two for the frames that the camera uses to populate future preview images + // + // Through trial and error it appears that two free buffers, in addition to the two buffers + // used in this code, are needed for the camera to work properly. Perhaps the camera has + // one thread for acquiring images, and another thread for calling into user code. If only + // three buffers are used, then the camera will spew thousands of warning messages when + // detection takes a non-trivial amount of time. + camera.setPreviewCallbackWithBuffer(CameraPreviewCallback()) + camera.addCallbackBuffer(createPreviewBuffer(previewSize!!)) + camera.addCallbackBuffer(createPreviewBuffer(previewSize!!)) + camera.addCallbackBuffer(createPreviewBuffer(previewSize!!)) + camera.addCallbackBuffer(createPreviewBuffer(previewSize!!)) + + return camera + } + + /** + * Stores a preview size and a corresponding same-aspect-ratio picture size. To avoid distorted + * preview images on some devices, the picture size must be set to a size that is the same aspect + * ratio as the preview size or the preview may end up being distorted. If the picture size is + * null, then there is no picture size with the same aspect ratio as the preview size. + */ + private class SizePair internal constructor( + previewSize: android.hardware.Camera.Size, + @Nullable pictureSize: android.hardware.Camera.Size? + ) { + private val preview: Size + private var picture: Size? = null + + init { + preview = Size(previewSize.width, previewSize.height) + if (pictureSize != null) { + picture = Size(pictureSize.width, pictureSize.height) + } + } + + internal fun previewSize(): Size { + return preview + } + + @Nullable + internal fun pictureSize(): Size? { + return picture + } + } + + /** + * Calculates the correct rotation for the given camera id and sets the rotation in the + * parameters. It also sets the camera's display orientation and rotation. + * + * @param parameters the camera parameters for which to set the rotation + * @param cameraId the camera id to set rotation based on + */ + private fun setRotation(camera: Camera, parameters: Camera.Parameters, cameraId: Int) { + val windowManager = activity.getSystemService(Context.WINDOW_SERVICE) as WindowManager + var degrees = 0 + val rotation = windowManager.defaultDisplay.rotation + when (rotation) { + Surface.ROTATION_0 -> degrees = 0 + Surface.ROTATION_90 -> degrees = 90 + Surface.ROTATION_180 -> degrees = 180 + Surface.ROTATION_270 -> degrees = 270 + else -> Log.e(TAG, "Bad rotation value: $rotation") + } + + val cameraInfo = Camera.CameraInfo() + Camera.getCameraInfo(cameraId, cameraInfo) + + val angle: Int + val displayAngle: Int + if (cameraInfo.facing == Camera.CameraInfo.CAMERA_FACING_FRONT) { + angle = (cameraInfo.orientation + degrees) % 360 + displayAngle = (360 - angle) % 360 // compensate for it being mirrored + } else { // back-facing + angle = (cameraInfo.orientation - degrees + 360) % 360 + displayAngle = angle + } + + // This corresponds to the rotation constants. + this.rotation = angle / 90 + + camera.setDisplayOrientation(displayAngle) + parameters.setRotation(angle) + } + + /** + * Creates one buffer for the camera preview callback. The size of the buffer is based off of the + * camera preview size and the format of the camera image. + * + * @return a new preview buffer of the appropriate size for the current camera settings + */ + @SuppressLint("InlinedApi") + private fun createPreviewBuffer(previewSize: Size): ByteArray { + val bitsPerPixel = ImageFormat.getBitsPerPixel(ImageFormat.NV21) + val sizeInBits = previewSize.height.toLong() * previewSize.width.toLong() * bitsPerPixel.toLong() + val bufferSize = Math.ceil(sizeInBits / 8.0).toInt() + 1 + + // Creating the byte array this way and wrapping it, as opposed to using .allocate(), + // should guarantee that there will be an array to work with. + val byteArray = ByteArray(bufferSize) + val buffer = ByteBuffer.wrap(byteArray) + if (!buffer.hasArray() || buffer.array() != byteArray) { + // I don't think that this will ever happen. But if it does, then we wouldn't be + // passing the preview content to the underlying detector later. + throw IllegalStateException("Failed to create valid buffer for camera source.") + } + + bytesToByteBuffer[byteArray] = buffer + return byteArray + } + + // ============================================================================================== + // Frame processing + // ============================================================================================== + + /** Called when the camera has a new preview frame. */ + private inner class CameraPreviewCallback : Camera.PreviewCallback { + override fun onPreviewFrame(data: ByteArray, camera: Camera) { + processingRunnable.setNextFrame(data, camera) + } + } + + fun setMachineLearningFrameProcessor(processor: VisionImageProcessor) { + synchronized(processorLock) { + cleanScreen() + if (frameProcessor != null) { + frameProcessor!!.stop() + } + frameProcessor = processor + } + } + + /** + * This runnable controls access to the underlying receiver, calling it to process frames when + * available from the camera. This is designed to run detection on frames as fast as possible + * (i.e., without unnecessary context switching or waiting on the next frame). + * + * + * While detection is running on a frame, new frames may be received from the camera. As these + * frames come in, the most recent frame is held onto as pending. As soon as detection and its + * associated processing is done for the previous frame, detection on the mostly recently received + * frame will immediately start on the same thread. + */ + private inner class FrameProcessingRunnable internal constructor() : Runnable { + + // This lock guards all of the member variables below. + private val lock = ReentrantLock() + private val condition = lock.newCondition() + private var active = true + + // These pending variables hold the state associated with the new frame awaiting processing. + private var pendingFrameData: ByteBuffer? = null + + /** + * Releases the underlying receiver. This is only safe to do after the associated thread has + * completed, which is managed in camera source's release method above. + */ + @SuppressLint("Assert") + internal fun release() { + //assert(processingThread!!.state == Thread.State.TERMINATED) + } + + /** Marks the runnable as active/not active. Signals any blocked threads to continue. */ + internal fun setActive(active: Boolean) { + lock.withLock { + this.active = active + condition.signalAll() + } + } + + /** + * Sets the frame data received from the camera. This adds the previous unused frame buffer (if + * present) back to the camera, and keeps a pending reference to the frame data for future use. + */ + internal fun setNextFrame(data: ByteArray, camera: Camera) { + lock.withLock { + if (pendingFrameData != null) { + camera.addCallbackBuffer(pendingFrameData!!.array()) + pendingFrameData = null + } + + if (!bytesToByteBuffer.containsKey(data)) { + Log.d( + TAG, + "Skipping frame. Could not find ByteBuffer associated with the image " + "data from the camera." + ) + return + } + + pendingFrameData = bytesToByteBuffer[data] + + // Notify the processor thread if it is waiting on the next frame (see below). + condition.signalAll() + } + } + + /** + * As long as the processing thread is active, this executes detection on frames continuously. + * The next pending frame is either immediately available or hasn't been received yet. Once it + * is available, we transfer the frame info to local variables and run detection on that frame. + * It immediately loops back for the next frame without pausing. + * + * + * If detection takes longer than the time in between new frames from the camera, this will + * mean that this loop will run without ever waiting on a frame, avoiding any context switching + * or frame acquisition time latency. + * + * + * If you find that this is using more CPU than you'd like, you should probably decrease the + * FPS setting above to allow for some idle time in between frames. + */ + @SuppressLint("InlinedApi") + override fun run() { + var data: ByteBuffer? = null + + while (true) { + lock.withLock { + while (active && pendingFrameData == null) { + try { + // Wait for the next frame to be received from the camera, since we + // don't have it yet. + condition.await() + } catch (e: InterruptedException) { + Log.d(TAG, "Frame processing loop terminated.", e) + return + } + + } + + if (!active) { + // Exit the loop once this camera source is stopped or released. We check + // this here, immediately after the wait() above, to handle the case where + // setActive(false) had been called, triggering the termination of this + // loop. + return + } + + // Hold onto the frame data locally, so that we can use this for detection + // below. We need to clear pendingFrameData to ensure that this buffer isn't + // recycled back to the camera before we are done using that data. + data = pendingFrameData!! + pendingFrameData = null + } + + // The code below needs to run outside of synchronization, because this will allow + // the camera to add pending frame(s) while we are running detection on the current + // frame. + + try { + synchronized(processorLock) { + Log.d(TAG, "Process an image") + frameProcessor!!.process( + data!!, + FrameMetadata.Builder() + .setWidth(previewSize!!.width) + .setHeight(previewSize!!.height) + .setRotation(rotation) + .setCameraFacing(cameraFacing) + .build(), + graphicOverlay + ) + } + } catch (t: Throwable) { + Log.e(TAG, "Exception thrown from receiver.", t) + } finally { + camera!!.addCallbackBuffer(data!!.array()) + } + } + } + } + + /** Cleans up graphicOverlay and child classes can do their cleanups as well . */ + private fun cleanScreen() { + graphicOverlay.clear() + } + + companion object { + @SuppressLint("InlinedApi") + val CAMERA_FACING_BACK = Camera.CameraInfo.CAMERA_FACING_BACK + + @SuppressLint("InlinedApi") + val CAMERA_FACING_FRONT = Camera.CameraInfo.CAMERA_FACING_FRONT + + private val TAG = "MIDemoApp:CameraSource" + + /** + * The dummy surface texture must be assigned a chosen name. Since we never use an OpenGL context, + * we can choose any ID we want here. The dummy surface texture is not a crazy hack - it is + * actually how the camera team recommends using the camera without a preview. + */ + private val DUMMY_TEXTURE_NAME = 100 + + /** + * If the absolute difference between a preview size aspect ratio and a picture size aspect ratio + * is less than this tolerance, they are considered to be the same aspect ratio. + */ + private val ASPECT_RATIO_TOLERANCE = 0.01f + + /** + * Gets the id for the camera specified by the direction it is facing. Returns -1 if no such + * camera was found. + * + * @param facing the desired camera (front-facing or rear-facing) + */ + private fun getIdForRequestedCamera(facing: Int): Int { + val cameraInfo = Camera.CameraInfo() + for (i in 0 until Camera.getNumberOfCameras()) { + Camera.getCameraInfo(i, cameraInfo) + if (cameraInfo.facing == facing) { + return i + } + } + return -1 + } + + /** + * Selects the most suitable preview and picture size, given the desired width and height. + * + * + * Even though we only need to find the preview size, it's necessary to find both the preview + * size and the picture size of the camera together, because these need to have the same aspect + * ratio. On some hardware, if you would only set the preview size, you will get a distorted + * image. + * + * @param camera the camera to select a preview size from + * @param desiredWidth the desired width of the camera preview frames + * @param desiredHeight the desired height of the camera preview frames + * @return the selected preview and picture size pair + */ + private fun selectSizePair(camera: Camera, desiredWidth: Int, desiredHeight: Int): SizePair? { + val validPreviewSizes = generateValidPreviewSizeList(camera) + + // The method for selecting the best size is to minimize the sum of the differences between + // the desired values and the actual values for width and height. This is certainly not the + // only way to select the best size, but it provides a decent tradeoff between using the + // closest aspect ratio vs. using the closest pixel area. + var selectedPair: SizePair? = null + var minDiff = Integer.MAX_VALUE + for (sizePair in validPreviewSizes) { + val size = sizePair.previewSize() + val diff = Math.abs(size.width - desiredWidth) + Math.abs(size.height - desiredHeight) + if (diff < minDiff) { + selectedPair = sizePair + minDiff = diff + } + } + + return selectedPair + } + + /** + * Generates a list of acceptable preview sizes. Preview sizes are not acceptable if there is not + * a corresponding picture size of the same aspect ratio. If there is a corresponding picture size + * of the same aspect ratio, the picture size is paired up with the preview size. + * + * + * This is necessary because even if we don't use still pictures, the still picture size must + * be set to a size that is the same aspect ratio as the preview size we choose. Otherwise, the + * preview images may be distorted on some devices. + */ + private fun generateValidPreviewSizeList(camera: Camera): List { + val parameters = camera.parameters + val supportedPreviewSizes = parameters.supportedPreviewSizes + val supportedPictureSizes = parameters.supportedPictureSizes + val validPreviewSizes = ArrayList() + for (previewSize in supportedPreviewSizes) { + val previewAspectRatio = previewSize.width.toFloat() / previewSize.height.toFloat() + + // By looping through the picture sizes in order, we favor the higher resolutions. + // We choose the highest resolution in order to support taking the full resolution + // picture later. + for (pictureSize in supportedPictureSizes) { + val pictureAspectRatio = pictureSize.width.toFloat() / pictureSize.height.toFloat() + if (Math.abs(previewAspectRatio - pictureAspectRatio) < ASPECT_RATIO_TOLERANCE) { + validPreviewSizes.add(SizePair(previewSize, pictureSize)) + break + } + } + } + + // If there are no picture sizes with the same aspect ratio as any preview sizes, allow all + // of the preview sizes and hope that the camera can handle it. Probably unlikely, but we + // still account for it. + if (validPreviewSizes.size == 0) { + Log.w(TAG, "No preview sizes have a corresponding same-aspect-ratio picture size") + for (previewSize in supportedPreviewSizes) { + // The null picture size will let us know that we shouldn't set a picture size. + validPreviewSizes.add(SizePair(previewSize, null)) + } + } + + return validPreviewSizes + } + + /** + * Selects the most suitable preview frames per second range, given the desired frames per second. + * + * @param camera the camera to select a frames per second range from + * @param desiredPreviewFps the desired frames per second for the camera preview frames + * @return the selected preview frames per second range + */ + @SuppressLint("InlinedApi") + private fun selectPreviewFpsRange(camera: Camera, desiredPreviewFps: Float): IntArray? { + // The camera API uses integers scaled by a factor of 1000 instead of floating-point frame + // rates. + val desiredPreviewFpsScaled = (desiredPreviewFps * 1000.0f).toInt() + + // The method for selecting the best range is to minimize the sum of the differences between + // the desired value and the upper and lower bounds of the range. This may select a range + // that the desired value is outside of, but this is often preferred. For example, if the + // desired frame rate is 29.97, the range (30, 30) is probably more desirable than the + // range (15, 30). + var selectedFpsRange: IntArray? = null + var minDiff = Integer.MAX_VALUE + val previewFpsRangeList = camera.parameters.supportedPreviewFpsRange + for (range in previewFpsRangeList) { + val deltaMin = desiredPreviewFpsScaled - range[Camera.Parameters.PREVIEW_FPS_MIN_INDEX] + val deltaMax = desiredPreviewFpsScaled - range[Camera.Parameters.PREVIEW_FPS_MAX_INDEX] + val diff = Math.abs(deltaMin) + Math.abs(deltaMax) + if (diff < minDiff) { + selectedFpsRange = range + minDiff = diff + } + } + return selectedFpsRange + } + } +} diff --git a/zcash-android-wallet-app/qrecycler/src/main/java/cash/z/android/vision/CameraSourcePreview.kt b/zcash-android-wallet-app/qrecycler/src/main/java/cash/z/android/vision/CameraSourcePreview.kt new file mode 100644 index 0000000..791becf --- /dev/null +++ b/zcash-android-wallet-app/qrecycler/src/main/java/cash/z/android/vision/CameraSourcePreview.kt @@ -0,0 +1,185 @@ +package cash.z.android.wallet.ui.util.vision + +// Copyright 2019 Electric Coin Company +// +// Copyright 2018 Google LLC +// +// 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 +// +// This file has been modified by Electric Coin Company to all for +// better fitting previews. +// +// 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. + +import android.annotation.SuppressLint +import android.content.Context +import android.content.res.Configuration +import android.util.AttributeSet +import android.util.Log +import android.view.SurfaceHolder +import android.view.SurfaceView +import android.view.ViewGroup +import java.io.IOException + + +/** Preview the camera image in the screen. */ +class CameraSourcePreview : ViewGroup { + + constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { + startRequested = false + surfaceAvailable = false + surfaceView = SurfaceView(context) + surfaceView.holder.addCallback(SurfaceCallback()) + addView(surfaceView) + } + + private val surfaceView: SurfaceView + private var startRequested: Boolean = false + private var surfaceAvailable: Boolean = false + private var cameraSource: CameraSource? = null + + private var overlay: GraphicOverlay? = null + + private val isPortraitMode: Boolean + get() { + val orientation = context.resources.configuration.orientation + if (orientation == Configuration.ORIENTATION_LANDSCAPE) { + return false + } + if (orientation == Configuration.ORIENTATION_PORTRAIT) { + return true + } + + Log.d(TAG, "isPortraitMode returning false by default") + return false + } + + @Throws(IOException::class) + fun start(cameraSource: CameraSource?) { + if (cameraSource == null) { + stop() + } + + this.cameraSource = cameraSource + + if (this.cameraSource != null) { + startRequested = true + startIfReady() + } + } + + @Throws(IOException::class) + fun start(cameraSource: CameraSource, overlay: GraphicOverlay) { + this.overlay = overlay + start(cameraSource) + } + + fun stop() { + if (cameraSource != null) { + cameraSource!!.stop() + } + } + + fun release() { + if (cameraSource != null) { + cameraSource!!.release() + cameraSource = null + } + } + + @SuppressLint("MissingPermission") + @Throws(IOException::class) + private fun startIfReady() { + if (startRequested && surfaceAvailable) { + cameraSource!!.start() + if (overlay != null) { + val size = cameraSource!!.previewSize!! + val min = Math.min(size.width, size.height) + val max = Math.max(size.width, size.height) + if (isPortraitMode) { + // Swap width and height sizes when in portrait, since it will be rotated by + // 90 degrees + overlay!!.setCameraInfo(min, max, cameraSource!!.cameraFacing) + } else { + overlay!!.setCameraInfo(max, min, cameraSource!!.cameraFacing) + } + overlay!!.clear() + } + startRequested = false + } + } + + private inner class SurfaceCallback : SurfaceHolder.Callback { + override fun surfaceCreated(surface: SurfaceHolder) { + surfaceAvailable = true + try { + startIfReady() + } catch (e: IOException) { + Log.e(TAG, "Could not start camera source.", e) + } + + } + + override fun surfaceDestroyed(surface: SurfaceHolder) { + surfaceAvailable = false + } + + override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {} + } + + override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { + var width = 320 + var height = 240 + if (cameraSource != null) { + val size = cameraSource!!.previewSize + if (size != null) { + width = size.width + height = size.height + } + } + + // Swap width and height sizes when in portrait, since it will be rotated 90 degrees + if (isPortraitMode) { + val tmp = width + width = height + height = tmp + } + + val layoutWidth = right - left + val layoutHeight = bottom - top + + // Computes height and width for potentially doing fit width. + var childWidth = layoutWidth + var childHeight = (layoutWidth.toFloat() / width.toFloat() * height).toInt() + + // If height is too tall using fit width, does fit height instead. + if (childHeight > layoutHeight) { + childHeight = layoutHeight + childWidth = (layoutHeight.toFloat() / height.toFloat() * width).toInt() + } + + for (i in 0 until childCount) { + getChildAt(i).layout(0, 0, childWidth, childHeight) + Log.d(TAG, "Assigned view: $i") + } + + try { + startIfReady() + } catch (e: IOException) { + Log.e(TAG, "Could not start camera source.", e) + } + + } + + companion object { + private val TAG = "MIDemoApp:Preview" + } +} \ No newline at end of file diff --git a/zcash-android-wallet-app/qrecycler/src/main/java/cash/z/android/vision/FrameMetadata.kt b/zcash-android-wallet-app/qrecycler/src/main/java/cash/z/android/vision/FrameMetadata.kt new file mode 100644 index 0000000..5e4cd41 --- /dev/null +++ b/zcash-android-wallet-app/qrecycler/src/main/java/cash/z/android/vision/FrameMetadata.kt @@ -0,0 +1,52 @@ +package cash.z.android.wallet.ui.util.vision + +// Copyright 2018 Google LLC +// +// 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. + +/** Describing a frame info. */ +class FrameMetadata private constructor(val width: Int, val height: Int, val rotation: Int, val cameraFacing: Int) { + + /** Builder of [FrameMetadata]. */ + class Builder { + + private var width: Int = 0 + private var height: Int = 0 + private var rotation: Int = 0 + private var cameraFacing: Int = 0 + + fun setWidth(width: Int): Builder { + this.width = width + return this + } + + fun setHeight(height: Int): Builder { + this.height = height + return this + } + + fun setRotation(rotation: Int): Builder { + this.rotation = rotation + return this + } + + fun setCameraFacing(facing: Int): Builder { + cameraFacing = facing + return this + } + + fun build(): FrameMetadata { + return FrameMetadata(width, height, rotation, cameraFacing) + } + } +} \ No newline at end of file diff --git a/zcash-android-wallet-app/qrecycler/src/main/java/cash/z/android/vision/GraphicOverlay.kt b/zcash-android-wallet-app/qrecycler/src/main/java/cash/z/android/vision/GraphicOverlay.kt new file mode 100644 index 0000000..fb782a3 --- /dev/null +++ b/zcash-android-wallet-app/qrecycler/src/main/java/cash/z/android/vision/GraphicOverlay.kt @@ -0,0 +1,161 @@ +package cash.z.android.wallet.ui.util.vision + +// Copyright 2018 Google LLC +// +// 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. + + +import android.content.Context +import android.graphics.Canvas +import android.util.AttributeSet +import android.view.View +import cash.z.android.wallet.ui.util.vision.GraphicOverlay.Graphic +import java.util.* + +/** + * A view which renders a series of custom graphics to be overlayed on top of an associated preview + * (i.e., the camera preview). The creator can add graphics objects, update the objects, and remove + * them, triggering the appropriate drawing and invalidation within the view. + * + * + * Supports scaling and mirroring of the graphics relative the camera's preview properties. The + * idea is that detection items are expressed in terms of a preview size, but need to be scaled up + * to the full view size, and also mirrored in the case of the front-facing camera. + * + * + * Associated [Graphic] items should use the following methods to convert to view + * coordinates for the graphics that are drawn: + * + * + * 1. [Graphic.scaleX] and [Graphic.scaleY] adjust the size of the + * supplied value from the preview scale to the view scale. + * 1. [Graphic.translateX] and [Graphic.translateY] adjust the + * coordinate from the preview's coordinate system to the view coordinate system. + * + */ +class GraphicOverlay(context: Context, attrs: AttributeSet) : View(context, attrs) { + private val lock = Any() + private var previewWidth: Int = 0 + private var widthScaleFactor = 1.0f + private var previewHeight: Int = 0 + private var heightScaleFactor = 1.0f + private val graphics = ArrayList() + + /** + * Base class for a custom graphics object to be rendered within the graphic overlay. Subclass + * this and implement the [Graphic.draw] method to define the graphics element. Add + * instances to the overlay using [GraphicOverlay.add]. + */ + abstract class Graphic(private val overlay: GraphicOverlay) { + + /** Returns the application context of the app. */ + val applicationContext: Context + get() = overlay.context.applicationContext + + /** + * Draw the graphic on the supplied canvas. Drawing should use the following methods to convert + * to view coordinates for the graphics that are drawn: + * + * + * 1. [Graphic.scaleX] and [Graphic.scaleY] adjust the size of the + * supplied value from the preview scale to the view scale. + * 1. [Graphic.translateX] and [Graphic.translateY] adjust the + * coordinate from the preview's coordinate system to the view coordinate system. + * + * + * @param canvas drawing canvas + */ + abstract fun draw(canvas: Canvas) + + /** + * Adjusts a horizontal value of the supplied value from the preview scale to the view scale. + */ + fun scaleX(horizontal: Float): Float { + return horizontal * overlay.widthScaleFactor + } + + /** Adjusts a vertical value of the supplied value from the preview scale to the view scale. */ + fun scaleY(vertical: Float): Float { + return vertical * overlay.heightScaleFactor + } + + /** + * Adjusts the x coordinate from the preview's coordinate system to the view coordinate system. + */ + fun translateX(x: Float): Float { + return scaleX(x) + } + + /** + * Adjusts the y coordinate from the preview's coordinate system to the view coordinate system. + */ + fun translateY(y: Float): Float { + return scaleY(y) + } + + fun postInvalidate() { + overlay.postInvalidate() + } + } + + /** Removes all graphics from the overlay. */ + fun clear() { + synchronized(lock) { + graphics.clear() + } + postInvalidate() + } + + /** Adds a graphic to the overlay. */ + fun add(graphic: Graphic) { + synchronized(lock) { + graphics.add(graphic) + } + } + + /** Removes a graphic from the overlay. */ + fun remove(graphic: Graphic) { + synchronized(lock) { + graphics.remove(graphic) + } + postInvalidate() + } + + /** + * Sets the camera attributes for size and facing direction, which informs how to transform image + * coordinates later. + */ + fun setCameraInfo(previewWidth: Int, previewHeight: Int, facing: Int) { + synchronized(lock) { + this.previewWidth = previewWidth + this.previewHeight = previewHeight + } + postInvalidate() + } + + /** Draws the overlay with its associated graphic objects. */ + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + + synchronized(lock) { + if (previewWidth != 0 && previewHeight != 0) { + widthScaleFactor = canvas.width.toFloat() / previewWidth.toFloat() + heightScaleFactor = canvas.height.toFloat() / previewHeight.toFloat() + } + + for (graphic in graphics) { + graphic.draw(canvas) + } + } + } +} \ No newline at end of file diff --git a/zcash-android-wallet-app/qrecycler/src/main/java/cash/z/android/vision/VisionProcessorBase.kt b/zcash-android-wallet-app/qrecycler/src/main/java/cash/z/android/vision/VisionProcessorBase.kt new file mode 100644 index 0000000..271e3fb --- /dev/null +++ b/zcash-android-wallet-app/qrecycler/src/main/java/cash/z/android/vision/VisionProcessorBase.kt @@ -0,0 +1,125 @@ +package cash.z.android.wallet.ui.util + +import android.graphics.Bitmap +import androidx.annotation.GuardedBy +import cash.z.android.wallet.ui.util.vision.BitmapUtils +import cash.z.android.wallet.ui.util.vision.FrameMetadata +import cash.z.android.wallet.ui.util.vision.GraphicOverlay +import com.google.android.gms.tasks.Task +import com.google.firebase.ml.vision.common.FirebaseVisionImage +import com.google.firebase.ml.vision.common.FirebaseVisionImageMetadata +import java.nio.ByteBuffer + +/** + * Abstract base class for ML Kit frame processors. Subclasses need to implement {@link + * #onSuccess(T, FrameMetadata, GraphicOverlay)} to define what they want to with the detection + * results and {@link #detectInImage(FirebaseVisionImage)} to specify the detector object. + * + * @param The type of the detected feature. + */ +abstract class VisionProcessorBase : VisionImageProcessor { + + // To keep the latest images and its metadata. + @GuardedBy("this") + private var latestImage: ByteBuffer? = null + + @GuardedBy("this") + private var latestImageMetaData: FrameMetadata? = null + + // To keep the images and metadata in process. + @GuardedBy("this") + private var processingImage: ByteBuffer? = null + + @GuardedBy("this") + private var processingMetaData: FrameMetadata? = null + + @Synchronized + override fun process( + data: ByteBuffer, + frameMetadata: FrameMetadata, + graphicOverlay: GraphicOverlay + ) { + latestImage = data + latestImageMetaData = frameMetadata + if (processingImage == null && processingMetaData == null) { + processLatestImage(graphicOverlay) + } + } + + // Bitmap version + override fun process(bitmap: Bitmap, graphicOverlay: GraphicOverlay) { + detectInVisionImage( + null, /* bitmap */ + FirebaseVisionImage.fromBitmap(bitmap), + null, + graphicOverlay + ) + } + + @Synchronized + private fun processLatestImage(graphicOverlay: GraphicOverlay) { + processingImage = latestImage + processingMetaData = latestImageMetaData + latestImage = null + latestImageMetaData = null + if (processingImage != null && processingMetaData != null) { + processImage(processingImage!!, processingMetaData!!, graphicOverlay) + } + } + + private fun processImage( + data: ByteBuffer, + frameMetadata: FrameMetadata, + graphicOverlay: GraphicOverlay + ) { + val metadata = FirebaseVisionImageMetadata.Builder() + .setFormat(FirebaseVisionImageMetadata.IMAGE_FORMAT_NV21) + .setWidth(frameMetadata.width) + .setHeight(frameMetadata.height) + .setRotation(frameMetadata.rotation) + .build() + + val bitmap = BitmapUtils.getBitmap(data, frameMetadata) + detectInVisionImage( + bitmap, FirebaseVisionImage.fromByteBuffer(data, metadata), frameMetadata, + graphicOverlay + ) + } + + private fun detectInVisionImage( + originalCameraImage: Bitmap?, + image: FirebaseVisionImage, + metadata: FrameMetadata?, + graphicOverlay: GraphicOverlay + ) { + detectInImage(image) + .addOnSuccessListener { results -> + onSuccess( + originalCameraImage, results, + metadata!!, + graphicOverlay + ) + processLatestImage(graphicOverlay) + } + .addOnFailureListener { e -> onFailure(e) } + } + + override fun stop() {} + + protected abstract fun detectInImage(image: FirebaseVisionImage): Task + + /** + * Callback that executes with a successful detection result. + * + * @param originalCameraImage hold the original image from camera, used to draw the background + * image. + */ + protected abstract fun onSuccess( + originalCameraImage: Bitmap?, + results: T, + frameMetadata: FrameMetadata, + graphicOverlay: GraphicOverlay + ) + + protected abstract fun onFailure(e: Exception) +} \ No newline at end of file diff --git a/zcash-android-wallet-app/qrecycler/src/main/java/cash/z/android/vision/VisonImageProcessor.kt b/zcash-android-wallet-app/qrecycler/src/main/java/cash/z/android/vision/VisonImageProcessor.kt new file mode 100644 index 0000000..00860c9 --- /dev/null +++ b/zcash-android-wallet-app/qrecycler/src/main/java/cash/z/android/vision/VisonImageProcessor.kt @@ -0,0 +1,36 @@ +package cash.z.android.wallet.ui.util + +// Copyright 2018 Google LLC +// +// 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. + + +import android.graphics.Bitmap +import cash.z.android.wallet.ui.util.vision.FrameMetadata +import cash.z.android.wallet.ui.util.vision.GraphicOverlay +import com.google.firebase.ml.common.FirebaseMLException +import java.nio.ByteBuffer + +/** An inferface to process the images with different ML Kit detectors and custom image models. */ +interface VisionImageProcessor { + + /** Processes the images with the underlying machine learning models. */ + @Throws(FirebaseMLException::class) + fun process(data: ByteBuffer, frameMetadata: FrameMetadata, graphicOverlay: GraphicOverlay) + + /** Processes the bitmap images. */ + fun process(bitmap: Bitmap, graphicOverlay: GraphicOverlay) + + /** Stops the underlying machine learning model and release resources. */ + fun stop() +} \ No newline at end of file diff --git a/zcash-android-wallet-app/qrecycler/src/main/res/layout/texture_view.xml b/zcash-android-wallet-app/qrecycler/src/main/res/layout/texture_view.xml new file mode 100644 index 0000000..91953b7 --- /dev/null +++ b/zcash-android-wallet-app/qrecycler/src/main/res/layout/texture_view.xml @@ -0,0 +1,23 @@ + + + + + + + diff --git a/zcash-android-wallet-app/qrecycler/src/main/res/values/attrs.xml b/zcash-android-wallet-app/qrecycler/src/main/res/values/attrs.xml new file mode 100644 index 0000000..7ac245d --- /dev/null +++ b/zcash-android-wallet-app/qrecycler/src/main/res/values/attrs.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/zcash-android-wallet-app/qrecycler/src/main/res/values/public.xml b/zcash-android-wallet-app/qrecycler/src/main/res/values/public.xml new file mode 100644 index 0000000..84966fa --- /dev/null +++ b/zcash-android-wallet-app/qrecycler/src/main/res/values/public.xml @@ -0,0 +1,21 @@ + + + + + + + + + + diff --git a/zcash-android-wallet-app/qrecycler/src/main/res/values/styles.xml b/zcash-android-wallet-app/qrecycler/src/main/res/values/styles.xml new file mode 100644 index 0000000..6ded716 --- /dev/null +++ b/zcash-android-wallet-app/qrecycler/src/main/res/values/styles.xml @@ -0,0 +1,24 @@ + + + + + + +