For #15079: handle QR permissions when changed in Android settings (#15097)

* Define intent data for activity

* Search dialog shows permissions for allow and deny camera

* Check camera permissions for fxa pairing

* Check camera permissions for old search

* Tests for pairing sync interactor and controller.

* Cleanup

* Use bool pref for setting. Use interfaces and default implementations for the sync interactor and controller.

* Lint
This commit is contained in:
Elise Richards 2020-09-17 16:41:28 -05:00 committed by GitHub
parent 8a81c1ee1d
commit 9afe9679d8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 304 additions and 177 deletions

View File

@ -26,7 +26,6 @@ import androidx.core.widget.NestedScrollView
import androidx.fragment.app.Fragment
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import androidx.preference.PreferenceManager
import kotlinx.android.synthetic.main.fragment_search.*
import kotlinx.android.synthetic.main.fragment_search.view.*
import kotlinx.android.synthetic.main.search_suggestions_hint.view.*
@ -51,7 +50,6 @@ import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.components.searchengine.CustomSearchEngineStore
import org.mozilla.fenix.components.searchengine.FenixSearchEngineProvider
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.getPreferenceKey
import org.mozilla.fenix.ext.hideToolbar
import org.mozilla.fenix.ext.requireComponents
import org.mozilla.fenix.ext.settings
@ -67,7 +65,6 @@ class SearchFragment : Fragment(), UserInteractionHandler {
private lateinit var toolbarView: ToolbarView
private lateinit var awesomeBarView: AwesomeBarView
private val qrFeature = ViewBoundFeatureWrapper<QrFeature>()
private var permissionDidUpdate = false
private lateinit var searchStore: SearchFragmentStore
private lateinit var searchInteractor: SearchInteractor
@ -202,62 +199,26 @@ class SearchFragment : Fragment(), UserInteractionHandler {
search_scan_button.visibility = if (context?.hasCamera() == true) View.VISIBLE else View.GONE
qrFeature.set(
QrFeature(
requireContext(),
fragmentManager = parentFragmentManager,
onNeedToRequestPermissions = { permissions ->
requestPermissions(permissions, REQUEST_CODE_CAMERA_PERMISSIONS)
},
onScanResult = { result ->
search_scan_button.isChecked = false
activity?.let {
AlertDialog.Builder(it).apply {
val spannable = resources.getSpanned(
R.string.qr_scanner_confirmation_dialog_message,
getString(R.string.app_name) to StyleSpan(BOLD),
result to StyleSpan(ITALIC)
)
setMessage(spannable)
setNegativeButton(R.string.qr_scanner_dialog_negative) { dialog: DialogInterface, _ ->
requireComponents.analytics.metrics.track(Event.QRScannerNavigationDenied)
dialog.cancel()
resetFocus()
}
setPositiveButton(R.string.qr_scanner_dialog_positive) { dialog: DialogInterface, _ ->
requireComponents.analytics.metrics.track(Event.QRScannerNavigationAllowed)
(activity as HomeActivity)
.openToBrowserAndLoad(
searchTermOrURL = result,
newTab = searchStore.state.tabId == null,
from = BrowserDirection.FromSearch
)
dialog.dismiss()
resetFocus()
}
create()
}.show()
requireComponents.analytics.metrics.track(Event.QRScannerPromptDisplayed)
}
}),
createQrFeature(),
owner = this,
view = view
)
view.search_scan_button.setOnClickListener {
toolbarView.view.clearFocus()
val cameraPermissionsDenied = PreferenceManager.getDefaultSharedPreferences(context)
.getBoolean(
getPreferenceKey(R.string.pref_key_camera_permissions),
false
)
if (cameraPermissionsDenied) {
searchInteractor.onCameraPermissionsNeeded()
} else {
if (requireContext().settings().shouldShowCameraPermissionPrompt) {
requireComponents.analytics.metrics.track(Event.QRScannerOpened)
qrFeature.get()?.scan(R.id.container)
} else {
if (requireContext().isPermissionGranted(Manifest.permission.CAMERA)) {
requireComponents.analytics.metrics.track(Event.QRScannerOpened)
qrFeature.get()?.scan(R.id.container)
} else {
searchInteractor.onCameraPermissionsNeeded()
}
}
view.hideKeyboard()
search_scan_button.isChecked = false
requireContext().settings().setCameraPermissionNeededState = false
}
view.search_engines_shortcut_button.setOnClickListener {
@ -322,6 +283,47 @@ class SearchFragment : Fragment(), UserInteractionHandler {
startPostponedEnterTransition()
}
private fun createQrFeature(): QrFeature {
return QrFeature(
requireContext(),
fragmentManager = parentFragmentManager,
onNeedToRequestPermissions = { permissions ->
requestPermissions(permissions, REQUEST_CODE_CAMERA_PERMISSIONS)
},
onScanResult = { result ->
search_scan_button.isChecked = false
activity?.let {
AlertDialog.Builder(it).apply {
val spannable = resources.getSpanned(
R.string.qr_scanner_confirmation_dialog_message,
getString(R.string.app_name) to StyleSpan(BOLD),
result to StyleSpan(ITALIC)
)
setMessage(spannable)
setNegativeButton(R.string.qr_scanner_dialog_negative) { dialog: DialogInterface, _ ->
requireComponents.analytics.metrics.track(Event.QRScannerNavigationDenied)
dialog.cancel()
resetFocus()
}
setPositiveButton(R.string.qr_scanner_dialog_positive) { dialog: DialogInterface, _ ->
requireComponents.analytics.metrics.track(Event.QRScannerNavigationAllowed)
(activity as HomeActivity)
.openToBrowserAndLoad(
searchTermOrURL = result,
newTab = searchStore.state.tabId == null,
from = BrowserDirection.FromSearch
)
dialog.dismiss()
resetFocus()
}
create()
}.show()
requireComponents.analytics.metrics.track(Event.QRScannerPromptDisplayed)
}
}
)
}
private fun updateToolbarContentDescription(searchState: SearchFragmentState) {
val urlView = toolbarView.view
.findViewById<InlineAutocompleteEditText>(R.id.mozac_browser_toolbar_edit_url_view)
@ -352,16 +354,11 @@ class SearchFragment : Fragment(), UserInteractionHandler {
searchStore.dispatch(SearchFragmentAction.UpdateShortcutsAvailability(areShortcutsAvailable))
}
if (!permissionDidUpdate) {
toolbarView.view.edit.focus()
}
updateClipboardSuggestion(
searchStore.state,
requireComponents.clipboardHandler.url
)
permissionDidUpdate = false
hideToolbar()
}
@ -423,22 +420,8 @@ class SearchFragment : Fragment(), UserInteractionHandler {
when (requestCode) {
REQUEST_CODE_CAMERA_PERMISSIONS -> qrFeature.withFeature {
it.onPermissionsResult(permissions, grantResults)
context?.let { context: Context ->
if (context.isPermissionGranted(Manifest.permission.CAMERA)) {
permissionDidUpdate = true
PreferenceManager.getDefaultSharedPreferences(context)
.edit().putBoolean(
getPreferenceKey(R.string.pref_key_camera_permissions), false
).apply()
} else {
PreferenceManager.getDefaultSharedPreferences(context)
.edit().putBoolean(
getPreferenceKey(R.string.pref_key_camera_permissions), true
).apply()
resetFocus()
}
}
resetFocus()
requireContext().settings().setCameraPermissionNeededState = false
}
else -> super.onRequestPermissionsResult(requestCode, permissions, grantResults)
}

View File

@ -29,7 +29,6 @@ import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import androidx.preference.PreferenceManager
import kotlinx.android.synthetic.main.fragment_search_dialog.*
import kotlinx.android.synthetic.main.fragment_search_dialog.view.*
import kotlinx.android.synthetic.main.search_suggestions_hint.view.*
@ -54,7 +53,6 @@ import org.mozilla.fenix.components.searchengine.CustomSearchEngineStore
import org.mozilla.fenix.components.searchengine.FenixSearchEngineProvider
import org.mozilla.fenix.components.toolbar.ToolbarPosition
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.getPreferenceKey
import org.mozilla.fenix.ext.isKeyboardVisible
import org.mozilla.fenix.ext.requireComponents
import org.mozilla.fenix.ext.settings
@ -205,28 +203,34 @@ class SearchDialogFragment : AppCompatDialogFragment(), UserInteractionHandler {
interactor.onSearchShortcutsButtonClicked()
}
qrFeature.set(
createQrFeature(),
owner = this,
view = view
)
qr_scan_button.visibility = if (context?.hasCamera() == true) View.VISIBLE else View.GONE
qr_scan_button.setOnClickListener {
if (!requireContext().hasCamera()) { return@setOnClickListener }
view.hideKeyboard()
toolbarView.view.clearFocus()
val cameraPermissionsDenied =
PreferenceManager.getDefaultSharedPreferences(context).getBoolean(
getPreferenceKey(R.string.pref_key_camera_permissions),
false
)
if (cameraPermissionsDenied) {
interactor.onCameraPermissionsNeeded()
resetFocus()
view.hideKeyboard()
toolbarView.view.requestFocus()
} else {
if (requireContext().settings().shouldShowCameraPermissionPrompt) {
requireComponents.analytics.metrics.track(Event.QRScannerOpened)
qrFeature.get()?.scan(R.id.search_wrapper)
} else {
if (requireContext().isPermissionGranted(Manifest.permission.CAMERA)) {
requireComponents.analytics.metrics.track(Event.QRScannerOpened)
qrFeature.get()?.scan(R.id.search_wrapper)
} else {
interactor.onCameraPermissionsNeeded()
resetFocus()
view.hideKeyboard()
toolbarView.view.requestFocus()
}
}
requireContext().settings().setCameraPermissionNeededState = false
}
fill_link_from_clipboard.setOnClickListener {
@ -238,12 +242,6 @@ class SearchDialogFragment : AppCompatDialogFragment(), UserInteractionHandler {
)
}
qrFeature.set(
createQrFeature(),
owner = this,
view = view
)
val stubListener = ViewStub.OnInflateListener { _, inflated ->
inflated.learn_more.setOnClickListener {
(activity as HomeActivity)
@ -379,7 +377,8 @@ class SearchDialogFragment : AppCompatDialogFragment(), UserInteractionHandler {
}.show()
requireComponents.analytics.metrics.track(Event.QRScannerPromptDisplayed)
}
})
}
)
}
override fun onRequestPermissionsResult(
@ -389,21 +388,9 @@ class SearchDialogFragment : AppCompatDialogFragment(), UserInteractionHandler {
) {
when (requestCode) {
REQUEST_CODE_CAMERA_PERMISSIONS -> qrFeature.withFeature {
context?.let { context: Context ->
it.onPermissionsResult(permissions, grantResults)
if (!context.isPermissionGranted(Manifest.permission.CAMERA)) {
PreferenceManager.getDefaultSharedPreferences(context)
.edit().putBoolean(
getPreferenceKey(R.string.pref_key_camera_permissions), true
).apply()
resetFocus()
} else {
PreferenceManager.getDefaultSharedPreferences(context)
.edit().putBoolean(
getPreferenceKey(R.string.pref_key_camera_permissions), false
).apply()
}
}
it.onPermissionsResult(permissions, grantResults)
resetFocus()
requireContext().settings().setCameraPermissionNeededState = false
}
else -> super.onRequestPermissionsResult(requestCode, permissions, grantResults)
}

View File

@ -4,28 +4,21 @@
package org.mozilla.fenix.settings
import android.content.DialogInterface
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.os.VibrationEffect
import android.os.Vibrator
import android.provider.Settings
import android.text.SpannableString
import android.view.View
import androidx.appcompat.app.AlertDialog
import androidx.core.content.ContextCompat
import androidx.core.content.getSystemService
import androidx.fragment.app.Fragment
import androidx.navigation.fragment.NavHostFragment.findNavController
import androidx.navigation.fragment.findNavController
import androidx.preference.PreferenceManager
import mozilla.components.feature.qr.QrFeature
import mozilla.components.support.base.feature.UserInteractionHandler
import mozilla.components.support.base.feature.ViewBoundFeatureWrapper
import org.mozilla.fenix.R
import org.mozilla.fenix.ext.getPreferenceKey
import org.mozilla.fenix.ext.requireComponents
import org.mozilla.fenix.ext.showToolbar
@ -65,23 +58,14 @@ class PairFragment : Fragment(R.layout.fragment_pair), UserInteractionHandler {
false
)
},
scanMessage = R.string.pair_instructions_2),
scanMessage = R.string.pair_instructions_2
),
owner = this,
view = view
)
val cameraPermissionsDenied = PreferenceManager.getDefaultSharedPreferences(context)
.getBoolean(
getPreferenceKey(R.string.pref_key_camera_permissions),
false
)
qrFeature.withFeature {
if (cameraPermissionsDenied) {
showPermissionsNeededDialog()
} else {
it.scan(R.id.pair_layout)
}
it.scan(R.id.pair_layout)
}
}
@ -116,57 +100,10 @@ class PairFragment : Fragment(R.layout.fragment_pair), UserInteractionHandler {
qrFeature.withFeature {
it.onPermissionsResult(permissions, grantResults)
}
PreferenceManager.getDefaultSharedPreferences(context)
.edit().putBoolean(
getPreferenceKey(R.string.pref_key_camera_permissions), false
).apply()
} else {
PreferenceManager.getDefaultSharedPreferences(context)
.edit().putBoolean(
getPreferenceKey(R.string.pref_key_camera_permissions), true
).apply()
findNavController().popBackStack(R.id.turnOnSyncFragment, false)
}
}
}
}
/**
* Shows an [AlertDialog] when camera permissions are needed.
*
* In versions above M, [AlertDialog.BUTTON_POSITIVE] takes the user to the app settings. This
* intent only exists in M and above. Below M, [AlertDialog.BUTTON_POSITIVE] routes to a SUMO
* help page to find the app settings.
*
* [AlertDialog.BUTTON_NEGATIVE] dismisses the dialog.
*/
private fun showPermissionsNeededDialog() {
AlertDialog.Builder(requireContext()).apply {
val spannableText = SpannableString(
resources.getString(R.string.camera_permissions_needed_message)
)
setMessage(spannableText)
setNegativeButton(R.string.camera_permissions_needed_negative_button_text) {
dialog: DialogInterface, _ ->
dialog.cancel()
}
setPositiveButton(R.string.camera_permissions_needed_positive_button_text) {
dialog: DialogInterface, _ ->
val intent: Intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
} else {
SupportUtils.createCustomTabIntent(
requireContext(),
SupportUtils.getSumoURLForTopic(
requireContext(),
SupportUtils.SumoTopic.QR_CAMERA_ACCESS
)
)
}
dialog.cancel()
startActivity(intent)
}
create()
}.show()
}
}

View File

@ -0,0 +1,74 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.settings.account
import android.content.DialogInterface
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.provider.Settings
import android.text.SpannableString
import androidx.annotation.VisibleForTesting
import androidx.appcompat.app.AlertDialog
import org.mozilla.fenix.HomeActivity
import org.mozilla.fenix.R
import org.mozilla.fenix.settings.SupportUtils
interface SyncController {
fun handleCameraPermissionsNeeded()
}
/**
* Controller for handling [DefaultSyncInteractor] requests.
*/
class DefaultSyncController(
private val activity: HomeActivity
) : SyncController {
/**
* Creates and shows an [AlertDialog] when camera permissions are needed.
*
* In versions above M, [AlertDialog.BUTTON_POSITIVE] takes the user to the app settings. This
* intent only exists in M and above. Below M, [AlertDialog.BUTTON_POSITIVE] routes to a SUMO
* help page to find the app settings.
*
* [AlertDialog.BUTTON_NEGATIVE] dismisses the dialog.
*/
override fun handleCameraPermissionsNeeded() {
val dialog = buildDialog()
dialog.show()
}
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
fun buildDialog(): AlertDialog.Builder {
return AlertDialog.Builder(activity).apply {
val spannableText = SpannableString(
activity.resources.getString(R.string.camera_permissions_needed_message)
)
setMessage(spannableText)
setNegativeButton(R.string.camera_permissions_needed_negative_button_text) { dialog: DialogInterface, _ ->
dialog.cancel()
}
setPositiveButton(R.string.camera_permissions_needed_positive_button_text) { dialog: DialogInterface, _ ->
val intent: Intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
} else {
SupportUtils.createCustomTabIntent(
activity,
SupportUtils.getSumoURLForTopic(
activity,
SupportUtils.SumoTopic.QR_CAMERA_ACCESS
)
)
}
val uri = Uri.fromParts("package", activity.packageName, null)
intent.data = uri
dialog.cancel()
activity.startActivity(intent)
}
create()
}
}
}

View File

@ -0,0 +1,20 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.settings.account
interface SyncInteractor {
fun onCameraPermissionsNeeded()
}
/**
* Interactor for [TurnOnSyncFragment].
*
* @param syncController Handles the interactions
*/
class DefaultSyncInteractor(private val syncController: DefaultSyncController) : SyncInteractor {
override fun onCameraPermissionsNeeded() {
syncController.handleCameraPermissionsNeeded()
}
}

View File

@ -4,6 +4,7 @@
package org.mozilla.fenix.settings.account
import android.Manifest
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
@ -18,15 +19,21 @@ import mozilla.components.concept.sync.AccountObserver
import mozilla.components.concept.sync.AuthType
import mozilla.components.concept.sync.OAuthAccount
import mozilla.components.support.ktx.android.content.hasCamera
import mozilla.components.support.ktx.android.content.isPermissionGranted
import mozilla.components.support.ktx.android.view.hideKeyboard
import org.mozilla.fenix.HomeActivity
import org.mozilla.fenix.R
import org.mozilla.fenix.components.FenixSnackbar
import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.ext.requireComponents
import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.ext.showToolbar
class TurnOnSyncFragment : Fragment(), AccountObserver {
private val args by navArgs<TurnOnSyncFragmentArgs>()
private lateinit var interactor: DefaultSyncInteractor
private var shouldLoginJustWithEmail = false
private var pairWithEmailStarted = false
@ -35,6 +42,23 @@ class TurnOnSyncFragment : Fragment(), AccountObserver {
}
private val paringClickListener = View.OnClickListener {
if (requireContext().settings().shouldShowCameraPermissionPrompt) {
requireComponents.analytics.metrics.track(Event.QRScannerOpened)
navigateToPairFragment()
} else {
if (requireContext().isPermissionGranted(Manifest.permission.CAMERA)) {
requireComponents.analytics.metrics.track(Event.QRScannerOpened)
navigateToPairFragment()
} else {
interactor.onCameraPermissionsNeeded()
view?.hideKeyboard()
}
}
view?.hideKeyboard()
requireContext().settings().setCameraPermissionNeededState = false
}
private fun navigateToPairFragment() {
val directions = TurnOnSyncFragmentDirections.actionTurnOnSyncFragmentToPairFragment()
requireView().findNavController().navigate(directions)
requireComponents.analytics.metrics.track(Event.SyncAuthScanPairing)
@ -89,6 +113,11 @@ class TurnOnSyncFragment : Fragment(), AccountObserver {
getString(R.string.sign_in_instructions),
HtmlCompat.FROM_HTML_MODE_LEGACY
)
interactor = DefaultSyncInteractor(
DefaultSyncController(activity = activity as HomeActivity)
)
return view
}

View File

@ -60,7 +60,6 @@ class Settings(private val appContext: Context) : PreferencesHolder {
private const val ALLOWED_INT = 2
private const val CFR_COUNT_CONDITION_FOCUS_INSTALLED = 1
private const val CFR_COUNT_CONDITION_FOCUS_NOT_INSTALLED = 3
private const val MIN_DAYS_SINCE_FEEDBACK_PROMPT = 120
const val ONE_DAY_MS = 60 * 60 * 24 * 1000L
const val ONE_WEEK_MS = 60 * 60 * 24 * 7 * 1000L
@ -766,6 +765,24 @@ class Settings(private val appContext: Context) : PreferencesHolder {
default = true
)
/**
* Used in [SearchDialogFragment.kt], [SearchFragment.kt] (deprecated), and [PairFragment.kt]
* to see if we need to check for camera permissions before using the QR code scanner.
*/
var shouldShowCameraPermissionPrompt by booleanPreference(
appContext.getPreferenceKey(R.string.pref_key_camera_permissions_needed),
default = true
)
/**
* Sets the state of permissions that have been checked, where [false] denotes already checked
* and [true] denotes needing to check. See [shouldShowCameraPermissionPrompt].
*/
var setCameraPermissionNeededState by booleanPreference(
appContext.getPreferenceKey(R.string.pref_key_camera_permissions_needed),
default = true
)
var shouldPromptToSaveLogins by booleanPreference(
appContext.getPreferenceKey(R.string.pref_key_save_logins),
default = true

View File

@ -225,5 +225,5 @@
<string name="pref_key_close_tabs_after_one_week" translatable="false">pref_key_close_tabs_after_one_week</string>
<string name="pref_key_close_tabs_after_one_month" translatable="false">pref_key_close_tabs_after_one_month</string>
<string name="pref_key_camera_permissions" translatable="false">pref_key_camera_permissions</string>
<string name="pref_key_camera_permissions_needed" translatable="false">pref_key_camera_permissions_needed</string>
</resources>

View File

@ -106,4 +106,13 @@ class SearchInteractorTest {
searchController.handleExistingSessionSelected(session)
}
}
@Test
fun onCameraPermissionsNeeded() {
interactor.onCameraPermissionsNeeded()
verify {
searchController.handleCameraPermissionsNeeded()
}
}
}

View File

@ -0,0 +1,40 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.settings.account
import io.mockk.MockKAnnotations
import io.mockk.every
import io.mockk.impl.annotations.MockK
import io.mockk.mockk
import io.mockk.spyk
import io.mockk.verify
import org.junit.Before
import org.junit.Test
import org.mozilla.fenix.HomeActivity
import org.mozilla.fenix.search.AlertDialogBuilder
class DefaultSyncControllerTest {
private lateinit var syncController: DefaultSyncController
@MockK(relaxed = true) private lateinit var activity: HomeActivity
@Before
fun setUp() {
MockKAnnotations.init(this)
syncController = DefaultSyncController(activity)
}
@Test
fun `show camera permissions needed dialog`() {
val dialogBuilder: AlertDialogBuilder = mockk(relaxed = true)
val spyController = spyk(syncController)
every { spyController.buildDialog() } returns dialogBuilder
spyController.handleCameraPermissionsNeeded()
verify { dialogBuilder.show() }
}
}

View File

@ -0,0 +1,31 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.settings.account
import io.mockk.mockk
import io.mockk.verify
import org.junit.Before
import org.junit.Test
class DefaultSyncInteractorTest {
private lateinit var syncInteractor: DefaultSyncInteractor
private lateinit var syncController: DefaultSyncController
@Before
fun setUp() {
syncController = mockk(relaxed = true)
syncInteractor = DefaultSyncInteractor(syncController)
}
@Test
fun onCameraPermissionsNeeded() {
syncInteractor.onCameraPermissionsNeeded()
verify {
syncController.handleCameraPermissionsNeeded()
}
}
}