checkpoint: completed major functionality in send including refactor

added CameraView and wired it into a fragment, then connected it with firebasevision. Also completely cleaned up the sendfragment and refactored the way that it manages currency conversions.
This commit is contained in:
Kevin Gorham 2019-02-18 00:05:40 -05:00 committed by Kevin Gorham
parent e26d66cc3b
commit 92b9558446
17 changed files with 862 additions and 386 deletions

View File

@ -106,7 +106,13 @@ dependencies {
debugImplementation deps.stetho debugImplementation deps.stetho
mockImplementation deps.stetho mockImplementation deps.stetho
testImplementation deps.junit testImplementation 'org.mockito:mockito-junit-jupiter:2.24.0'
testImplementation 'com.nhaarman.mockitokotlin2:mockito-kotlin:2.1.0'
testImplementation "org.junit.jupiter:junit-jupiter-api:5.4.0"
testImplementation "org.junit.jupiter:junit-jupiter-engine:5.4.0"
testImplementation "org.junit.jupiter:junit-jupiter-migrationsupport:5.4.0"
androidTestImplementation deps.androidx.test.runner androidTestImplementation deps.androidx.test.runner
androidTestImplementation deps.androidx.test.espresso androidTestImplementation deps.androidx.test.espresso
} }

View File

@ -6,6 +6,8 @@
<uses-permission android:name="android.permission.CAMERA"/> <uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<!--<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />-->
<!--<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />-->
<dist:module dist:instant="true" /> <dist:module dist:instant="true" />

View File

@ -1,6 +1,8 @@
package cash.z.android.wallet.sample package cash.z.android.wallet.sample
import cash.z.wallet.sdk.data.SampleSeedProvider import cash.z.wallet.sdk.data.SampleSeedProvider
import java.math.BigDecimal
import java.math.MathContext
object AliceWallet { object AliceWallet {
const val name = "test.reference.alice" const val name = "test.reference.alice"
@ -45,11 +47,5 @@ object SampleProperties {
const val COMPACT_BLOCK_PORT = 9067 const val COMPACT_BLOCK_PORT = 9067
val wallet = AliceWallet val wallet = AliceWallet
// TODO: placeholder until we have a network service for this // TODO: placeholder until we have a network service for this
const val USD_PER_ZEC = 49.07 val USD_PER_ZEC = BigDecimal("49.07", MathContext.DECIMAL128)
/**
* A simple flag that helps with removing shortcuts in the code used during development.
* TODO: either elevate this to a real thing (based off a system property or some such) or delete it!
*/
const val DEV_MODE = false
} }

View File

@ -20,7 +20,6 @@ import androidx.navigation.ui.setupWithNavController
import cash.z.android.wallet.BuildConfig import cash.z.android.wallet.BuildConfig
import cash.z.android.wallet.R import cash.z.android.wallet.R
import cash.z.android.wallet.ZcashWalletApplication import cash.z.android.wallet.ZcashWalletApplication
import cash.z.android.wallet.sample.SampleProperties.DEV_MODE
import dagger.Module import dagger.Module
import dagger.android.ContributesAndroidInjector import dagger.android.ContributesAndroidInjector
import dagger.android.support.DaggerAppCompatActivity import dagger.android.support.DaggerAppCompatActivity
@ -49,12 +48,12 @@ class MainActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main) setContentView(R.layout.activity_main)
if(!DEV_MODE)synchronizer.start(this) synchronizer.start(this)
} }
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
if(!DEV_MODE)synchronizer.stop() synchronizer.stop()
} }
override fun onBackPressed() { override fun onBackPressed() {

View File

@ -11,6 +11,7 @@ import androidx.recyclerview.widget.RecyclerView
import cash.z.android.wallet.R import cash.z.android.wallet.R
import cash.z.android.wallet.extention.toAppColor import cash.z.android.wallet.extention.toAppColor
import cash.z.wallet.sdk.dao.WalletTransaction import cash.z.wallet.sdk.dao.WalletTransaction
import cash.z.wallet.sdk.ext.convertZatoshiToZec
import cash.z.wallet.sdk.ext.toZec import cash.z.wallet.sdk.ext.toZec
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
@ -40,7 +41,7 @@ class TransactionViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView)
val sign = if(tx.isSend) "-" else "+" val sign = if(tx.isSend) "-" else "+"
val amountColor = if (tx.isSend) R.color.text_dark_dimmed else R.color.colorPrimary val amountColor = if (tx.isSend) R.color.text_dark_dimmed else R.color.colorPrimary
val transactionColor = if(tx.isSend) R.color.send_associated else R.color.receive_associated val transactionColor = if(tx.isSend) R.color.send_associated else R.color.receive_associated
val zecAbsoluteValue = tx.value.absoluteValue.toZec(3) val zecAbsoluteValue = tx.value.absoluteValue.convertZatoshiToZec(3)
status.setBackgroundColor(transactionColor.toAppColor()) status.setBackgroundColor(transactionColor.toAppColor())
timestamp.text = if (!tx.isMined || tx.timeInSeconds == 0L) "Pending" else formatter.format(tx.timeInSeconds * 1000) timestamp.text = if (!tx.isMined || tx.timeInSeconds == 0L) "Pending" else formatter.format(tx.timeInSeconds * 1000)
Log.e("TWIG-z", "TimeInSeconds: ${tx.timeInSeconds}") Log.e("TWIG-z", "TimeInSeconds: ${tx.timeInSeconds}")

View File

@ -23,7 +23,6 @@ import cash.z.android.wallet.R
import cash.z.android.wallet.databinding.FragmentHomeBinding import cash.z.android.wallet.databinding.FragmentHomeBinding
import cash.z.android.wallet.extention.* import cash.z.android.wallet.extention.*
import cash.z.android.wallet.sample.SampleProperties import cash.z.android.wallet.sample.SampleProperties
import cash.z.android.wallet.sample.SampleProperties.DEV_MODE
import cash.z.android.wallet.ui.adapter.TransactionAdapter import cash.z.android.wallet.ui.adapter.TransactionAdapter
import cash.z.android.wallet.ui.presenter.HomePresenter import cash.z.android.wallet.ui.presenter.HomePresenter
import cash.z.android.wallet.ui.util.AlternatingRowColorDecoration import cash.z.android.wallet.ui.util.AlternatingRowColorDecoration
@ -33,7 +32,7 @@ import cash.z.wallet.sdk.dao.WalletTransaction
import cash.z.wallet.sdk.data.ActiveSendTransaction import cash.z.wallet.sdk.data.ActiveSendTransaction
import cash.z.wallet.sdk.data.ActiveTransaction import cash.z.wallet.sdk.data.ActiveTransaction
import cash.z.wallet.sdk.data.TransactionState import cash.z.wallet.sdk.data.TransactionState
import cash.z.wallet.sdk.ext.toZec import cash.z.wallet.sdk.ext.*
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import com.leinardi.android.speeddial.SpeedDialActionItem import com.leinardi.android.speeddial.SpeedDialActionItem
import dagger.Module import dagger.Module
@ -168,11 +167,11 @@ class HomeFragment : BaseFragment(), SwipeRefreshLayout.OnRefreshListener, HomeP
//TODO: pull some of this logic into the presenter, particularly the part that deals with ZEC <-> USD price conversion //TODO: pull some of this logic into the presenter, particularly the part that deals with ZEC <-> USD price conversion
override fun updateBalance(old: Long, new: Long) { override fun updateBalance(old: Long, new: Long) {
val zecValue = new/1e8 val zecValue = new.convertZatoshiToZec()
setZecValue(zecValue) setZecValue(zecValue.toZecString(3))
setUsdValue(SampleProperties.USD_PER_ZEC * zecValue) setUsdValue(zecValue.convertZecToUsd(SampleProperties.USD_PER_ZEC).toUsdString())
onContentRefreshComplete(zecValue) onContentRefreshComplete(new)
} }
override fun setTransactions(transactions: List<WalletTransaction>) { override fun setTransactions(transactions: List<WalletTransaction>) {
@ -245,12 +244,12 @@ class HomeFragment : BaseFragment(), SwipeRefreshLayout.OnRefreshListener, HomeP
private fun updatePrimaryTransaction(transaction: ActiveTransaction, transactionState: TransactionState) { private fun updatePrimaryTransaction(transaction: ActiveTransaction, transactionState: TransactionState) {
setActiveTransactionsShown(true) setActiveTransactionsShown(true)
Log.e("TWIG", "setting transaction state to ${transactionState::class.simpleName}") Log.e("TWIG", "setting transaction state to ${transactionState::class.simpleName}")
var title = "Active Transaction" var title = binding.includeContent.textActiveTransactionTitle.text?.toString() ?: ""
var subtitle = "Processing..." var subtitle = binding.includeContent.textActiveTransactionSubtitle.text?.toString() ?: ""
when (transactionState) { when (transactionState) {
TransactionState.Creating -> { TransactionState.Creating -> {
binding.includeContent.headerActiveTransaction.visibility = View.VISIBLE binding.includeContent.headerActiveTransaction.visibility = View.VISIBLE
title = "Preparing ${transaction.value.toZec(3)} ZEC" title = "Preparing ${transaction.value.convertZatoshiToZecString(3)} ZEC"
subtitle = "to ${(transaction as ActiveSendTransaction).toAddress}" subtitle = "to ${(transaction as ActiveSendTransaction).toAddress}"
setTransactionActive(transaction, true) setTransactionActive(transaction, true)
} }
@ -281,7 +280,7 @@ class HomeFragment : BaseFragment(), SwipeRefreshLayout.OnRefreshListener, HomeP
binding.includeContent.lottieActiveTransaction.playAnimation() binding.includeContent.lottieActiveTransaction.playAnimation()
title = "ZEC Sent" title = "ZEC Sent"
subtitle = "Today at 2:11pm" subtitle = "Today at 2:11pm"
binding.includeContent.textActiveTransactionValue.text = transaction.value.toZec(3).toString() binding.includeContent.textActiveTransactionValue.text = transaction.value.convertZatoshiToZecString(3)
binding.includeContent.textActiveTransactionValue.visibility = View.VISIBLE binding.includeContent.textActiveTransactionValue.visibility = View.VISIBLE
binding.includeContent.buttonActiveTransactionCancel.visibility = View.GONE binding.includeContent.buttonActiveTransactionCancel.visibility = View.GONE
} else { } else {
@ -405,8 +404,7 @@ class HomeFragment : BaseFragment(), SwipeRefreshLayout.OnRefreshListener, HomeP
.create() .create()
} }
private fun setUsdValue(value: Double) { private fun setUsdValue(valueString: String) {
val valueString = String.format("$%,.2f",value)
val hairSpace = "\u200A" val hairSpace = "\u200A"
// val adjustedValue = "$$hairSpace$valueString" // val adjustedValue = "$$hairSpace$valueString"
val textSpan = SpannableString(valueString) val textSpan = SpannableString(valueString)
@ -415,8 +413,8 @@ class HomeFragment : BaseFragment(), SwipeRefreshLayout.OnRefreshListener, HomeP
binding.includeHeader.textBalanceUsd.text = textSpan binding.includeHeader.textBalanceUsd.text = textSpan
} }
private fun setZecValue(value: Double) { private fun setZecValue(value: String) {
binding.includeHeader.textBalanceZec.text = if(value == 0.0) "0" else String.format("%.3f",value) binding.includeHeader.textBalanceZec.text = value
// // bugfix: there is a bug in motionlayout that causes text to flicker as it is resized because the last character doesn't fit. Padding both sides with a thin space works around this bug. // // bugfix: there is a bug in motionlayout that causes text to flicker as it is resized because the last character doesn't fit. Padding both sides with a thin space works around this bug.
@ -431,8 +429,8 @@ class HomeFragment : BaseFragment(), SwipeRefreshLayout.OnRefreshListener, HomeP
* If the balance changes from zero, the wallet is no longer empty so hide the empty view. * If the balance changes from zero, the wallet is no longer empty so hide the empty view.
* But don't do either of these things if the situation has not changed. * But don't do either of these things if the situation has not changed.
*/ */
private fun onContentRefreshComplete(value: Double) { private fun onContentRefreshComplete(value: Long) {
val isEmpty = value <= 0.0 val isEmpty = value <= 0L
// wasEmpty isn't enough info. it must be considered along with whether these views were ever initialized // wasEmpty isn't enough info. it must be considered along with whether these views were ever initialized
val wasEmpty = binding.includeContent.groupEmptyViewItems.visibility == View.VISIBLE val wasEmpty = binding.includeContent.groupEmptyViewItems.visibility == View.VISIBLE
// situation has changed when we weren't initialized but now we have a balance or emptiness has changed // situation has changed when we weren't initialized but now we have a balance or emptiness has changed
@ -537,42 +535,15 @@ class HomeFragment : BaseFragment(), SwipeRefreshLayout.OnRefreshListener, HomeP
lateinit var headerEmptyViews: Array<View> lateinit var headerEmptyViews: Array<View>
lateinit var headerFullViews: Array<View> lateinit var headerFullViews: Array<View>
fun shrink(): Double {
return binding.includeHeader.textBalanceZec.text.toString().trim().toDouble() - Random.nextDouble(5.0)
}
fun grow(): Double {
return binding.includeHeader.textBalanceZec.text.toString().trim().toDouble() + Random.nextDouble(5.0)
}
fun reduceValue() {
shrink().let {
if(it < 0) { setZecValue(0.0); toggleViews(empty); forceRedraw() }
else view?.postDelayed({
setZecValue(it)
setUsdValue(it*75.0)
reduceValue()
}, delay)
}
}
fun increaseValue(target: Double) {
grow().let {
if(it > target) { setZecValue(target); setUsdValue(target*75.0); toggleViews(empty) }
else view?.postDelayed({
setZecValue(it)
setUsdValue(it*75.0)
increaseValue(target)
if (headerFullViews[0].parent == null || headerEmptyViews[0].parent != null) toggleViews(false)
forceRedraw()
}, delay)
}
}
fun forceRedraw() { fun forceRedraw() {
view?.postDelayed({ view?.postDelayed({
binding.includeHeader.containerHomeHeader.progress = binding.includeHeader.containerHomeHeader.progress - 0.1f binding.includeHeader.containerHomeHeader.progress = binding.includeHeader.containerHomeHeader.progress - 0.1f
}, delay * 2) }, delay * 2)
} }
internal fun toggle(isEmpty: Boolean) { // internal fun toggle(isEmpty: Boolean) {
toggleValues(isEmpty) // toggleValues(isEmpty)
} // }
internal fun toggleViews(isEmpty: Boolean) { internal fun toggleViews(isEmpty: Boolean) {
Log.e("TWIG-t", "toggling views to isEmpty == $isEmpty") Log.e("TWIG-t", "toggling views to isEmpty == $isEmpty")
@ -643,14 +614,14 @@ class HomeFragment : BaseFragment(), SwipeRefreshLayout.OnRefreshListener, HomeP
view?.postDelayed(::forceRedraw, delay * 2) view?.postDelayed(::forceRedraw, delay * 2)
} }
internal fun toggleValues(isEmpty: Boolean) { // internal fun toggleValues(isEmpty: Boolean) {
empty = isEmpty // empty = isEmpty
if(empty) { // if(empty) {
reduceValue() // reduceValue()
} else { // } else {
increaseValue(Random.nextDouble(20.0, 100.0)) // increaseValue(Random.nextDouble(20.0, 100.0))
} // }
} // }
inner class HomeTransitionListener : Transition.TransitionListener { inner class HomeTransitionListener : Transition.TransitionListener {

View File

@ -1,27 +1,67 @@
package cash.z.android.wallet.ui.fragment package cash.z.android.wallet.ui.fragment
import android.animation.Animator
import android.content.Context import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.hardware.camera2.CameraCharacteristics
import android.hardware.camera2.CameraManager
import android.media.Image
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewAnimationUtils
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.databinding.DataBindingUtil import androidx.databinding.DataBindingUtil
import cash.z.android.cameraview.CameraView
import cash.z.android.wallet.R import cash.z.android.wallet.R
import cash.z.android.wallet.databinding.FragmentScanBinding import cash.z.android.wallet.databinding.FragmentScanBinding
import cash.z.android.wallet.extention.Toaster
import cash.z.android.wallet.ui.activity.MainActivity import cash.z.android.wallet.ui.activity.MainActivity
import com.google.firebase.ml.vision.FirebaseVision
import com.google.firebase.ml.vision.barcode.FirebaseVisionBarcode
import com.google.firebase.ml.vision.barcode.FirebaseVisionBarcodeDetectorOptions
import com.google.firebase.ml.vision.common.FirebaseVisionImage
import dagger.Module import dagger.Module
import dagger.android.ContributesAndroidInjector import dagger.android.ContributesAndroidInjector
/** /**
* Fragment for scanning addresss, hopefully. * Fragment for scanning addresss, hopefully.
*/ */
class ScanFragment : BaseFragment() { class ScanFragment : BaseFragment() {
lateinit var binding: FragmentScanBinding lateinit var binding: FragmentScanBinding
var barcodeCallback: BarcodeCallback? = null
// private var cameraSource: CameraSource? = null interface BarcodeCallback {
fun onBarcodeScanned(value: String)
}
private val revealCamera = Runnable {
binding.overlayBarcodeScan.apply {
val cX = measuredWidth / 2
val cY = measuredHeight / 2
ViewAnimationUtils.createCircularReveal(this, cX, cY, 0.0f, cX.toFloat()).start()
postDelayed({
val v:View = this
v.animate().alpha(0.0f).apply { duration = 2400L }.setListener(object : Animator.AnimatorListener {
override fun onAnimationRepeat(animation: Animator?) {
}
override fun onAnimationStart(animation: Animator?) {
}
override fun onAnimationEnd(animation: Animator?) {
binding.overlayBarcodeScan.visibility = View.GONE
}
override fun onAnimationCancel(animation: Animator?) {
binding.overlayBarcodeScan.visibility = View.GONE
}
})
},500L)
}
}
private val requiredPermissions: Array<String?> private val requiredPermissions: Array<String?>
get() { get() {
@ -77,6 +117,8 @@ class ScanFragment : BaseFragment() {
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
binding.overlayBarcodeScan.post(revealCamera)
System.err.println("camoorah : onResume ScanFragment")
if(allPermissionsGranted()) onStartCamera() if(allPermissionsGranted()) onStartCamera()
// launch { // launch {
// sendPresenter.start() // sendPresenter.start()
@ -161,9 +203,62 @@ class ScanFragment : BaseFragment() {
private fun onStartCamera() { private fun onStartCamera() {
with(binding.cameraView) { with(binding.cameraView) {
// workaround race conditions with google play services downloading the binaries for Firebase Vision APIs
postDelayed({ postDelayed({
firebaseCallback = PoCallback()
start() start()
}, 1500L) }, 1000L)
}
}
inner class PoCallback : CameraView.FirebaseCallback {
val options = FirebaseVisionBarcodeDetectorOptions.Builder()
.setBarcodeFormats(FirebaseVisionBarcode.FORMAT_QR_CODE)
.build()
val barcodeDetector = FirebaseVision.getInstance().getVisionBarcodeDetector(options)
var cameraId = getBackCameraId()
private fun getBackCameraId(): String {
val manager = mainActivity.getSystemService(Context.CAMERA_SERVICE) as CameraManager
for (cameraId in manager.cameraIdList) {
val characteristics = manager.getCameraCharacteristics(cameraId)
val cOrientation = characteristics.get(CameraCharacteristics.LENS_FACING)!!
if (cOrientation == CameraCharacteristics.LENS_FACING_BACK) return cameraId
}
throw IllegalArgumentException("no rear-facing camera found!")
}
override fun onImageAvailable(image: Image) {
System.err.println("camoorah : onImageAvailable: $image width: ${image.width} height: ${image.height}")
var firebaseImage = FirebaseVisionImage.fromMediaImage(image, getRotationCompensation(cameraId, mainActivity))
barcodeDetector
.detectInImage(firebaseImage)
.addOnSuccessListener { results ->
if (results.isNotEmpty()) {
val barcode = results[0]
val value = barcode.rawValue
val message = "found: $value"
Toaster.short(message)
onScanSuccess(value!!)
// TODO: highlight the barcode
var bounds = barcode.boundingBox
var corners = barcode.cornerPoints
binding.cameraView.setBarcode(barcode)
}
}
}
}
private var pendingSuccess = false
private fun onScanSuccess(value: String) {
if (!pendingSuccess) {
pendingSuccess = true
with(binding.cameraView) {
postDelayed({
barcodeCallback?.onBarcodeScanned(value)
}, 3000L)
}
} }
} }
@ -177,7 +272,6 @@ class ScanFragment : BaseFragment() {
} }
} }
@Module @Module
abstract class ScanFragmentModule { abstract class ScanFragmentModule {
@ContributesAndroidInjector @ContributesAndroidInjector

View File

@ -17,56 +17,43 @@ import androidx.core.content.getSystemService
import androidx.core.graphics.drawable.DrawableCompat import androidx.core.graphics.drawable.DrawableCompat
import androidx.core.text.toSpannable import androidx.core.text.toSpannable
import androidx.databinding.DataBindingUtil import androidx.databinding.DataBindingUtil
import androidx.navigation.fragment.FragmentNavigatorExtras import androidx.fragment.app.Fragment
import cash.z.android.qrecycler.QScanner
import cash.z.android.wallet.BuildConfig import cash.z.android.wallet.BuildConfig
import cash.z.android.wallet.R import cash.z.android.wallet.R
import cash.z.android.wallet.databinding.FragmentSendBinding import cash.z.android.wallet.databinding.FragmentSendBinding
import cash.z.android.wallet.extention.* import cash.z.android.wallet.extention.*
import cash.z.android.wallet.sample.SampleProperties import cash.z.android.wallet.sample.SampleProperties
import cash.z.android.wallet.sample.SampleProperties.DEV_MODE
import cash.z.android.wallet.ui.activity.MainActivity import cash.z.android.wallet.ui.activity.MainActivity
import cash.z.android.wallet.ui.presenter.SendPresenter import cash.z.android.wallet.ui.presenter.SendPresenter
import cash.z.wallet.sdk.ext.convertZatoshiToZecString
import cash.z.wallet.sdk.ext.safelyConvertToBigDecimal
import dagger.Module import dagger.Module
import dagger.android.ContributesAndroidInjector import dagger.android.ContributesAndroidInjector
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.text.DecimalFormat import java.text.DecimalFormat
import javax.inject.Inject
import kotlin.math.absoluteValue import kotlin.math.absoluteValue
/** /**
* Fragment for sending Zcash. * Fragment for sending Zcash.
* *
*/ */
class SendFragment : BaseFragment(), SendPresenter.SendView { class SendFragment : BaseFragment(), SendPresenter.SendView, ScanFragment.BarcodeCallback {
@Inject
lateinit var qrCodeScanner: QScanner
lateinit var sendPresenter: SendPresenter lateinit var sendPresenter: SendPresenter
lateinit var binding: FragmentSendBinding lateinit var binding: FragmentSendBinding
private val zecFormatter = DecimalFormat("#.######")
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 zec = R.string.zec_abbreviation.toAppString()
private val usd = R.string.usd_abbreviation.toAppString() private val usd = R.string.usd_abbreviation.toAppString()
//
// Lifecycle
//
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle? savedInstanceState: Bundle?
): View? { ): View? {
// val enterTransitionSet = TransitionInflater.from(mainActivity).inflateTransition(R.transition.transition_zec_sent).apply {
// duration = 3500L
// }
//
// this.sharedElementReturnTransition = enterTransitionSet
// this.sharedElementEnterTransition = enterTransitionSet
//
// this.allowReturnTransitionOverlap = false
// allowEnterTransitionOverlap = false
return DataBindingUtil.inflate<FragmentSendBinding>( return DataBindingUtil.inflate<FragmentSendBinding>(
inflater, R.layout.fragment_send, container, false inflater, R.layout.fragment_send, container, false
).let { ).let {
@ -75,6 +62,11 @@ class SendFragment : BaseFragment(), SendPresenter.SendView {
} }
} }
override fun onAttachFragment(childFragment: Fragment?) {
super.onAttachFragment(childFragment)
(childFragment as? ScanFragment)?.barcodeCallback = this
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
(activity as MainActivity).let { mainActivity -> (activity as MainActivity).let { mainActivity ->
@ -83,119 +75,6 @@ class SendFragment : BaseFragment(), SendPresenter.SendView {
mainActivity.supportActionBar?.setTitle(R.string.destination_title_send) mainActivity.supportActionBar?.setTitle(R.string.destination_title_send)
} }
init() init()
initDialog()
}
// temporary function until presenter is setup
private fun init() {
binding.imageSwapCurrency.setOnClickListener {
onToggleCurrency()
}
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.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() {
binding.dialogSendBackground.setOnClickListener {
hideSendDialog()
}
binding.dialogSubmitButton.setOnClickListener {
if (DEV_MODE) submit() else onSendZec()
}
} }
override fun onActivityCreated(savedInstanceState: Bundle?) { override fun onActivityCreated(savedInstanceState: Bundle?) {
@ -208,7 +87,6 @@ class SendFragment : BaseFragment(), SendPresenter.SendView {
launch { launch {
sendPresenter.start() sendPresenter.start()
} }
if(DEV_MODE) showSendDialog()
} }
override fun onPause() { override fun onPause() {
@ -216,56 +94,137 @@ class SendFragment : BaseFragment(), SendPresenter.SendView {
sendPresenter.stop() sendPresenter.stop()
} }
//
// SendView Implementation
//
override fun submit() { override fun submit() {
submitNoAnimations() mainActivity.navController.navigate(R.id.nav_home_fragment)
} }
private fun submitNoAnimations() { override fun setHeaders(isUsdSelected: Boolean, headerString: String, subheaderString: String) {
mainActivity.navController.navigate( showCurrencySymbols(isUsdSelected)
R.id.nav_home_fragment, setHeaderValue(headerString)
null, setSubheaderValue(subheaderString, isUsdSelected)
null,
FragmentNavigatorExtras(binding.dialogTextTitle to "transition_active_transaction_title")
)
} }
fun submitWithSharedElements() { override fun setHeaderValue(value: String) {
var extras = with(binding) { binding.textValueHeader.setText(value)
listOf(dialogSendBackground, dialogSendContents, dialogTextTitle, dialogTextAddress) }
.map{ it to it.transitionName }
.let { FragmentNavigatorExtras(*it.toTypedArray()) } @SuppressLint("SetTextI18n") // SetTextI18n lint logic has errors and does not recognize that the entire string contains variables, formatted per locale and loaded from string resources.
override fun setSubheaderValue(value: String, isUsdSelected: Boolean) {
val subheaderLabel = if (isUsdSelected) zec else usd
binding.textValueSubheader.text = "$value $subheaderLabel" //ignore SetTextI18n error here because it is invalid
}
override fun showSendDialog(zecString: String, usdString: String, toAddress: String, hasMemo: Boolean) {
hideKeyboard()
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 = toAddress
binding.dialogTextMemoIncluded.visibility = if(hasMemo) View.VISIBLE else View.GONE
binding.groupDialogSend.visibility = View.VISIBLE
}
override fun updateBalance(old: Long, new: Long) {
// TODO: use a formatted string resource here
val availableTextSpan = "${new.convertZatoshiToZecString(8)} $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
}
//
// ScanFragment.BarcodeCallback implemenation
//
override fun onBarcodeScanned(value: String) {
exitScanMode()
binding.inputZcashAddress.setText(value)
validateAddressInput()
}
//
// Internal View Logic
//
/**
* Initialize view logic only. Click listeners, text change handlers and tooltips.
*/
private fun init() {
/* Presenter calls */
binding.imageSwapCurrency.setOnClickListener {
sendPresenter.toggleCurrency()
} }
// val extras = FragmentNavigatorExtras(
// binding.dialogSendContents to binding.dialogSendContents.transitionName,
// binding.dialogTextTitle to getString(R.string.transition_active_transaction_title),
// binding.dialogTextAddress to getString(R.string.transition_active_transaction_address),
// binding.dialogSendBackground to getString(R.string.transition_active_transaction_background)
// )
mainActivity.navController.navigate(R.id.nav_home_fragment, binding.textValueHeader.apply {
null, afterTextChanged {
null, sendPresenter.headerUpdating(it)
extras) }
}
binding.buttonSendZec.setOnClickListener {
sendPresenter.sendPressed()
}
/* Non-Presenter calls (UI-only logic) */
binding.textAreaMemo.afterTextChanged {
binding.textMemoCharCount.text =
"${binding.textAreaMemo.text.length} / ${resources.getInteger(R.integer.memo_max_length)}"
}
binding.imageScanQr.apply {
TooltipCompat.setTooltipText(this, context.getString(R.string.send_tooltip_scan_qr))
}
binding.imageAddressShortcut?.apply {
if (BuildConfig.DEBUG) {
visibility = View.VISIBLE
TooltipCompat.setTooltipText(this, context.getString(R.string.send_tooltip_address_shortcut))
setOnClickListener(::onPasteShortcutAddress)
} else {
visibility = View.GONE
}
}
binding.dialogSendBackground.setOnClickListener {
hideSendDialog()
}
binding.dialogSubmitButton.setOnClickListener {
onSendZec()
}
binding.imageScanQr.setOnClickListener(::onScanQrCode)
// allow background taps to dismiss the keyboard and clear focus
binding.contentFragmentSend.setOnClickListener {
it?.findFocus()?.clearFocus()
validateUserInput()
hideKeyboard()
}
binding.buttonSendZec.text = getString(R.string.send_button_label, zec)
setSendEnabled(false)
} }
@SuppressLint("SetTextI18n") private fun showCurrencySymbols(isUsdSelected: Boolean) {
fun onToggleCurrency() { // visibility has some kind of bug that appears to be related to layout groups. So using alpha instead since our API level is high enough to support that
view?.findFocus()?.clearFocus() if (isUsdSelected) {
formatUserInput() binding.textDollarSymbolHeader.alpha = 1.0f
val isInitiallyUsd = usdSelected // hold this value because we modify visibility here and that's what the value is based on binding.imageZecSymbolSubheader.alpha = 1.0f
val subHeaderValue = binding.textValueSubheader.text.toString().substringBefore(' ') binding.imageZecSymbolHeader.alpha = 0.0f
val currencyLabelAfterToggle = if (isInitiallyUsd) usd else zec // what is selected is about to move to the subheader where the currency is labelled binding.textDollarSymbolSubheader.alpha = 0.0f
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 { } else {
binding.groupZecSelected.visibility = View.GONE binding.imageZecSymbolHeader.alpha = 1.0f
binding.groupUsdSelected.visibility = View.VISIBLE binding.textDollarSymbolSubheader.alpha = 1.0f
binding.textDollarSymbolHeader.alpha = 0.0f
binding.imageZecSymbolSubheader.alpha = 0.0f
} }
} }
@ -274,72 +233,44 @@ class SendFragment : BaseFragment(), SendPresenter.SendView {
val fragment = ScanFragment() val fragment = ScanFragment()
val ft = childFragmentManager.beginTransaction() val ft = childFragmentManager.beginTransaction()
.add(R.id.camera_placeholder, fragment, "camera_fragment") .add(R.id.camera_placeholder, fragment, "camera_fragment")
.addToBackStack("camera_fragment_scanning")
.commit() .commit()
// val intent = Intent(mainActivity, CameraQrScanner::class.java)
// mainActivity.startActivity(intent) binding.groupHiddenDuringScan.visibility = View.INVISIBLE
// qrCodeScanner.scanBarcode { barcode: Result<String> -> binding.buttonCancelScan.apply {
// if (barcode.isSuccess) { visibility = View.VISIBLE
// binding.inputZcashAddress.setText(barcode.getOrThrow()) animate().alpha(1.0f).apply {
// formatAddressInput() duration = 3000L
// } else { }
// Toaster.short("failed to scan QR code") setOnClickListener {
// } exitScanMode()
// } }
}
} }
// 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. // 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) { private fun onPasteShortcutAddress(view: View) {
view.context.alert(R.string.send_alert_shortcut_clicked) { view.context.alert(R.string.send_alert_shortcut_clicked) {
binding.inputZcashAddress.setText(SampleProperties.wallet.defaultSendAddress) binding.inputZcashAddress.setText(SampleProperties.wallet.defaultSendAddress)
setAddressError(null) validateAddressInput()
hideKeyboard() hideKeyboard()
} }
} }
override fun updateBalance(old: Long, new: Long) {
val zecBalance = new / 100000000.0
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()
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
}
private fun onSendZec() { private fun onSendZec() {
setSendEnabled(false) setSendEnabled(false)
// val currency = if(zecSelected) "ZEC" else "USD" sendPresenter.sendFunds()
// Toaster.short("sending ${text_value_header.text} $currency...")
//TODO: convert and use only zec amount
// val amount = text_value_header.text.toString().toDouble()
// val address = input_zcash_address.text.toString()
val amount = 0.0018
val address = "ztestsapling1fg82ar8y8whjfd52l0xcq0w3n7nn7cask2scp9rp27njeurr72ychvud57s9tu90fdqgwdt07lg"
sendPresenter.sendToAddress(amount, address)
} }
private fun exitScanMode() {
// val cameraFragment = childFragmentManager.findFragmentByTag("camera_fragment")
// Internal View Logic if (cameraFragment != null) {
// val ft = childFragmentManager.beginTransaction()
.remove(cameraFragment)
private fun showSendDialog() { .commit()
hideKeyboard() }
binding.buttonCancelScan.visibility = View.GONE
val address = binding.inputZcashAddress.text binding.groupHiddenDuringScan.visibility = View.VISIBLE
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() { private fun hideKeyboard() {
@ -352,16 +283,116 @@ class SendFragment : BaseFragment(), SendPresenter.SendView {
binding.groupDialogSend.visibility = View.GONE binding.groupDialogSend.visibility = View.GONE
} }
// note: be careful calling this with `true` that should only happen when all conditions have been validated
private fun setSendEnabled(isEnabled: Boolean) { private fun setSendEnabled(isEnabled: Boolean) {
binding.buttonSendZec.isEnabled = isEnabled binding.buttonSendZec.isEnabled = isEnabled
if (isEnabled) { }
binding.buttonSendZec.text = "send $zec"
// binding.progressSend.visibility = View.GONE private fun setAddressError(message: String?) {
if (message == null) {
setAddressLineColor()
binding.textAddressError.text = null
binding.textAddressError.visibility = View.GONE
} else { } else {
binding.buttonSendZec.text = "sending..." setAddressLineColor(R.color.zcashRed)
// binding.progressSend.visibility = View.VISIBLE binding.textAddressError.text = message
binding.textAddressError.visibility = View.VISIBLE
setSendEnabled(false)
} }
} }
private fun setAddressLineColor(@ColorRes colorRes: Int = R.color.zcashBlack_12) {
DrawableCompat.setTint(
binding.inputZcashAddress.background,
ContextCompat.getColor(mainActivity, colorRes)
)
}
private fun setAmountError(isError: Boolean) {
val color = if (isError) R.color.zcashRed else R.color.text_dark
binding.textAmountBackground.setTextColor(color.toAppColor())
}
//
// Validation
//
override fun validateUserInput(): Boolean {
val allValid = validateAddressInput() && validateAmountInput() && validateMemo()
setSendEnabled(allValid)
return allValid
}
/**
* Validate the memo input and update presenter when valid.
*
* @return true when the memo is valid
*/
private fun validateMemo(): Boolean {
val memo = binding.textAreaMemo.text.toString()
return memo.all { it.isLetterOrDigit() }.also { if (it) sendPresenter.memoValidated(memo) }
}
/**
* Validate the address input and update presenter when valid.
*
* @return true when the address is valid
*/
private fun validateAddressInput(): Boolean {
var isValid = false
val address = binding.inputZcashAddress.text.toString()
if (address.isNotEmpty() && address.length < R.integer.z_address_min_length.toAppInt()) setAddressError(R.string.send_error_address_too_short.toAppString())
else if (address.any { !it.isLetterOrDigit() }) setAddressError(R.string.send_error_address_invalid_char.toAppString())
else setAddressError(null).also { isValid = true; sendPresenter.addressValidated(address) }
return isValid
}
/**
* Validate the amount input and update the presenter when valid.
*
* @return true when the amount is valid
*/
private fun validateAmountInput(): Boolean {
return try {
val amount = binding.textValueHeader.text.toString().safelyConvertToBigDecimal()!!
sendPresenter.headerValidated(amount)
setAmountError(false)
true
} catch (t: Throwable) {
Toaster.short("Invalid ZEC or USD value")
setSendEnabled(false)
setAmountError(true)
false
}
}
// TODO: come back to this test code later and fix the shared element transitions
//
// fun submitWithSharedElements() {
// var extras = with(binding) {
// listOf(dialogSendBackground, dialogSendContents, dialogTextTitle, dialogTextAddress)
// .map{ it to it.transitionName }
// .let { FragmentNavigatorExtras(*it.toTypedArray()) }
// }
// val extras = FragmentNavigatorExtras(
// binding.dialogSendContents to binding.dialogSendContents.transitionName,
// binding.dialogTextTitle to getString(R.string.transition_active_transaction_title),
// binding.dialogTextAddress to getString(R.string.transition_active_transaction_address),
// binding.dialogSendBackground to getString(R.string.transition_active_transaction_background)
// )
//
// mainActivity.navController.navigate(R.id.nav_home_fragment,
// null,
// null,
// extras)
// }
} }

View File

@ -1,14 +1,16 @@
package cash.z.android.wallet.ui.presenter package cash.z.android.wallet.ui.presenter
import android.util.Log import android.util.Log
import cash.z.android.wallet.sample.SampleProperties
import cash.z.android.wallet.ui.presenter.Presenter.PresenterView import cash.z.android.wallet.ui.presenter.Presenter.PresenterView
import cash.z.wallet.sdk.data.Synchronizer import cash.z.wallet.sdk.data.Synchronizer
import cash.z.wallet.sdk.entity.Transaction import cash.z.wallet.sdk.ext.*
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.ReceiveChannel import kotlinx.coroutines.channels.ReceiveChannel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.math.BigDecimal
class SendPresenter( class SendPresenter(
private val view: SendView, private val view: SendView,
@ -17,13 +19,25 @@ class SendPresenter(
interface SendView : PresenterView { interface SendView : PresenterView {
fun updateBalance(old: Long, new: Long) fun updateBalance(old: Long, new: Long)
fun setHeaders(isUsdSelected: Boolean, headerString: String, subheaderString: String)
fun setHeaderValue(usdString: String)
fun setSubheaderValue(usdString: String, isUsdSelected: Boolean)
fun showSendDialog(zecString: String, usdString: String, toAddress: String, hasMemo: Boolean)
fun validateUserInput(): Boolean
fun submit() fun submit()
} }
private var balanceJob: Job? = null private var balanceJob: Job? = null
var sendUiModel = SendUiModel()
//
// LifeCycle
//
override suspend fun start() { override suspend fun start() {
Log.e("@TWIG-v", "sendPresenter starting!") Log.e("@TWIG-v", "sendPresenter starting!")
// set the currency to zec and update the view, intializing everything to zero
toggleCurrency()
with(view) { with(view) {
balanceJob = launchBalanceBinder(synchronizer.balance()) balanceJob = launchBalanceBinder(synchronizer.balance())
} }
@ -35,27 +49,117 @@ class SendPresenter(
} }
fun CoroutineScope.launchBalanceBinder(channel: ReceiveChannel<Long>) = launch { fun CoroutineScope.launchBalanceBinder(channel: ReceiveChannel<Long>) = launch {
var old: Long? = null
Log.e("@TWIG-v", "send balance binder starting!") Log.e("@TWIG-v", "send balance binder starting!")
for (new in channel) { for (new in channel) {
Log.e("@TWIG-v", "send polled a balance item") Log.e("@TWIG-v", "send polled a balance item")
bind(old, new).also { old = new } bind(new)
} }
Log.e("@TWIG-v", "send balance binder exiting!") Log.e("@TWIG-v", "send balance binder exiting!")
} }
fun sendToAddress(value: Double, toAddress: String) {
//
// Public API
//
fun sendFunds() {
//TODO: prehaps grab the activity scope or let the sycnchronizer have scope and make that function not suspend //TODO: prehaps grab the activity scope or let the sycnchronizer have scope and make that function not suspend
// also, we need to handle cancellations. So yeah, definitely do this differently // also, we need to handle cancellations. So yeah, definitely do this differently
GlobalScope.launch { GlobalScope.launch {
val zatoshi = Math.round(value * 1e8) synchronizer.sendToAddress(sendUiModel.zecValue!!, sendUiModel.toAddress)
synchronizer.sendToAddress(zatoshi, toAddress)
} }
view.submit() view.submit()
} }
fun bind(old: Long?, new: Long) { /**
Log.e("@TWIG-v", "binding balance of $new") * Called when the user has tapped on the button for toggling currency, swapping zec for usd
view.updateBalance(old ?: 0L, new) */
fun toggleCurrency() {
view.validateUserInput()
sendUiModel = sendUiModel.copy(isUsdSelected = !sendUiModel.isUsdSelected)
with(sendUiModel) {
view.setHeaders(
isUsdSelected = isUsdSelected,
headerString = if (isUsdSelected) usdValue.toUsdString() else zecValue.convertZatoshiToZecString(),
subheaderString = if (isUsdSelected) zecValue.convertZatoshiToZecString() else usdValue.toUsdString()
)
}
} }
}
/**
* As the user is typing the header string, update the subheader string. Do not modify our own internal model yet.
* Internal model is only updated after [headerValidated] is called.
*/
fun headerUpdating(headerValue: String) {
headerValue.safelyConvertToBigDecimal()?.let { headerValueAsDecimal ->
val subheaderValue = headerValueAsDecimal.convertCurrency(SampleProperties.USD_PER_ZEC, sendUiModel.isUsdSelected)
// subheader string contains opposite currency of the selected one. so if usd is selected, format the subheader as zec
val subheaderString = if(sendUiModel.isUsdSelected) subheaderValue.toZecString() else subheaderValue.toUsdString()
view.setSubheaderValue(subheaderString, sendUiModel.isUsdSelected)
}
}
fun sendPressed() {
with(sendUiModel) {
view.showSendDialog(
zecString = zecValue.convertZatoshiToZecString(),
usdString = usdValue.toUsdString(),
toAddress = toAddress,
hasMemo = !memo.isBlank()
)
}
}
fun headerValidated(amount: BigDecimal) {
with(sendUiModel) {
if (isUsdSelected) {
val headerString = amount.toUsdString()
val usdValue = amount
val zecValue = amount.convertUsdToZec(SampleProperties.USD_PER_ZEC)
val subheaderString = zecValue.toZecString()
sendUiModel = sendUiModel.copy(zecValue = zecValue.convertZecToZatoshi(), usdValue = usdValue)
view.setHeaders(sendUiModel.isUsdSelected, headerString, subheaderString)
} else {
val headerString = amount.toZecString()
val zecValue = amount
val usdValue = amount.convertZecToUsd(SampleProperties.USD_PER_ZEC)
val subheaderString = usdValue.toUsdString()
sendUiModel = sendUiModel.copy(zecValue = zecValue.convertZecToZatoshi(), usdValue = usdValue)
println("calling setHeaders with $headerString $subheaderString")
view.setHeaders(sendUiModel.isUsdSelected, headerString, subheaderString)
}
}
}
fun addressValidated(address: String) {
sendUiModel = sendUiModel.copy(toAddress = address)
}
/**
* After the user has typed a memo, validated by the UI, then update the model.
*
* assert: this method is only called after the memo input has been validated by the UI
*/
fun memoValidated(sanitizedValue: String) {
sendUiModel = sendUiModel.copy(memo = sanitizedValue)
}
fun bind(newZecBalance: Long) {
if (newZecBalance >= 0) {
Log.e("@TWIG-v", "binding balance of $newZecBalance")
val old = sendUiModel.zecValue
sendUiModel = sendUiModel.copy(zecValue = newZecBalance)
view.updateBalance(old ?: 0L, newZecBalance)
}
}
data class SendUiModel(
val isUsdSelected: Boolean = true,
val zecValue: Long? = null,
val usdValue: BigDecimal = BigDecimal.ZERO,
val toAddress: String = "",
val memo: String = ""
)
}

View File

@ -1,8 +1,19 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"> <layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<cash.z.android.cameraview.CameraView <cash.z.android.cameraview.CameraView
android:id="@+id/camera_view" android:id="@+id/camera_view"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent"/> android:layout_height="match_parent"
app:autoFocus="true"
app:facing="back"
app:flash="auto">
<View
android:id="@+id/overlay_barcode_scan"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/zcashBlack_54"
android:layout_gravity="center"/>
</cash.z.android.cameraview.CameraView>
</layout> </layout>

View File

@ -82,14 +82,20 @@
app:layout_constraintTop_toBottomOf="@id/background_header" app:layout_constraintTop_toBottomOf="@id/background_header"
app:layout_constraintVertical_chainStyle="spread_inside" /> app:layout_constraintVertical_chainStyle="spread_inside" />
<FrameLayout <Button
android:id="@+id/camera_placeholder" android:id="@+id/button_cancel_scan"
android:layout_width="0dp" android:layout_width="wrap_content"
android:layout_height="0dp" android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="@id/transition_active_transaction_bg" android:layout_marginTop="4dp"
app:layout_constraintBottom_toBottomOf="@id/transition_active_transaction_bg" android:background="@null"
app:layout_constraintStart_toStartOf="@id/transition_active_transaction_bg" android:backgroundTint="@null"
app:layout_constraintEnd_toEndOf="@id/transition_active_transaction_bg"/> android:minWidth="0dp"
android:minHeight="0dp"
android:text="@string/cancel"
android:textColor="@color/zcashRed"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="@id/transition_active_transaction_bg"
app:layout_constraintTop_toBottomOf="@id/transition_active_transaction_bg" />
<TextView <TextView
android:id="@+id/text_amount_background" android:id="@+id/text_amount_background"
@ -108,7 +114,7 @@
android:id="@+id/image_swap_currency" android:id="@+id/image_swap_currency"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginRight="28dp" android:layout_marginRight="18dp"
android:backgroundTint="@color/zcashPrimaryMedium" android:backgroundTint="@color/zcashPrimaryMedium"
android:foregroundTint="@color/colorAccent" android:foregroundTint="@color/colorAccent"
app:layout_constraintBottom_toBottomOf="@id/transition_active_transaction_bg" app:layout_constraintBottom_toBottomOf="@id/transition_active_transaction_bg"
@ -123,6 +129,7 @@
android:background="@null" android:background="@null"
android:inputType="numberDecimal" android:inputType="numberDecimal"
android:minWidth="12dp" android:minWidth="12dp"
android:maxLength="8"
android:text="0" android:text="0"
android:textColor="@color/text_dark" android:textColor="@color/text_dark"
android:textSize="@dimen/text_size_h3" android:textSize="@dimen/text_size_h3"
@ -160,6 +167,7 @@
android:layout_height="0dp" android:layout_height="0dp"
android:layout_marginTop="4dp" android:layout_marginTop="4dp"
android:tint="@color/text_dark" android:tint="@color/text_dark"
tools:visibility="invisible"
app:layout_constraintDimensionRatio="H,1:1" app:layout_constraintDimensionRatio="H,1:1"
app:layout_constraintEnd_toStartOf="@id/text_value_subheader" app:layout_constraintEnd_toStartOf="@id/text_value_subheader"
app:layout_constraintTop_toTopOf="@id/text_value_subheader" app:layout_constraintTop_toTopOf="@id/text_value_subheader"
@ -169,24 +177,26 @@
android:id="@+id/text_dollar_symbol_header" android:id="@+id/text_dollar_symbol_header"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="$" android:text="$"
android:textColor="@color/text_dark" android:textColor="@color/text_dark"
android:textSize="18dp" android:textSize="18dp"
android:includeFontPadding="false"
android:textStyle="bold" android:textStyle="bold"
app:layout_constraintEnd_toStartOf="@id/image_zec_symbol_header" tools:visibility="invisible"
app:layout_constraintTop_toTopOf="@id/image_zec_symbol_header" /> app:layout_constraintEnd_toStartOf="@id/text_value_header"
app:layout_constraintBottom_toBottomOf="@id/image_zec_symbol_header"
/>
<TextView <TextView
android:id="@+id/text_dollar_symbol_subheader" android:id="@+id/text_dollar_symbol_subheader"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="2dp" android:includeFontPadding="false"
android:layout_marginRight="2dp" android:layout_marginRight="2dp"
android:text="$" android:text="$"
android:textColor="@color/text_dark" android:textColor="@color/text_dark"
android:textSize="8dp" android:textSize="8dp"
app:layout_constraintEnd_toStartOf="@id/image_zec_symbol_subheader" app:layout_constraintEnd_toStartOf="@id/text_value_subheader"
app:layout_constraintTop_toTopOf="@id/image_zec_symbol_subheader" /> app:layout_constraintTop_toTopOf="@id/image_zec_symbol_subheader" />
<!-- Address --> <!-- Address -->
@ -195,7 +205,7 @@
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:hint="@string/send_hint_input_zcash_address" android:hint="@string/send_hint_input_zcash_address"
android:paddingRight="68dp" android:paddingRight="76dp"
android:singleLine="true" android:singleLine="true"
app:backgroundTint="@color/zcashBlack_12" app:backgroundTint="@color/zcashBlack_12"
app:layout_constraintBottom_toTopOf="@id/text_area_memo" app:layout_constraintBottom_toTopOf="@id/text_area_memo"
@ -232,7 +242,7 @@
android:id="@+id/image_address_shortcut" android:id="@+id/image_address_shortcut"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginRight="8dp" android:layout_marginRight="16dp"
app:layout_constraintBottom_toBottomOf="@id/image_scan_qr" app:layout_constraintBottom_toBottomOf="@id/image_scan_qr"
app:layout_constraintEnd_toStartOf="@id/image_scan_qr" app:layout_constraintEnd_toStartOf="@id/image_scan_qr"
app:layout_constraintTop_toTopOf="@id/image_scan_qr" app:layout_constraintTop_toTopOf="@id/image_scan_qr"
@ -307,6 +317,17 @@
app:layout_constraintEnd_toEndOf="@id/divider_memo" app:layout_constraintEnd_toEndOf="@id/divider_memo"
app:layout_constraintTop_toBottomOf="@id/divider_memo" /> app:layout_constraintTop_toBottomOf="@id/divider_memo" />
<!-- Scan Area -->
<FrameLayout
android:id="@+id/camera_placeholder"
android:layout_width="0dp"
android:layout_height="0dp"
tools:visibility="gone"
app:layout_constraintBottom_toBottomOf="@id/transition_active_transaction_bg"
app:layout_constraintEnd_toEndOf="@id/transition_active_transaction_bg"
app:layout_constraintStart_toStartOf="@id/transition_active_transaction_bg"
app:layout_constraintTop_toTopOf="@id/transition_active_transaction_bg" />
<!-- --> <!-- -->
<!-- Dialog --> <!-- Dialog -->
<!-- --> <!-- -->
@ -320,7 +341,7 @@
android:visibility="gone" android:visibility="gone"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
tools:visibility="visible" /> tools:visibility="gone" />
<androidx.constraintlayout.widget.ConstraintLayout <androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/dialog_send_contents" android:id="@+id/dialog_send_contents"
@ -338,7 +359,7 @@
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
app:layout_constraintWidth_default="percent" app:layout_constraintWidth_default="percent"
app:layout_constraintWidth_percent="0.80" app:layout_constraintWidth_percent="0.80"
tools:visibility="visible"> tools:visibility="gone">
<TextView <TextView
android:id="@+id/dialog_text_title" android:id="@+id/dialog_text_title"
@ -409,10 +430,10 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="1dp" android:layout_height="1dp"
android:background="@color/divider_background" android:background="@color/divider_background"
app:layout_goneMarginTop="32dp"
app:layout_constraintBottom_toTopOf="@+id/dialog_submit_button" app:layout_constraintBottom_toTopOf="@+id/dialog_submit_button"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/dialog_text_memo_included" /> app:layout_constraintTop_toBottomOf="@id/dialog_text_memo_included"
app:layout_goneMarginTop="32dp" />
<Button <Button
android:id="@+id/dialog_submit_button" android:id="@+id/dialog_submit_button"
@ -430,18 +451,6 @@
<!-- Groups --> <!-- Groups -->
<!-- --> <!-- -->
<androidx.constraintlayout.widget.Group
android:id="@+id/group_zec_selected"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:constraint_referenced_ids="image_zec_symbol_header,text_dollar_symbol_subheader" />
<androidx.constraintlayout.widget.Group
android:id="@+id/group_usd_selected"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:constraint_referenced_ids="image_zec_symbol_subheader,text_dollar_symbol_header" />
<androidx.constraintlayout.widget.Group <androidx.constraintlayout.widget.Group
android:id="@+id/group_dialog_send" android:id="@+id/group_dialog_send"
android:layout_width="wrap_content" android:layout_width="wrap_content"
@ -449,6 +458,20 @@
android:visibility="gone" android:visibility="gone"
app:constraint_referenced_ids="dialog_send_contents, dialog_send_background" /> app:constraint_referenced_ids="dialog_send_contents, dialog_send_background" />
<androidx.constraintlayout.widget.Group
android:id="@+id/group_hidden_during_scan"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:constraint_referenced_ids="
transition_active_transaction_bg,
text_value_subheader,
text_dollar_symbol_subheader,
image_zec_symbol_header,
text_dollar_symbol_header,
text_amount_background,
text_value_header,
image_swap_currency" />
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView> </ScrollView>
</layout> </layout>

View File

@ -65,9 +65,10 @@
<string name="send_submit_button_text">Send Zec</string> <string name="send_submit_button_text">Send Zec</string>
<string name="send_tooltip_scan_qr">Scan QR Code</string> <string name="send_tooltip_scan_qr">Scan QR Code</string>
<string name="send_tooltip_address_shortcut">Paste Sample Address</string> <string name="send_tooltip_address_shortcut">Paste Sample Address</string>
<string name="send_subheader_value">0 %1$s</string> <string name="send_button_label">send %1$s</string>
<string name="send_dialog_title">Send %1$s %2$s ($%3$s)?</string> <string name="send_dialog_title">Send %1$s %2$s ($%3$s)?</string>
<string name="send_alert_shortcut_clicked">Paste a valid sample address for testing?</string> <string name="send_alert_shortcut_clicked">Paste a valid sample address for testing?</string>
<string name="send_error_address_too_short">Address is too short.</string> <string name="send_error_address_too_short">Address is too short.</string>
<string name="send_error_address_invalid_char">Address contains invalid characters.</string>
</resources> </resources>

View File

@ -0,0 +1,110 @@
package cash.z.android.wallet.ui.presenter
import cash.z.wallet.sdk.data.Synchronizer
import cash.z.wallet.sdk.ext.convertZatoshiToZecString
import cash.z.wallet.sdk.ext.safelyConvertToBigDecimal
import com.nhaarman.mockitokotlin2.*
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.mockito.ArgumentMatchers.anyString
import org.mockito.Mock
import org.mockito.junit.jupiter.MockitoExtension
import java.math.BigDecimal
import java.math.RoundingMode
@ExtendWith(MockitoExtension::class)
internal class SendPresenterTest {
@Mock val view: SendPresenter.SendView = mock()
lateinit var presenter: SendPresenter
@BeforeEach
fun setUp(@Mock synchronizer: Synchronizer) {
presenter = SendPresenter(view, synchronizer)
}
@AfterEach
fun tearDown() {
}
@Test
fun headerUpdating_leadingZeros() {
presenter.headerUpdating("007")
verify(view).setSubheaderValue("0.14", true)
}
@Test
fun headerUpdating_commas() {
presenter.headerUpdating("1,000")
verify(view).setSubheaderValue("20.38", true)
}
@Test
fun headerUpdating_badInputCommas() {
presenter.headerUpdating("34,5")
assertTrue(presenter.sendUiModel.isUsdSelected)
verify(view).setSubheaderValue("7.03", true)
}
@Test
fun headerValidated_roundDown() {
presenter.toggleCurrency()
assertTrue(!presenter.sendUiModel.isUsdSelected, "zec should be selected to avoid testing conversions")
presenter.headerValidated("1.1234561".safelyConvertToBigDecimal()!!)
verify(view, atLeastOnce()).setHeaders(eq(false), eq("1.123456"), eq("55.13"))
}
@Test
fun headerValidated_usdConversion() {
assertTrue(presenter.sendUiModel.isUsdSelected, "expecting USD for this test")
presenter.headerValidated("1000.045".safelyConvertToBigDecimal()!!)
verify(view).setHeaders(eq(true), eq("1,000.04"), eq("20.379967"))
}
@Test
fun headerValidated_roundUp() {
presenter.toggleCurrency()
assertTrue(!presenter.sendUiModel.isUsdSelected, "zec should be selected to avoid testing conversions")
presenter.headerValidated("1.1234556".safelyConvertToBigDecimal()!!)
verify(view).setHeaders(eq(false), eq("1.123456"), eq("55.13"))
}
@Test
fun headerValidated_roundUpBankersRounding() {
// banker's rounding follows odd up, even down
// We'll encourage using that since it has good statistical properties and this rounding only happens in the UI
presenter.toggleCurrency()
assertTrue(!presenter.sendUiModel.isUsdSelected, "zec should be selected to avoid testing conversions")
presenter.headerValidated("1.1234535".safelyConvertToBigDecimal()!!)
assertEquals(112345350, presenter.sendUiModel.zecValue)
assertEquals("1.123454", presenter.sendUiModel.zecValue.convertZatoshiToZecString(), "5 is odd, we should round up")
presenter.headerValidated("1.1234565".safelyConvertToBigDecimal()!!)
assertEquals(112345650, presenter.sendUiModel.zecValue)
assertEquals("1.123456", presenter.sendUiModel.zecValue.convertZatoshiToZecString(), "6 is even, we should round down")
}
@Test
fun parseSafely_commas() {
assertEquals("3124", "3,124".safelyConvertToBigDecimal().toString())
}
@Test
fun parseSafely_commasBad() {
assertEquals("3124", ",3124".safelyConvertToBigDecimal().toString())
assertEquals("3124", "3,124".safelyConvertToBigDecimal().toString())
assertEquals("3124", "31,24".safelyConvertToBigDecimal().toString())
assertEquals("3124", "312,4".safelyConvertToBigDecimal().toString())
assertEquals("3124", "3124,".safelyConvertToBigDecimal().toString())
assertEquals("3124", ",3,1,2,4,".safelyConvertToBigDecimal().toString())
}
@Test
fun parseSafely_spaces() {
assertEquals("3124", " 3124".safelyConvertToBigDecimal().toString())
assertEquals("3124", "3 124".safelyConvertToBigDecimal().toString())
assertEquals("3124", "31 24".safelyConvertToBigDecimal().toString())
assertEquals("3124", "312 4".safelyConvertToBigDecimal().toString())
assertEquals("3124", "3124 ".safelyConvertToBigDecimal().toString())
assertEquals("3124", " 3 1 2 4 ".safelyConvertToBigDecimal().toString())
assertEquals("3124", " 3 12 4 ".safelyConvertToBigDecimal().toString())
}
}

View File

@ -16,31 +16,39 @@
package cash.z.android.cameraview package cash.z.android.cameraview
import cash.z.android.qrecycler.R
import android.app.Activity import android.app.Activity
import android.content.Context import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RectF
import android.hardware.camera2.CameraAccessException
import android.hardware.camera2.CameraCharacteristics
import android.hardware.camera2.CameraManager
import android.media.Image
import android.os.Build import android.os.Build
import android.os.Parcel import android.os.Parcel
import android.os.Parcelable import android.os.Parcelable
import android.util.AttributeSet import android.util.AttributeSet
import android.view.Surface
import android.view.View import android.view.View
import android.widget.FrameLayout import android.widget.FrameLayout
import androidx.annotation.IntDef import androidx.annotation.IntDef
import androidx.annotation.NonNull import androidx.annotation.NonNull
import androidx.annotation.Nullable import androidx.annotation.Nullable
import androidx.core.os.ParcelableCompat import androidx.annotation.RequiresApi
import androidx.core.os.ParcelableCompatCreatorCallbacks import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import cash.z.android.cameraview.api21.Camera2 import cash.z.android.cameraview.api21.Camera2
import cash.z.android.cameraview.base.AspectRatio import cash.z.android.cameraview.base.AspectRatio
import cash.z.android.cameraview.base.CameraViewImpl import cash.z.android.cameraview.base.CameraViewImpl
import cash.z.android.cameraview.base.Constants import cash.z.android.cameraview.base.Constants
import cash.z.android.cameraview.base.PreviewImpl import cash.z.android.cameraview.base.PreviewImpl
import cash.z.android.qrecycler.R
import com.google.android.cameraview.Camera2Api23 import com.google.android.cameraview.Camera2Api23
import com.google.android.cameraview.TextureViewPreview import com.google.android.cameraview.TextureViewPreview
import java.lang.IllegalStateException import com.google.firebase.ml.vision.barcode.FirebaseVisionBarcode
import java.lang.annotation.Retention import com.google.firebase.ml.vision.common.FirebaseVisionImageMetadata
import java.lang.annotation.RetentionPolicy
import java.util.* import java.util.*
open class CameraView(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : open class CameraView(context: Context, attrs: AttributeSet?, defStyleAttr: Int) :
@ -56,6 +64,16 @@ open class CameraView(context: Context, attrs: AttributeSet?, defStyleAttr: Int)
private var mDisplayOrientationDetector: DisplayOrientationDetector? private var mDisplayOrientationDetector: DisplayOrientationDetector?
var firebaseCallback: FirebaseCallback? = null
set(value) {
(mImpl as? Camera2)?.firebaseCallback = value
field = value
}
lateinit var cameraId: String
/** /**
* @return `true` if the camera is opened. * @return `true` if the camera is opened.
*/ */
@ -293,6 +311,18 @@ open class CameraView(context: Context, attrs: AttributeSet?, defStyleAttr: Int)
} }
} }
private var rectPaint = Paint().apply {
color = Color.GREEN
style = Paint.Style.FILL
strokeWidth = 8f
}
override fun draw(canvas: Canvas) {
super.draw(canvas)
val rect = RectF(0f,0f,canvas.width.toFloat(),canvas.height.toFloat())
canvas.drawRect(rect, rectPaint)
}
override fun onSaveInstanceState(): Parcelable? { override fun onSaveInstanceState(): Parcelable? {
val state = SavedState(super.onSaveInstanceState()) val state = SavedState(super.onSaveInstanceState())
state.facing = facing state.facing = facing
@ -329,6 +359,11 @@ open class CameraView(context: Context, attrs: AttributeSet?, defStyleAttr: Int)
// onRestoreInstanceState(state) // onRestoreInstanceState(state)
// mImpl.start() // mImpl.start()
} }
// start results in cameraId being set so bubble that up for firebase rotation use
when(mImpl) {
is Camera2 -> cameraId = (mImpl as Camera2).cameraId!!
}
} }
/** /**
@ -367,6 +402,56 @@ open class CameraView(context: Context, attrs: AttributeSet?, defStyleAttr: Int)
mImpl.takePicture() mImpl.takePicture()
} }
fun setBarcode(barcode: FirebaseVisionBarcode) {
}
interface FirebaseCallback {
fun onImageAvailable(image: Image)
// TODO: attribute this code. The library I found it in has no attribution but it clearly came from somewhere. Modified it to not require instantiating a sparsearray of orientations (just use when instead) also simplified method signature
// one source : https://github.com/firebase/snippets-android/blob/master/mlkit/app/src/main/java/com/google/firebase/example/mlkit/VisionImage.java
/**
* Get the angle by which an image must be rotated given the device's current
* orientation.
*/
@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
@Throws(CameraAccessException::class)
fun getRotationCompensation(cameraId: String, activity: Activity): Int {
// Get the device's current rotation relative to its "native" orientation.
// Then, from the ORIENTATIONS table, look up the angle the image must be
// rotated to compensate for the device's rotation.
val deviceRotation = activity.windowManager.defaultDisplay.rotation
var rotationCompensation = when(deviceRotation) {
Surface.ROTATION_0 -> 90
Surface.ROTATION_90 -> 0
Surface.ROTATION_180 -> 270
Surface.ROTATION_270 -> 180
else -> throw IllegalArgumentException("Unsupported rotation value! Expected [0|90|180|270] but got: $deviceRotation")
}
// On most devices, the sensor orientation is 90 degrees, but for some
// devices it is 270 degrees. For devices with a sensor orientation of
// 270, rotate the image an additional 180 ((270 + 270) % 360) degrees.
val cameraManager = activity.getSystemService(AppCompatActivity.CAMERA_SERVICE) as CameraManager
val sensorOrientation = cameraManager
.getCameraCharacteristics(cameraId)
.get(CameraCharacteristics.SENSOR_ORIENTATION)!!
rotationCompensation = (rotationCompensation + sensorOrientation + 270) % 360
// Return the corresponding FirebaseVisionImageMetadata rotation value.
val result: Int
when (rotationCompensation) {
0 -> result = FirebaseVisionImageMetadata.ROTATION_0
90 -> result = FirebaseVisionImageMetadata.ROTATION_90
180 -> result = FirebaseVisionImageMetadata.ROTATION_180
270 -> result = FirebaseVisionImageMetadata.ROTATION_270
else -> throw IllegalArgumentException("Unsupported rotation value! Expected [0|90|180|270] but got: $deviceRotation") // this should be impossible, given that we would have already thrown an exception
}
return result
}
}
private inner class CallbackBridge internal constructor() : CameraViewImpl.Callback { private inner class CallbackBridge internal constructor() : CameraViewImpl.Callback {
private val mCallbacks = ArrayList<Callback>() private val mCallbacks = ArrayList<Callback>()
@ -465,14 +550,14 @@ open class CameraView(context: Context, attrs: AttributeSet?, defStyleAttr: Int)
* *
* @param cameraView The associated [CameraView]. * @param cameraView The associated [CameraView].
*/ */
fun onCameraOpened(cameraView: CameraView) {} open fun onCameraOpened(cameraView: CameraView) {}
/** /**
* Called when camera is closed. * Called when camera is closed.
* *
* @param cameraView The associated [CameraView]. * @param cameraView The associated [CameraView].
*/ */
fun onCameraClosed(cameraView: CameraView) {} open fun onCameraClosed(cameraView: CameraView) {}
/** /**
* Called when a picture is taken. * Called when a picture is taken.
@ -480,7 +565,7 @@ open class CameraView(context: Context, attrs: AttributeSet?, defStyleAttr: Int)
* @param cameraView The associated [CameraView]. * @param cameraView The associated [CameraView].
* @param data JPEG data. * @param data JPEG data.
*/ */
fun onPictureTaken(cameraView: CameraView, data: ByteArray) {} open fun onPictureTaken(cameraView: CameraView, data: ByteArray) {}
} }
companion object { companion object {

View File

@ -23,18 +23,24 @@ import android.graphics.ImageFormat
import android.hardware.camera2.* import android.hardware.camera2.*
import android.hardware.camera2.params.StreamConfigurationMap import android.hardware.camera2.params.StreamConfigurationMap
import android.media.ImageReader import android.media.ImageReader
import android.os.Handler
import android.util.Log import android.util.Log
import android.util.SparseIntArray import android.util.SparseIntArray
import androidx.annotation.NonNull import androidx.annotation.NonNull
import androidx.annotation.RequiresPermission import androidx.annotation.RequiresPermission
import cash.z.android.cameraview.CameraView
import cash.z.android.cameraview.base.* import cash.z.android.cameraview.base.*
import java.util.* import android.os.HandlerThread
@TargetApi(21) @TargetApi(21)
internal open class Camera2(callback: CameraViewImpl.Callback, preview: PreviewImpl, context: Context) : CameraViewImpl(callback, preview) { internal open class Camera2(callback: CameraViewImpl.Callback, preview: PreviewImpl, context: Context) : CameraViewImpl(callback, preview) {
private val mCameraManager: CameraManager private val mCameraManager: CameraManager
var firebaseCallback: CameraView.FirebaseCallback? = null
private val mCameraDeviceCallback = object : CameraDevice.StateCallback() { private val mCameraDeviceCallback = object : CameraDevice.StateCallback() {
override fun onOpened(@NonNull camera: CameraDevice) { override fun onOpened(@NonNull camera: CameraDevice) {
@ -70,7 +76,7 @@ internal open class Camera2(callback: CameraViewImpl.Callback, preview: PreviewI
try { try {
mCaptureSession!!.setRepeatingRequest( mCaptureSession!!.setRepeatingRequest(
mPreviewRequestBuilder!!.build(), mPreviewRequestBuilder!!.build(),
mCaptureCallback, null mCaptureCallback, backgroundHandler
) )
} catch (e: CameraAccessException) { } catch (e: CameraAccessException) {
Log.e(TAG, "Failed to start camera preview because it couldn't access camera", e) Log.e(TAG, "Failed to start camera preview because it couldn't access camera", e)
@ -101,7 +107,7 @@ internal open class Camera2(callback: CameraViewImpl.Callback, preview: PreviewI
) )
setState(Camera2.PictureCaptureCallback.STATE_PRECAPTURE) setState(Camera2.PictureCaptureCallback.STATE_PRECAPTURE)
try { try {
mCaptureSession!!.capture(mPreviewRequestBuilder!!.build(), this, null) mCaptureSession!!.capture(mPreviewRequestBuilder!!.build(), this, backgroundHandler)
mPreviewRequestBuilder!!.set( mPreviewRequestBuilder!!.set(
CaptureRequest.CONTROL_AE_PRECAPTURE_TRIGGER, CaptureRequest.CONTROL_AE_PRECAPTURE_TRIGGER,
CaptureRequest.CONTROL_AE_PRECAPTURE_TRIGGER_IDLE CaptureRequest.CONTROL_AE_PRECAPTURE_TRIGGER_IDLE
@ -119,19 +125,21 @@ internal open class Camera2(callback: CameraViewImpl.Callback, preview: PreviewI
} }
private val mOnImageAvailableListener = ImageReader.OnImageAvailableListener { reader -> private val mOnImageAvailableListener = ImageReader.OnImageAvailableListener { reader ->
reader.acquireNextImage().use { image -> reader.acquireNextImage().use { image ->
val planes = image.planes val planes = image.planes
if (planes.size > 0) { if (planes.isNotEmpty()) {
val buffer = planes[0].buffer System.err.println("camoorah : planes was empty: $firebaseCallback")
val data = ByteArray(buffer.remaining()) firebaseCallback?.onImageAvailable(image)
buffer.get(data) try{ image.close() } catch(t: Throwable){ System.err.println("camoorah : failed to close")}
mCallback.onPictureTaken(data) } else {
System.err.println("planes was empty")
} }
} }
} }
private var mCameraId: String? = null var cameraId: String? = null
private var mCameraCharacteristics: CameraCharacteristics? = null private var mCameraCharacteristics: CameraCharacteristics? = null
@ -167,7 +175,7 @@ internal open class Camera2(callback: CameraViewImpl.Callback, preview: PreviewI
try { try {
mCaptureSession!!.setRepeatingRequest( mCaptureSession!!.setRepeatingRequest(
mPreviewRequestBuilder!!.build(), mPreviewRequestBuilder!!.build(),
mCaptureCallback, null mCaptureCallback, backgroundHandler
) )
} catch (e: CameraAccessException) { } catch (e: CameraAccessException) {
field = saved field = saved
@ -212,7 +220,7 @@ internal open class Camera2(callback: CameraViewImpl.Callback, preview: PreviewI
try { try {
mCaptureSession!!.setRepeatingRequest( mCaptureSession!!.setRepeatingRequest(
mPreviewRequestBuilder!!.build(), mPreviewRequestBuilder!!.build(),
mCaptureCallback, null mCaptureCallback, backgroundHandler
) )
} catch (e: CameraAccessException) { } catch (e: CameraAccessException) {
mAutoFocus = !mAutoFocus mAutoFocus = !mAutoFocus
@ -236,6 +244,7 @@ internal open class Camera2(callback: CameraViewImpl.Callback, preview: PreviewI
if (!chooseCameraIdByFacing()) { if (!chooseCameraIdByFacing()) {
return false return false
} }
startBackgroundThread()
collectCameraInfo() collectCameraInfo()
prepareImageReader() prepareImageReader()
startOpeningCamera() startOpeningCamera()
@ -243,6 +252,7 @@ internal open class Camera2(callback: CameraViewImpl.Callback, preview: PreviewI
} }
override fun stop() { override fun stop() {
stopBackgroundThread()
if (mCaptureSession != null) { if (mCaptureSession != null) {
mCaptureSession!!.close() mCaptureSession!!.close()
mCaptureSession = null mCaptureSession = null
@ -314,14 +324,14 @@ internal open class Camera2(callback: CameraViewImpl.Callback, preview: PreviewI
val internal = characteristics.get(CameraCharacteristics.LENS_FACING) val internal = characteristics.get(CameraCharacteristics.LENS_FACING)
?: throw NullPointerException("Unexpected state: LENS_FACING null") ?: throw NullPointerException("Unexpected state: LENS_FACING null")
if (internal == internalFacing) { if (internal == internalFacing) {
mCameraId = id cameraId = id
mCameraCharacteristics = characteristics mCameraCharacteristics = characteristics
return true return true
} }
} }
// Not found // Not found
mCameraId = ids[0] cameraId = ids[0]
mCameraCharacteristics = mCameraManager.getCameraCharacteristics(mCameraId!!) mCameraCharacteristics = mCameraManager.getCameraCharacteristics(cameraId!!)
val level = mCameraCharacteristics!!.get( val level = mCameraCharacteristics!!.get(
CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL
) )
@ -359,7 +369,7 @@ internal open class Camera2(callback: CameraViewImpl.Callback, preview: PreviewI
private fun collectCameraInfo() { private fun collectCameraInfo() {
val map = mCameraCharacteristics!!.get( val map = mCameraCharacteristics!!.get(
CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP
) ?: throw IllegalStateException("Failed to get configuration map: " + mCameraId!!) ) ?: throw IllegalStateException("Failed to get configuration map: " + cameraId!!)
mPreviewSizes.clear() mPreviewSizes.clear()
for (size in map.getOutputSizes(mPreview.outputClass)) { for (size in map.getOutputSizes(mPreview.outputClass)) {
val width = size.width val width = size.width
@ -392,12 +402,12 @@ internal open class Camera2(callback: CameraViewImpl.Callback, preview: PreviewI
if (mImageReader != null) { if (mImageReader != null) {
mImageReader!!.close() mImageReader!!.close()
} }
val largest = mPictureSizes.sizes(mAspectRatio).last() // val largest = mPictureSizes.sizes(mAspectRatio).last()
val previewSize = chooseOptimalSize()
mImageReader = ImageReader.newInstance( mImageReader = ImageReader.newInstance(
largest.width, largest.height, previewSize.width / 4, previewSize.height / 4, ImageFormat.YUV_420_888, 2
ImageFormat.JPEG, /* maxImages */ 2
) )
mImageReader!!.setOnImageAvailableListener(mOnImageAvailableListener, null) mImageReader!!.setOnImageAvailableListener(mOnImageAvailableListener, backgroundHandler)
} }
/** /**
@ -409,9 +419,9 @@ internal open class Camera2(callback: CameraViewImpl.Callback, preview: PreviewI
@RequiresPermission(Manifest.permission.CAMERA) @RequiresPermission(Manifest.permission.CAMERA)
private fun startOpeningCamera() { private fun startOpeningCamera() {
try { try {
mCameraManager.openCamera(mCameraId!!, mCameraDeviceCallback, null) mCameraManager.openCamera(cameraId!!, mCameraDeviceCallback, backgroundHandler)
} catch (e: CameraAccessException) { } catch (e: CameraAccessException) {
throw RuntimeException("Failed to open camera: " + mCameraId!!, e) throw RuntimeException("Failed to open camera: " + cameraId!!, e)
} }
} }
@ -434,9 +444,10 @@ internal open class Camera2(callback: CameraViewImpl.Callback, preview: PreviewI
try { try {
mPreviewRequestBuilder = mCamera!!.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW) mPreviewRequestBuilder = mCamera!!.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW)
mPreviewRequestBuilder!!.addTarget(surface) mPreviewRequestBuilder!!.addTarget(surface)
mPreviewRequestBuilder!!.addTarget(mImageReader!!.surface)
mCamera!!.createCaptureSession( mCamera!!.createCaptureSession(
Arrays.asList(surface, mImageReader!!.surface), listOf(surface, mImageReader!!.surface),
mSessionCallback, null mSessionCallback, backgroundHandler
) )
} catch (e: CameraAccessException) { } catch (e: CameraAccessException) {
throw RuntimeException("Failed to start camera session") throw RuntimeException("Failed to start camera session")
@ -572,7 +583,7 @@ internal open class Camera2(callback: CameraViewImpl.Callback, preview: PreviewI
) )
try { try {
mCaptureCallback.setState(PictureCaptureCallback.STATE_LOCKING) mCaptureCallback.setState(PictureCaptureCallback.STATE_LOCKING)
mCaptureSession!!.capture(mPreviewRequestBuilder!!.build(), mCaptureCallback, null) mCaptureSession!!.capture(mPreviewRequestBuilder!!.build(), mCaptureCallback, backgroundHandler)
} catch (e: CameraAccessException) { } catch (e: CameraAccessException) {
Log.e(TAG, "Failed to lock focus.", e) Log.e(TAG, "Failed to lock focus.", e)
} }
@ -583,6 +594,7 @@ internal open class Camera2(callback: CameraViewImpl.Callback, preview: PreviewI
* Captures a still picture. * Captures a still picture.
*/ */
fun captureStillPicture() { fun captureStillPicture() {
Log.e("camoorah", "capturing still picture")
try { try {
val captureRequestBuilder = mCamera!!.createCaptureRequest( val captureRequestBuilder = mCamera!!.createCaptureRequest(
CameraDevice.TEMPLATE_STILL_CAPTURE CameraDevice.TEMPLATE_STILL_CAPTURE
@ -647,7 +659,7 @@ internal open class Camera2(callback: CameraViewImpl.Callback, preview: PreviewI
) { ) {
unlockFocus() unlockFocus()
} }
}, null }, backgroundHandler
) )
} catch (e: CameraAccessException) { } catch (e: CameraAccessException) {
Log.e(TAG, "Cannot capture a still picture.", e) Log.e(TAG, "Cannot capture a still picture.", e)
@ -665,21 +677,47 @@ internal open class Camera2(callback: CameraViewImpl.Callback, preview: PreviewI
CaptureRequest.CONTROL_AF_TRIGGER_CANCEL CaptureRequest.CONTROL_AF_TRIGGER_CANCEL
) )
try { try {
mCaptureSession!!.capture(mPreviewRequestBuilder!!.build(), mCaptureCallback, null) mCaptureSession!!.capture(mPreviewRequestBuilder!!.build(), mCaptureCallback, backgroundHandler)
updateAutoFocus() updateAutoFocus()
updateFlash() updateFlash()
mPreviewRequestBuilder!!.set( mPreviewRequestBuilder!!.set(
CaptureRequest.CONTROL_AF_TRIGGER, CaptureRequest.CONTROL_AF_TRIGGER,
CaptureRequest.CONTROL_AF_TRIGGER_IDLE CaptureRequest.CONTROL_AF_TRIGGER_IDLE
) )
mCaptureSession!!.setRepeatingRequest(mPreviewRequestBuilder!!.build(), mCaptureCallback, null) mCaptureSession!!.setRepeatingRequest(mPreviewRequestBuilder!!.build(), mCaptureCallback, backgroundHandler)
mCaptureCallback.setState(PictureCaptureCallback.STATE_PREVIEW) mCaptureCallback.setState(PictureCaptureCallback.STATE_PREVIEW)
} catch (e: CameraAccessException) { } catch (e: CameraAccessException) {
Log.e(TAG, "Failed to restart camera preview.", e) Log.e(TAG, "Failed to restart camera preview.", e)
} }
} }
var backgroundHandlerThread: HandlerThread? = null
var backgroundHandler: Handler? = null
/**
* Starts a background thread and its [Handler].
*/
private fun startBackgroundThread() {
backgroundHandlerThread = HandlerThread("CameraBackgroundProcessor")
backgroundHandlerThread?.start()
backgroundHandler = Handler(backgroundHandlerThread?.looper)
}
/**
* Stops the background thread and its [Handler].
*/
private fun stopBackgroundThread() {
backgroundHandlerThread?.quitSafely()
try {
backgroundHandlerThread?.join()
backgroundHandlerThread = null
backgroundHandler = null
} catch (e: InterruptedException) {
e.printStackTrace()
}
}
/** /**
* A [CameraCaptureSession.CaptureCallback] for capturing a still picture. * A [CameraCaptureSession.CaptureCallback] for capturing a still picture.
*/ */

View File

@ -7,6 +7,7 @@ import com.google.android.gms.tasks.Task
import com.google.firebase.ml.vision.FirebaseVision import com.google.firebase.ml.vision.FirebaseVision
import com.google.firebase.ml.vision.barcode.FirebaseVisionBarcode import com.google.firebase.ml.vision.barcode.FirebaseVisionBarcode
import com.google.firebase.ml.vision.barcode.FirebaseVisionBarcodeDetector import com.google.firebase.ml.vision.barcode.FirebaseVisionBarcodeDetector
import com.google.firebase.ml.vision.barcode.FirebaseVisionBarcodeDetectorOptions
import com.google.firebase.ml.vision.common.FirebaseVisionImage import com.google.firebase.ml.vision.common.FirebaseVisionImage
import java.io.IOException import java.io.IOException
@ -18,8 +19,11 @@ class BarcodeScanningProcessor : VisionProcessorBase<List<FirebaseVisionBarcode>
// FirebaseVisionBarcodeDetectorOptions.Builder() // FirebaseVisionBarcodeDetectorOptions.Builder()
// .setBarcodeFormats(FirebaseVisionBarcode.FORMAT_QR_CODE) // .setBarcodeFormats(FirebaseVisionBarcode.FORMAT_QR_CODE)
// .build() // .build()
public val detector: FirebaseVisionBarcodeDetector by lazy { val detector: FirebaseVisionBarcodeDetector by lazy {
FirebaseVision.getInstance().visionBarcodeDetector val options = FirebaseVisionBarcodeDetectorOptions.Builder()
.setBarcodeFormats(FirebaseVisionBarcode.FORMAT_QR_CODE)
.build()
FirebaseVision.getInstance().getVisionBarcodeDetector(options)
} }