From dd598272e6c32da7a816dabcaad2480ea1887591 Mon Sep 17 00:00:00 2001 From: Vladimir Tanakov Date: Tue, 28 Sep 2021 15:40:45 +0300 Subject: [PATCH] feat: Add simple scan feature (#4236) * Add repository to work with camera prefs * Add SimpleScan feature * fix ml camera * Add tests for SimpleScanViewModel * Add string to obf * fix build --- app/src/main/AndroidManifest.xml | 5 + .../compare/ProductCompareActivity.kt | 68 ++++- .../compare/ProductCompareViewModel.kt | 65 ++++- .../compare/ScanProductActivityContract.kt | 28 -- .../features/scan/ContinuousScanActivity.kt | 110 +++----- .../features/simplescan/SimpleScanActivity.kt | 247 ++++++++++++++++++ .../simplescan/SimpleScanActivityContract.kt | 26 ++ .../simplescan/SimpleScanScannerOptions.kt | 10 + .../simplescan/SimpleScanViewModel.kt | 76 ++++++ .../github/scrachx/openfood/hilt/AppModule.kt | 2 +- .../scrachx/openfood/models/CameraState.kt | 13 + .../ScannerPreferencesRepository.kt | 76 ++++++ .../openfood/utils/PreferencesUtils.kt | 9 - .../ic_baseline_camera_focus_off_24.xml | 5 + .../ic_baseline_camera_focus_on_24.xml | 5 + .../ic_baseline_flip_camera_android_24.xml | 7 + .../layout/activity_product_comparison.xml | 20 ++ .../main/res/layout/activity_simple_scan.xml | 64 +++++ app/src/main/res/values/strings.xml | 2 + app/src/obf/res/values/strings.xml | 1 + .../compare/ProductCompareViewModelTest.kt | 12 +- .../simplescan/SimpleScanViewModelTest.kt | 142 ++++++++++ 22 files changed, 852 insertions(+), 141 deletions(-) delete mode 100644 app/src/main/java/openfoodfacts/github/scrachx/openfood/features/compare/ScanProductActivityContract.kt create mode 100644 app/src/main/java/openfoodfacts/github/scrachx/openfood/features/simplescan/SimpleScanActivity.kt create mode 100644 app/src/main/java/openfoodfacts/github/scrachx/openfood/features/simplescan/SimpleScanActivityContract.kt create mode 100644 app/src/main/java/openfoodfacts/github/scrachx/openfood/features/simplescan/SimpleScanScannerOptions.kt create mode 100644 app/src/main/java/openfoodfacts/github/scrachx/openfood/features/simplescan/SimpleScanViewModel.kt create mode 100644 app/src/main/java/openfoodfacts/github/scrachx/openfood/models/CameraState.kt create mode 100644 app/src/main/java/openfoodfacts/github/scrachx/openfood/repositories/ScannerPreferencesRepository.kt create mode 100644 app/src/main/res/drawable/ic_baseline_camera_focus_off_24.xml create mode 100644 app/src/main/res/drawable/ic_baseline_camera_focus_on_24.xml create mode 100644 app/src/main/res/drawable/ic_baseline_flip_camera_android_24.xml create mode 100644 app/src/main/res/layout/activity_simple_scan.xml create mode 100644 app/src/test/java/openfoodfacts/github/scrachx/openfood/features/simplescan/SimpleScanViewModelTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 28ebc26555b4..425bc529fd5f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -305,6 +305,11 @@ android:name=".features.scan.ContinuousScanActivity" android:screenOrientation="portrait" android:theme="@style/OFFTheme.NoActionBar" /> + - product?.let { viewModel.addProductToCompare(it) } + private val scanProductContract = registerForActivityResult(SimpleScanActivityContract()) { barcode -> + barcode?.let { viewModel.barcodeDetected(it) } } override fun onCreate(savedInstanceState: Bundle?) { @@ -67,18 +69,31 @@ class ProductCompareActivity : BaseActivity() { } lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.alreadyExistFlow.collect { - Toast.makeText(this@ProductCompareActivity, getString(R.string.product_already_exists_in_comparison), Toast.LENGTH_SHORT).show() + viewModel.sideEffectFlow + .flowWithLifecycle(lifecycle) + .collect { + when (it) { + is SideEffect.ProductAlreadyAdded -> showProductAlreadyAddedDialog() + is SideEffect.ProductNotFound -> showProductNotFoundDialog() + is SideEffect.ConnectionError -> showConnectionErrorDialog() + } } - } } lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.productsFlow.collect { products -> + viewModel.productsFlow + .flowWithLifecycle(lifecycle) + .distinctUntilChanged() + .collect { products -> createAdapter(products) } - } + } + lifecycleScope.launch { + viewModel.loadingVisibleFlow + .flowWithLifecycle(lifecycle) + .distinctUntilChanged() + .collect { + binding.comparisonProgressView.isVisible = it + } } binding.productComparisonButton.setOnClickListener { @@ -161,9 +176,36 @@ class ProductCompareActivity : BaseActivity() { } } + private fun showProductAlreadyAddedDialog() { + MaterialAlertDialogBuilder(this) + .setMessage(R.string.product_already_exists_in_comparison) + .setPositiveButton(R.string.ok_button) { dialog, _ -> + dialog.dismiss() + } + .show() + } + + private fun showProductNotFoundDialog() { + MaterialAlertDialogBuilder(this) + .setMessage(R.string.txtDialogsContentPowerMode) + .setPositiveButton(R.string.ok_button) { dialog, _ -> + dialog.dismiss() + } + .show() + } + + private fun showConnectionErrorDialog() { + MaterialAlertDialogBuilder(this) + .setTitle(R.string.alert_dialog_warning_title) + .setMessage(R.string.txtConnectionError) + .setPositiveButton(R.string.ok_button) { dialog, _ -> + dialog.dismiss() + } + .show() + } + companion object { const val KEY_PRODUCTS_TO_COMPARE = "products_to_compare" - const val KEY_COMPARE_PRODUCT = "compare_product" @JvmStatic fun start(context: Context, product: Product) { diff --git a/app/src/main/java/openfoodfacts/github/scrachx/openfood/features/compare/ProductCompareViewModel.kt b/app/src/main/java/openfoodfacts/github/scrachx/openfood/features/compare/ProductCompareViewModel.kt index be63c792c5a9..52d6fa998766 100644 --- a/app/src/main/java/openfoodfacts/github/scrachx/openfood/features/compare/ProductCompareViewModel.kt +++ b/app/src/main/java/openfoodfacts/github/scrachx/openfood/features/compare/ProductCompareViewModel.kt @@ -1,5 +1,6 @@ package openfoodfacts.github.scrachx.openfood.features.compare +import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel @@ -14,9 +15,11 @@ import openfoodfacts.github.scrachx.openfood.analytics.AnalyticsEvent import openfoodfacts.github.scrachx.openfood.analytics.MatomoAnalytics import openfoodfacts.github.scrachx.openfood.models.Product import openfoodfacts.github.scrachx.openfood.models.entities.additive.AdditiveName +import openfoodfacts.github.scrachx.openfood.network.OpenFoodAPIClient import openfoodfacts.github.scrachx.openfood.repositories.ProductRepository import openfoodfacts.github.scrachx.openfood.utils.CoroutineDispatchers import openfoodfacts.github.scrachx.openfood.utils.LocaleManager +import openfoodfacts.github.scrachx.openfood.utils.Utils import javax.inject.Inject @HiltViewModel @@ -24,20 +27,35 @@ class ProductCompareViewModel @Inject constructor( private val productRepository: ProductRepository, private val localeManager: LocaleManager, private val matomoAnalytics: MatomoAnalytics, - private val coroutineDispatchers: CoroutineDispatchers + private val coroutineDispatchers: CoroutineDispatchers, + private val openFoodAPIClient: OpenFoodAPIClient, ) : ViewModel() { - private val _alreadyExistFlow = MutableSharedFlow() - val alreadyExistFlow = _alreadyExistFlow.asSharedFlow() + private val _sideEffectFlow = MutableSharedFlow() + val sideEffectFlow = _sideEffectFlow.asSharedFlow() - private val _productsFlow = MutableStateFlow>(emptyList()) + private val _productsFlow = MutableStateFlow(listOf()) val productsFlow = _productsFlow.asStateFlow() + private val _loadingVisibleFlow = MutableStateFlow(false) + val loadingVisibleFlow = _loadingVisibleFlow.asStateFlow() + + fun barcodeDetected(barcode: String) { + viewModelScope.launch { + if (isProductAlreadyAdded(barcode)) { + emitSideEffect(SideEffect.ProductAlreadyAdded) + } else { + fetchProduct(barcode) + } + } + } + fun addProductToCompare(product: Product) { viewModelScope.launch { - if (_productsFlow.value.any { it.product.code == product.code }) { - _alreadyExistFlow.emit(Unit) + if (isProductAlreadyAdded(product.code)) { + emitSideEffect(SideEffect.ProductAlreadyAdded) } else { + _loadingVisibleFlow.emit(true) matomoAnalytics.trackEvent(AnalyticsEvent.AddProductToComparison(product.code)) val result = withContext(coroutineDispatchers.io()) { CompareProduct(product, fetchAdditives(product)) @@ -45,6 +63,7 @@ class ProductCompareViewModel @Inject constructor( withContext(coroutineDispatchers.main()) { updateProductList(result) } + _loadingVisibleFlow.emit(false) } } } @@ -53,6 +72,10 @@ class ProductCompareViewModel @Inject constructor( return localeManager.getLanguage() } + private fun isProductAlreadyAdded(barcode: String): Boolean { + return _productsFlow.value.any { it.product.code == barcode } + } + private fun updateProductList(item: CompareProduct) { val newList = _productsFlow.value + item _productsFlow.value = newList @@ -71,8 +94,38 @@ class ProductCompareViewModel @Inject constructor( .filter { it.isNotNull } } + private suspend fun fetchProduct(barcode: String) { + _loadingVisibleFlow.emit(true) + withContext(coroutineDispatchers.io()) { + try { + val product = openFoodAPIClient.getProductStateFull(barcode, userAgent = Utils.HEADER_USER_AGENT_SCAN).product + if (product == null) { + emitSideEffect(SideEffect.ProductNotFound) + } else { + addProductToCompare(product) + } + } catch (t: Throwable) { + Log.w("ProductCompareViewModel", t.message, t) + emitSideEffect(SideEffect.ConnectionError) + } + } + } + + private suspend fun emitSideEffect(effect: SideEffect) { + withContext(coroutineDispatchers.main()) { + _sideEffectFlow.emit(effect) + _loadingVisibleFlow.emit(false) + } + } + data class CompareProduct( val product: Product, val additiveNames: List ) + + sealed class SideEffect { + object ProductAlreadyAdded : SideEffect() + object ProductNotFound : SideEffect() + object ConnectionError : SideEffect() + } } diff --git a/app/src/main/java/openfoodfacts/github/scrachx/openfood/features/compare/ScanProductActivityContract.kt b/app/src/main/java/openfoodfacts/github/scrachx/openfood/features/compare/ScanProductActivityContract.kt deleted file mode 100644 index b05b75362779..000000000000 --- a/app/src/main/java/openfoodfacts/github/scrachx/openfood/features/compare/ScanProductActivityContract.kt +++ /dev/null @@ -1,28 +0,0 @@ -package openfoodfacts.github.scrachx.openfood.features.compare - -import android.app.Activity -import android.content.Context -import android.content.Intent -import androidx.activity.result.contract.ActivityResultContract -import openfoodfacts.github.scrachx.openfood.features.compare.ProductCompareActivity.Companion.KEY_COMPARE_PRODUCT -import openfoodfacts.github.scrachx.openfood.features.compare.ProductCompareActivity.Companion.KEY_PRODUCTS_TO_COMPARE -import openfoodfacts.github.scrachx.openfood.features.scan.ContinuousScanActivity -import openfoodfacts.github.scrachx.openfood.models.Product - -class ScanProductActivityContract : ActivityResultContract() { - - override fun createIntent(context: Context, input: Unit?): Intent { - return Intent(context, ContinuousScanActivity::class.java) - .putExtra(KEY_COMPARE_PRODUCT, true) - } - - override fun parseResult(resultCode: Int, intent: Intent?): Product? { - val bundle = intent?.extras ?: return null - return if (resultCode == Activity.RESULT_OK && bundle.containsKey(KEY_PRODUCTS_TO_COMPARE)) { - bundle.getSerializable(KEY_PRODUCTS_TO_COMPARE) as? Product - ?: error("Unable to deserialize product from intent.") - } else { - null - } - } -} diff --git a/app/src/main/java/openfoodfacts/github/scrachx/openfood/features/scan/ContinuousScanActivity.kt b/app/src/main/java/openfoodfacts/github/scrachx/openfood/features/scan/ContinuousScanActivity.kt index 2901592528c3..bf6ded8872e6 100644 --- a/app/src/main/java/openfoodfacts/github/scrachx/openfood/features/scan/ContinuousScanActivity.kt +++ b/app/src/main/java/openfoodfacts/github/scrachx/openfood/features/scan/ContinuousScanActivity.kt @@ -15,7 +15,6 @@ */ package openfoodfacts.github.scrachx.openfood.features.scan -import android.content.Context import android.content.Intent import android.content.SharedPreferences import android.hardware.Camera @@ -31,7 +30,6 @@ import android.widget.Toast import androidx.annotation.StringRes import androidx.appcompat.widget.PopupMenu import androidx.core.content.ContextCompat -import androidx.core.content.edit import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat import androidx.core.view.isVisible @@ -39,7 +37,6 @@ import androidx.fragment.app.FragmentTransaction import androidx.fragment.app.commit import androidx.lifecycle.lifecycleScope import com.google.android.material.bottomsheet.BottomSheetBehavior -import com.google.zxing.BarcodeFormat import com.google.zxing.ResultPoint import com.google.zxing.client.android.BeepManager import com.journeyapps.barcodescanner.BarcodeCallback @@ -64,8 +61,6 @@ import openfoodfacts.github.scrachx.openfood.analytics.AnalyticsView import openfoodfacts.github.scrachx.openfood.analytics.MatomoAnalytics import openfoodfacts.github.scrachx.openfood.databinding.ActivityContinuousScanBinding import openfoodfacts.github.scrachx.openfood.features.ImagesManageActivity -import openfoodfacts.github.scrachx.openfood.features.compare.ProductCompareActivity -import openfoodfacts.github.scrachx.openfood.features.compare.ProductCompareActivity.Companion.KEY_PRODUCTS_TO_COMPARE import openfoodfacts.github.scrachx.openfood.features.product.edit.ProductEditActivity import openfoodfacts.github.scrachx.openfood.features.product.view.IProductView import openfoodfacts.github.scrachx.openfood.features.product.view.ProductViewActivity.ShowIngredientsAction @@ -77,6 +72,7 @@ import openfoodfacts.github.scrachx.openfood.features.product.view.summary.Summa import openfoodfacts.github.scrachx.openfood.features.shared.BaseActivity import openfoodfacts.github.scrachx.openfood.listeners.CommonBottomListenerInstaller.installBottomNavigation import openfoodfacts.github.scrachx.openfood.listeners.CommonBottomListenerInstaller.selectNavigationItem +import openfoodfacts.github.scrachx.openfood.models.CameraState import openfoodfacts.github.scrachx.openfood.models.DaoSession import openfoodfacts.github.scrachx.openfood.models.InvalidBarcodeDao import openfoodfacts.github.scrachx.openfood.models.Product @@ -88,6 +84,7 @@ import openfoodfacts.github.scrachx.openfood.models.entities.analysistagconfig.A import openfoodfacts.github.scrachx.openfood.models.eventbus.ProductNeedsRefreshEvent import openfoodfacts.github.scrachx.openfood.network.ApiFields.StateTags import openfoodfacts.github.scrachx.openfood.repositories.ProductRepository +import openfoodfacts.github.scrachx.openfood.repositories.ScannerPreferencesRepository import openfoodfacts.github.scrachx.openfood.utils.* import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe @@ -122,6 +119,9 @@ class ContinuousScanActivity : BaseActivity(), IProductView { @Inject lateinit var localeManager: LocaleManager + @Inject + lateinit var scannerPrefsRepository: ScannerPreferencesRepository + private lateinit var beepManager: BeepManager internal lateinit var quickViewBehavior: BottomSheetBehavior private val barcodeInputListener = BarcodeInputListener() @@ -130,7 +130,6 @@ class ContinuousScanActivity : BaseActivity(), IProductView { private val bottomSheetCallback by lazy { QuickViewCallback(this) } - private val cameraPref by lazy { getSharedPreferences("camera", 0) } private val settings by lazy { getAppPreferences() } private var productDisp: Job? = null @@ -223,7 +222,7 @@ class ContinuousScanActivity : BaseActivity(), IProductView { .apply { setGravity(CENTER, 0, 0) } .show() - Log.w(LOG_TAG, err.message, err) + Log.w("ContinuousScanActivity", err.message, err) } return@launch } @@ -244,12 +243,6 @@ class ContinuousScanActivity : BaseActivity(), IProductView { // Add product to scan history productDisp = lifecycleScope.launch { client.addToHistory(product) } - // If we're here from comparison -> add product, return to comparison activity - if (intent.getBooleanExtra(ProductCompareActivity.KEY_COMPARE_PRODUCT, false)) { - setResult(RESULT_OK, Intent().putExtra(KEY_PRODUCTS_TO_COMPARE, product)) - finish() - } - showAllViews() binding.txtProductCallToAction.let { @@ -509,18 +502,17 @@ class ContinuousScanActivity : BaseActivity(), IProductView { quickViewBehavior.state = BottomSheetBehavior.STATE_HIDDEN quickViewBehavior.addBottomSheetCallback(bottomSheetCallback) - cameraPref.let { - beepActive = it.getBoolean(SETTING_RING, false) - flashActive = it.getBoolean(SETTING_FLASH, false) - autoFocusActive = it.getBoolean(SETTING_FOCUS, true) - cameraState = it.getInt(SETTING_STATE, 0) - } + + beepActive = scannerPrefsRepository.getRingPref() + flashActive = scannerPrefsRepository.getFlashPref() + autoFocusActive = scannerPrefsRepository.getAutoFocusPref() + cameraState = scannerPrefsRepository.getCameraPref().value // Setup barcode scanner if (!useMLScanner) { binding.barcodeScanner.visibility = View.VISIBLE binding.cameraPreviewViewStub.isVisible = false - binding.barcodeScanner.barcodeView.decoderFactory = DefaultDecoderFactory(BARCODE_FORMATS) + binding.barcodeScanner.barcodeView.decoderFactory = DefaultDecoderFactory(ScannerPreferencesRepository.BARCODE_FORMATS) binding.barcodeScanner.setStatusText(null) binding.barcodeScanner.setOnClickListener { quickViewBehavior.state = BottomSheetBehavior.STATE_HIDDEN @@ -568,11 +560,6 @@ class ContinuousScanActivity : BaseActivity(), IProductView { lastBarcode = barcodeValue.also { if (!isFinishing) setShownProduct(it) } } - override fun onBackPressed() { - super.onBackPressed() - setResult(RESULT_CANCELED) - } - override fun onStart() { super.onStart() EventBus.getDefault().register(this) @@ -647,7 +634,6 @@ class ContinuousScanActivity : BaseActivity(), IProductView { this.actionBar?.hide() } - private fun setupPopupMenu() { cameraSettingMenu = PopupMenu(this, binding.buttonMore).also { it.menuInflater.inflate(R.menu.popup_menu, it.menu) @@ -669,13 +655,12 @@ class ContinuousScanActivity : BaseActivity(), IProductView { @Suppress("deprecation") private fun toggleCamera() { - cameraState = if (cameraState == Camera.CameraInfo.CAMERA_FACING_BACK) { Camera.CameraInfo.CAMERA_FACING_FRONT } else { Camera.CameraInfo.CAMERA_FACING_BACK } - cameraPref.edit { putInt(SETTING_STATE, cameraState) } + scannerPrefsRepository.saveCameraPref(CameraState.fromInt(cameraState)) if (!useMLScanner) { val settings = binding.barcodeScanner.barcodeView.cameraSettings @@ -688,33 +673,29 @@ class ContinuousScanActivity : BaseActivity(), IProductView { } else { mlKitView.toggleCamera() } - } private fun toggleFlash() { - cameraPref.edit { - if (flashActive) { - flashActive = false - binding.toggleFlash.setImageResource(R.drawable.ic_flash_off_white_24dp) - putBoolean(SETTING_FLASH, false) + if (flashActive) { + flashActive = false + binding.toggleFlash.setImageResource(R.drawable.ic_flash_off_white_24dp) - if (useMLScanner) { - mlKitView.updateFlashSetting(flashActive) - } else { - binding.barcodeScanner.setTorchOff() - } + if (useMLScanner) { + mlKitView.updateFlashSetting(flashActive) } else { - flashActive = true - binding.toggleFlash.setImageResource(R.drawable.ic_flash_on_white_24dp) - putBoolean(SETTING_FLASH, true) + binding.barcodeScanner.setTorchOff() + } + } else { + flashActive = true + binding.toggleFlash.setImageResource(R.drawable.ic_flash_on_white_24dp) - if (useMLScanner) { - mlKitView.updateFlashSetting(flashActive) - } else { - binding.barcodeScanner.setTorchOn() - } + if (useMLScanner) { + mlKitView.updateFlashSetting(flashActive) + } else { + binding.barcodeScanner.setTorchOn() } } + scannerPrefsRepository.saveFlashPref(flashActive) } private fun showMoreSettings() { @@ -726,15 +707,12 @@ class ContinuousScanActivity : BaseActivity(), IProductView { R.id.toggleBeep -> { beepActive = !beepActive item.isChecked = beepActive - cameraPref.edit { - putBoolean(SETTING_RING, beepActive) - apply() - } + scannerPrefsRepository.saveRingPref(beepActive) } R.id.toggleAutofocus -> { autoFocusActive = !autoFocusActive item.isChecked = autoFocusActive - cameraPref.edit { putBoolean(SETTING_FOCUS, autoFocusActive) } + scannerPrefsRepository.saveAutoFocusPref(autoFocusActive) if (useMLScanner) { mlKitView.updateFocusModeSetting(autoFocusActive) @@ -844,33 +822,5 @@ class ContinuousScanActivity : BaseActivity(), IProductView { companion object { private const val LOGIN_ACTIVITY_REQUEST_CODE = 2 - val BARCODE_FORMATS = listOf( - BarcodeFormat.UPC_A, - BarcodeFormat.UPC_E, - BarcodeFormat.EAN_13, - BarcodeFormat.EAN_8, - BarcodeFormat.RSS_14, - BarcodeFormat.CODE_39, - BarcodeFormat.CODE_93, - BarcodeFormat.CODE_128, - BarcodeFormat.ITF - ) - private const val SETTING_RING = "ring" - private const val SETTING_FLASH = "flash" - private const val SETTING_FOCUS = "focus" - private const val SETTING_STATE = "cameraState" - private val LOG_TAG = this::class.simpleName!! - - - @JvmStatic - fun start(context: Context) { - Intent(context, ContinuousScanActivity::class.java).apply { - putExtra(ProductCompareActivity.KEY_COMPARE_PRODUCT, true) - context.startActivity(this) - } - } - } - - } diff --git a/app/src/main/java/openfoodfacts/github/scrachx/openfood/features/simplescan/SimpleScanActivity.kt b/app/src/main/java/openfoodfacts/github/scrachx/openfood/features/simplescan/SimpleScanActivity.kt new file mode 100644 index 000000000000..23d586d5b6c3 --- /dev/null +++ b/app/src/main/java/openfoodfacts/github/scrachx/openfood/features/simplescan/SimpleScanActivity.kt @@ -0,0 +1,247 @@ +@file:Suppress("DEPRECATION") + +package openfoodfacts.github.scrachx.openfood.features.simplescan + +import android.content.DialogInterface +import android.content.Intent +import android.hardware.Camera +import android.os.Bundle +import android.text.InputType +import android.util.Log +import android.widget.EditText +import android.widget.FrameLayout +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat +import androidx.core.view.isVisible +import androidx.lifecycle.flowWithLifecycle +import androidx.lifecycle.lifecycleScope +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.zxing.ResultPoint +import com.journeyapps.barcodescanner.BarcodeCallback +import com.journeyapps.barcodescanner.BarcodeResult +import com.journeyapps.barcodescanner.DefaultDecoderFactory +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import openfoodfacts.github.scrachx.openfood.R +import openfoodfacts.github.scrachx.openfood.databinding.ActivitySimpleScanBinding +import openfoodfacts.github.scrachx.openfood.features.scan.MlKitCameraView +import openfoodfacts.github.scrachx.openfood.features.simplescan.SimpleScanActivityContract.Companion.KEY_SCANNED_BARCODE +import openfoodfacts.github.scrachx.openfood.models.CameraState +import openfoodfacts.github.scrachx.openfood.repositories.ScannerPreferencesRepository +import java.util.concurrent.atomic.AtomicBoolean + +@AndroidEntryPoint +class SimpleScanActivity : AppCompatActivity() { + + private lateinit var binding: ActivitySimpleScanBinding + private val viewModel: SimpleScanViewModel by viewModels() + + private val mlKitView by lazy { MlKitCameraView(this) } + private val scannerInitialized = AtomicBoolean(false) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivitySimpleScanBinding.inflate(layoutInflater) + setContentView(binding.root) + + hideSystemUI() + + binding.scanFlashBtn.setOnClickListener { + viewModel.changeCameraFlash() + } + binding.scanFlipCameraBtn.setOnClickListener { + viewModel.changeCameraState() + } + binding.scanChangeFocusBtn.setOnClickListener { + viewModel.changeCameraAutoFocus() + } + binding.troubleScanningBtn.setOnClickListener { + viewModel.troubleScanningPressed() + } + + lifecycleScope.launch { + viewModel.scannerOptionsFlow + .flowWithLifecycle(lifecycle) + .collect { options -> + Log.d("SimpleScanActivity", "options: $options") + if (!scannerInitialized.getAndSet(true)) { + setupBarcodeScanner(options) + } + applyScannerOptions(options) + } + } + + lifecycleScope.launch { + viewModel.sideEffectsFlow + .flowWithLifecycle(lifecycle) + .collect { sideEffect -> + Log.d("SimpleScanActivity", "sideEffect: $sideEffect") + when (sideEffect) { + is SimpleScanViewModel.SideEffect.ScanTrouble -> { + stopScanning() + showManualInputDialog() + } + is SimpleScanViewModel.SideEffect.BarcodeDetected -> { + val intent = Intent().putExtra(KEY_SCANNED_BARCODE, sideEffect.barcode) + setResult(RESULT_OK, intent) + finish() + } + } + } + } + } + + override fun onResume() { + super.onResume() + startScanning() + } + + override fun onWindowFocusChanged(hasFocus: Boolean) { + super.onWindowFocusChanged(hasFocus) + // status bar will remain visible if user presses home and then reopens the activity + // hence hiding status bar again + hideSystemUI() + } + + override fun onBackPressed() { + super.onBackPressed() + setResult(RESULT_CANCELED) + } + + private fun hideSystemUI() { + WindowInsetsControllerCompat(window, binding.root).hide(WindowInsetsCompat.Type.statusBars()) + actionBar?.hide() + } + + private fun applyScannerOptions(options: SimpleScanScannerOptions) { + // camera state + if (options.mlScannerEnabled) { + mlKitView.toggleCamera() + } else { + val cameraId = when (options.cameraState) { + CameraState.Back -> Camera.CameraInfo.CAMERA_FACING_BACK + CameraState.Front -> Camera.CameraInfo.CAMERA_FACING_FRONT + } + with(binding.scanBarcodeView) { + pause() + val newSettings = barcodeView.cameraSettings.apply { + requestedCameraId = cameraId + } + barcodeView.cameraSettings = newSettings + resume() + } + } + + // flash + val flashIconRes = if (options.flashEnabled) { + R.drawable.ic_flash_off_white_24dp + } else { + R.drawable.ic_flash_on_white_24dp + } + binding.scanFlashBtn.setImageResource(flashIconRes) + + if (options.mlScannerEnabled) { + mlKitView.updateFlashSetting(options.flashEnabled) + } else { + if (options.flashEnabled) { + binding.scanBarcodeView.setTorchOn() + } else { + binding.scanBarcodeView.setTorchOff() + } + } + + // autofocus + val focusIconRes = if (options.autoFocusEnabled) { + R.drawable.ic_baseline_camera_focus_on_24 + } else { + R.drawable.ic_baseline_camera_focus_off_24 + } + binding.scanChangeFocusBtn.setImageResource(focusIconRes) + if (options.mlScannerEnabled) { + mlKitView.updateFocusModeSetting(options.autoFocusEnabled) + } else { + with(binding.scanBarcodeView) { + pause() + val newSettings = barcodeView.cameraSettings.apply { + isAutoFocusEnabled = options.autoFocusEnabled + } + barcodeView.cameraSettings = newSettings + resume() + } + } + } + + private fun setupBarcodeScanner(options: SimpleScanScannerOptions) { + binding.scanBarcodeView.isVisible = !options.mlScannerEnabled + + if (options.mlScannerEnabled) { + mlKitView.attach(binding.scanMlView, options.cameraState.value, options.flashEnabled, options.autoFocusEnabled) + mlKitView.barcodeScannedCallback = { + viewModel.barcodeDetected(it) + } + } else { + with(binding.scanBarcodeView) { + barcodeView.decoderFactory = DefaultDecoderFactory(ScannerPreferencesRepository.BARCODE_FORMATS) + setStatusText(null) + barcodeView.cameraSettings.requestedCameraId = options.cameraState.value + barcodeView.cameraSettings.isAutoFocusEnabled = options.autoFocusEnabled + + decodeContinuous(object : BarcodeCallback { + override fun barcodeResult(result: BarcodeResult?) { + result?.text?.let { + viewModel.barcodeDetected(it) + } + } + + override fun possibleResultPoints(resultPoints: MutableList?) = Unit + }) + } + } + } + + private fun stopScanning() { + if (viewModel.scannerOptionsFlow.value.mlScannerEnabled) { + mlKitView.stopCameraPreview() + } else { + binding.scanBarcodeView.pause() + } + } + + private fun startScanning() { + if (viewModel.scannerOptionsFlow.value.mlScannerEnabled) { + mlKitView.onResume() + mlKitView.startCameraPreview() + } else { + binding.scanBarcodeView.resume() + } + } + + private fun showManualInputDialog() { + val inputEditText = EditText(this).apply { + inputType = InputType.TYPE_CLASS_NUMBER + } + val view = FrameLayout(this).apply { + val margin = resources.getDimensionPixelSize(R.dimen.activity_horizontal_margin) + setPadding(margin, margin / 2, margin, margin / 2) + addView(inputEditText) + } + val dialog = MaterialAlertDialogBuilder(this@SimpleScanActivity) + .setTitle(R.string.trouble_scanning) + .setMessage(R.string.enter_barcode) + .setView(view) + .setPositiveButton(R.string.ok_button, null) + .setNegativeButton(R.string.cancel_button) { _, _ -> + startScanning() + } + .show() + dialog.getButton(DialogInterface.BUTTON_POSITIVE).setOnClickListener { + inputEditText.text?.toString()?.let { + viewModel.barcodeDetected(it) + } + + } + } +} diff --git a/app/src/main/java/openfoodfacts/github/scrachx/openfood/features/simplescan/SimpleScanActivityContract.kt b/app/src/main/java/openfoodfacts/github/scrachx/openfood/features/simplescan/SimpleScanActivityContract.kt new file mode 100644 index 000000000000..5f2bc0bc93cf --- /dev/null +++ b/app/src/main/java/openfoodfacts/github/scrachx/openfood/features/simplescan/SimpleScanActivityContract.kt @@ -0,0 +1,26 @@ +package openfoodfacts.github.scrachx.openfood.features.simplescan + +import android.app.Activity +import android.content.Context +import android.content.Intent +import androidx.activity.result.contract.ActivityResultContract + +class SimpleScanActivityContract : ActivityResultContract() { + + companion object { + const val KEY_SCANNED_BARCODE = "scanned_barcode" + } + + override fun createIntent(context: Context, input: Unit?): Intent { + return Intent(context, SimpleScanActivity::class.java) + } + + override fun parseResult(resultCode: Int, intent: Intent?): String? { + val bundle = intent?.extras ?: return null + return if (resultCode == Activity.RESULT_OK && bundle.containsKey(KEY_SCANNED_BARCODE)) { + bundle.getString(KEY_SCANNED_BARCODE, null) + } else { + null + } + } +} diff --git a/app/src/main/java/openfoodfacts/github/scrachx/openfood/features/simplescan/SimpleScanScannerOptions.kt b/app/src/main/java/openfoodfacts/github/scrachx/openfood/features/simplescan/SimpleScanScannerOptions.kt new file mode 100644 index 000000000000..db4cb22f3fc0 --- /dev/null +++ b/app/src/main/java/openfoodfacts/github/scrachx/openfood/features/simplescan/SimpleScanScannerOptions.kt @@ -0,0 +1,10 @@ +package openfoodfacts.github.scrachx.openfood.features.simplescan + +import openfoodfacts.github.scrachx.openfood.models.CameraState + +data class SimpleScanScannerOptions( + val mlScannerEnabled: Boolean, + val cameraState: CameraState, + val autoFocusEnabled: Boolean, + val flashEnabled: Boolean +) \ No newline at end of file diff --git a/app/src/main/java/openfoodfacts/github/scrachx/openfood/features/simplescan/SimpleScanViewModel.kt b/app/src/main/java/openfoodfacts/github/scrachx/openfood/features/simplescan/SimpleScanViewModel.kt new file mode 100644 index 000000000000..3b00ba8aca01 --- /dev/null +++ b/app/src/main/java/openfoodfacts/github/scrachx/openfood/features/simplescan/SimpleScanViewModel.kt @@ -0,0 +1,76 @@ +package openfoodfacts.github.scrachx.openfood.features.simplescan + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import openfoodfacts.github.scrachx.openfood.models.CameraState +import openfoodfacts.github.scrachx.openfood.repositories.ScannerPreferencesRepository +import javax.inject.Inject + +@HiltViewModel +class SimpleScanViewModel @Inject constructor( + private val scannerPrefsRepository: ScannerPreferencesRepository, +) : ViewModel() { + + private val _sideEffectsFlow = MutableSharedFlow() + val sideEffectsFlow = _sideEffectsFlow.asSharedFlow() + + private val _scannerOptionsFlow = MutableStateFlow( + SimpleScanScannerOptions( + mlScannerEnabled = scannerPrefsRepository.isMlScannerEnabled(), + cameraState = scannerPrefsRepository.getCameraPref(), + autoFocusEnabled = scannerPrefsRepository.getAutoFocusPref(), + flashEnabled = scannerPrefsRepository.getFlashPref() + ) + ) + val scannerOptionsFlow = _scannerOptionsFlow.asStateFlow() + + fun changeCameraAutoFocus() { + val newValue = !_scannerOptionsFlow.value.autoFocusEnabled + scannerPrefsRepository.saveAutoFocusPref(newValue) + _scannerOptionsFlow.value = _scannerOptionsFlow.value.copy( + autoFocusEnabled = newValue + ) + } + + fun changeCameraFlash() { + val newValue = !_scannerOptionsFlow.value.flashEnabled + scannerPrefsRepository.saveFlashPref(newValue) + _scannerOptionsFlow.value = _scannerOptionsFlow.value.copy( + flashEnabled = newValue + ) + } + + fun changeCameraState() { + val newValue = when (_scannerOptionsFlow.value.cameraState) { + CameraState.Front -> CameraState.Back + CameraState.Back -> CameraState.Front + } + scannerPrefsRepository.saveCameraPref(newValue) + _scannerOptionsFlow.value = _scannerOptionsFlow.value.copy( + cameraState = newValue + ) + } + + fun barcodeDetected(barcode: String) { + viewModelScope.launch { + _sideEffectsFlow.emit(SideEffect.BarcodeDetected(barcode)) + } + } + + fun troubleScanningPressed() { + viewModelScope.launch { + _sideEffectsFlow.emit(SideEffect.ScanTrouble) + } + } + + sealed class SideEffect { + data class BarcodeDetected(val barcode: String) : SideEffect() + object ScanTrouble : SideEffect() + } +} diff --git a/app/src/main/java/openfoodfacts/github/scrachx/openfood/hilt/AppModule.kt b/app/src/main/java/openfoodfacts/github/scrachx/openfood/hilt/AppModule.kt index 49ec8d82c023..0527591ed88e 100644 --- a/app/src/main/java/openfoodfacts/github/scrachx/openfood/hilt/AppModule.kt +++ b/app/src/main/java/openfoodfacts/github/scrachx/openfood/hilt/AppModule.kt @@ -61,5 +61,5 @@ class AppModule { @Provides @Singleton fun provideSharedPreferences(@ApplicationContext context: Context): SharedPreferences = - PreferenceManager.getDefaultSharedPreferences(context) + PreferenceManager.getDefaultSharedPreferences(context) } diff --git a/app/src/main/java/openfoodfacts/github/scrachx/openfood/models/CameraState.kt b/app/src/main/java/openfoodfacts/github/scrachx/openfood/models/CameraState.kt new file mode 100644 index 000000000000..97f7931bdc1b --- /dev/null +++ b/app/src/main/java/openfoodfacts/github/scrachx/openfood/models/CameraState.kt @@ -0,0 +1,13 @@ +package openfoodfacts.github.scrachx.openfood.models + +enum class CameraState(val value: Int) { + Back(0), Front(1); + + companion object { + fun fromInt(value: Int) = when (value) { + 0 -> Back + 1 -> Front + else -> throw IllegalStateException("Unsupported camera state $value.") + } + } +} \ No newline at end of file diff --git a/app/src/main/java/openfoodfacts/github/scrachx/openfood/repositories/ScannerPreferencesRepository.kt b/app/src/main/java/openfoodfacts/github/scrachx/openfood/repositories/ScannerPreferencesRepository.kt new file mode 100644 index 000000000000..63567d27a0da --- /dev/null +++ b/app/src/main/java/openfoodfacts/github/scrachx/openfood/repositories/ScannerPreferencesRepository.kt @@ -0,0 +1,76 @@ +package openfoodfacts.github.scrachx.openfood.repositories + +import android.content.Context +import androidx.core.content.edit +import com.google.zxing.BarcodeFormat +import dagger.hilt.android.qualifiers.ApplicationContext +import openfoodfacts.github.scrachx.openfood.BuildConfig +import openfoodfacts.github.scrachx.openfood.R +import openfoodfacts.github.scrachx.openfood.models.CameraState +import openfoodfacts.github.scrachx.openfood.utils.getAppPreferences +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ScannerPreferencesRepository @Inject constructor( + @ApplicationContext private val context: Context, +) { + + private val cameraPrefs by lazy { context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) } + private val appPrefs by lazy { context.getAppPreferences() } + + fun saveAutoFocusPref(value: Boolean) { + cameraPrefs.edit { + putBoolean(SETTING_FOCUS, value) + } + } + + fun getAutoFocusPref() = cameraPrefs.getBoolean(SETTING_FOCUS, true) + + fun saveFlashPref(value: Boolean) { + cameraPrefs.edit { + putBoolean(SETTING_FLASH, value) + } + } + + fun getFlashPref() = cameraPrefs.getBoolean(SETTING_FLASH, false) + + fun saveCameraPref(camera: CameraState) { + cameraPrefs.edit { + putInt(SETTING_STATE, camera.value) + } + } + + fun getCameraPref() = CameraState.fromInt(cameraPrefs.getInt(SETTING_STATE, CameraState.Back.value)) + + fun saveRingPref(value: Boolean) { + cameraPrefs.edit { + putBoolean(SETTING_RING, value) + } + } + + fun getRingPref() = cameraPrefs.getBoolean(SETTING_RING, false) + + fun isMlScannerEnabled(): Boolean { + return BuildConfig.USE_MLKIT && appPrefs.getBoolean(context.getString(R.string.pref_scanner_mlkit_key), false) + } + + companion object { + private const val PREFS_NAME = "camera" + private const val SETTING_RING = "ring" + private const val SETTING_FLASH = "flash" + private const val SETTING_FOCUS = "focus" + private const val SETTING_STATE = "cameraState" + val BARCODE_FORMATS = listOf( + BarcodeFormat.UPC_A, + BarcodeFormat.UPC_E, + BarcodeFormat.EAN_13, + BarcodeFormat.EAN_8, + BarcodeFormat.RSS_14, + BarcodeFormat.CODE_39, + BarcodeFormat.CODE_93, + BarcodeFormat.CODE_128, + BarcodeFormat.ITF + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/openfoodfacts/github/scrachx/openfood/utils/PreferencesUtils.kt b/app/src/main/java/openfoodfacts/github/scrachx/openfood/utils/PreferencesUtils.kt index c55e826445e2..7f425b66196f 100644 --- a/app/src/main/java/openfoodfacts/github/scrachx/openfood/utils/PreferencesUtils.kt +++ b/app/src/main/java/openfoodfacts/github/scrachx/openfood/utils/PreferencesUtils.kt @@ -12,23 +12,14 @@ fun PreferencesFragment.requirePreference(key: String): T = fun PreferenceScreen.requirePreference(key: String): T = findPreference(key) ?: error("$key preference does not exist.") - inline fun PreferencesFragment.requirePreference(key: String, action: T.() -> Unit) { requirePreference(key).run(action) } -inline fun PreferenceScreen.requirePreference(key: String, action: T.() -> Unit) { - requirePreference(key).run(action) -} - - fun Context.getLoginPreferences(mode: Int = Context.MODE_PRIVATE): SharedPreferences = getSharedPreferences(PreferencesFragment.LOGIN_SHARED_PREF, mode) fun Context.getAppPreferences(mode: Int = Context.MODE_PRIVATE): SharedPreferences = getSharedPreferences(PreferencesFragment.APP_SHARED_PREF, mode) -fun Context.getCameraPreferences(mode: Int = Context.MODE_PRIVATE): SharedPreferences = - getSharedPreferences("camera", mode) - fun Context.isUserSet() = !getLoginPreferences().getString("user", null).isNullOrBlank() diff --git a/app/src/main/res/drawable/ic_baseline_camera_focus_off_24.xml b/app/src/main/res/drawable/ic_baseline_camera_focus_off_24.xml new file mode 100644 index 000000000000..52a3fa00f47f --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_camera_focus_off_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_camera_focus_on_24.xml b/app/src/main/res/drawable/ic_baseline_camera_focus_on_24.xml new file mode 100644 index 000000000000..402ea910a4db --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_camera_focus_on_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_baseline_flip_camera_android_24.xml b/app/src/main/res/drawable/ic_baseline_flip_camera_android_24.xml new file mode 100644 index 000000000000..fcb504f7acb1 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_flip_camera_android_24.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/app/src/main/res/layout/activity_product_comparison.xml b/app/src/main/res/layout/activity_product_comparison.xml index 9366e6359305..e179dd121686 100644 --- a/app/src/main/res/layout/activity_product_comparison.xml +++ b/app/src/main/res/layout/activity_product_comparison.xml @@ -57,6 +57,26 @@ + + + + + + + + + + + + + + + + + + +