qr scanner fragments merged into one

This commit is contained in:
fedrunov 2022-01-20 13:12:01 +01:00
parent 1efb6e162c
commit b6eb27f8a1
20 changed files with 435 additions and 470 deletions

1
changelog.d/4873.misc Normal file
View file

@ -0,0 +1 @@
Qr code scanning fragments merged into one

View file

@ -60,6 +60,7 @@ import im.vector.app.features.login2.created.AccountCreatedViewModel
import im.vector.app.features.matrixto.MatrixToBottomSheetViewModel
import im.vector.app.features.onboarding.OnboardingViewModel
import im.vector.app.features.poll.create.CreatePollViewModel
import im.vector.app.features.qrcode.QrCodeScannerViewModel
import im.vector.app.features.rageshake.BugReportViewModel
import im.vector.app.features.reactions.EmojiSearchResultViewModel
import im.vector.app.features.room.RequireActiveMembershipViewModel
@ -219,6 +220,11 @@ interface MavericksViewModelModule {
@MavericksViewModelKey(CreateDirectRoomViewModel::class)
fun createDirectRoomViewModelFactory(factory: CreateDirectRoomViewModel.Factory): MavericksAssistedViewModelFactory<*, *>
@Binds
@IntoMap
@MavericksViewModelKey(QrCodeScannerViewModel::class)
fun qrCodeViewModelFactory(factory: QrCodeScannerViewModel.Factory): MavericksAssistedViewModelFactory<*, *>
@Binds
@IntoMap
@MavericksViewModelKey(RoomNotificationSettingsViewModel::class)

View file

@ -28,6 +28,7 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentTransaction
import im.vector.app.R
fun ComponentActivity.registerStartForActivityResult(onResult: (ActivityResult) -> Unit): ActivityResultLauncher<Intent> {
return registerForActivityResult(ActivityResultContracts.StartActivityForResult(), onResult)
@ -66,8 +67,12 @@ fun <T : Fragment> AppCompatActivity.replaceFragment(
fragmentClass: Class<T>,
params: Parcelable? = null,
tag: String? = null,
allowStateLoss: Boolean = false) {
allowStateLoss: Boolean = false,
useCustomAnimation: Boolean = false) {
supportFragmentManager.commitTransaction(allowStateLoss) {
if (useCustomAnimation) {
setCustomAnimations(R.anim.fade_in, R.anim.fade_out, R.anim.fade_in, R.anim.fade_out)
}
replace(container.id, fragmentClass, params.toMvRxBundle(), tag)
}
}

View file

@ -0,0 +1,23 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.core.platform
import com.airbnb.mvrx.MavericksState
data class VectorDummyViewState(
val isDummy: Unit = Unit
) : MavericksState

View file

@ -17,7 +17,6 @@
package im.vector.app.features.analytics.accountdata
import androidx.lifecycle.asFlow
import com.airbnb.mvrx.MavericksState
import com.airbnb.mvrx.MavericksViewModelFactory
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
@ -26,6 +25,7 @@ import im.vector.app.core.di.MavericksAssistedViewModelFactory
import im.vector.app.core.di.hiltMavericksViewModelFactory
import im.vector.app.core.platform.EmptyAction
import im.vector.app.core.platform.EmptyViewEvents
import im.vector.app.core.platform.VectorDummyViewState
import im.vector.app.core.platform.VectorViewModel
import im.vector.app.features.analytics.VectorAnalytics
import im.vector.app.features.analytics.log.analyticsTag
@ -42,24 +42,20 @@ import org.matrix.android.sdk.flow.flow
import timber.log.Timber
import java.util.UUID
data class DummyState(
val dummy: Boolean = false
) : MavericksState
class AnalyticsAccountDataViewModel @AssistedInject constructor(
@Assisted initialState: DummyState,
@Assisted initialState: VectorDummyViewState,
private val session: Session,
private val analytics: VectorAnalytics
) : VectorViewModel<DummyState, EmptyAction, EmptyViewEvents>(initialState) {
) : VectorViewModel<VectorDummyViewState, EmptyAction, EmptyViewEvents>(initialState) {
private var checkDone: Boolean = false
@AssistedFactory
interface Factory : MavericksAssistedViewModelFactory<AnalyticsAccountDataViewModel, DummyState> {
override fun create(initialState: DummyState): AnalyticsAccountDataViewModel
interface Factory : MavericksAssistedViewModelFactory<AnalyticsAccountDataViewModel, VectorDummyViewState> {
override fun create(initialState: VectorDummyViewState): AnalyticsAccountDataViewModel
}
companion object : MavericksViewModelFactory<AnalyticsAccountDataViewModel, DummyState> by hiltMavericksViewModelFactory() {
companion object : MavericksViewModelFactory<AnalyticsAccountDataViewModel, VectorDummyViewState> by hiltMavericksViewModelFactory() {
private const val ANALYTICS_EVENT_TYPE = "im.vector.analytics"
}

View file

@ -23,4 +23,8 @@ sealed class CreateDirectRoomAction : VectorViewModelAction {
data class CreateRoomAndInviteSelectedUsers(
val selections: Set<PendingSelection>
) : CreateDirectRoomAction()
data class QrScannedAction(
val result: String
) : CreateDirectRoomAction()
}

View file

@ -22,6 +22,7 @@ import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.lifecycle.lifecycleScope
import com.airbnb.mvrx.Async
import com.airbnb.mvrx.Fail
@ -44,6 +45,10 @@ import im.vector.app.core.utils.onPermissionDeniedSnackbar
import im.vector.app.core.utils.registerForPermissionsResult
import im.vector.app.features.analytics.plan.Screen
import im.vector.app.features.contactsbook.ContactsBookFragment
import im.vector.app.features.qrcode.QrCodeScannerEvents
import im.vector.app.features.qrcode.QrCodeScannerViewModel
import im.vector.app.features.qrcode.QrScannerArgs
import im.vector.app.features.qrcode.QrCodeScannerFragment
import im.vector.app.features.userdirectory.UserListFragment
import im.vector.app.features.userdirectory.UserListFragmentArgs
import im.vector.app.features.userdirectory.UserListSharedAction
@ -59,6 +64,8 @@ import javax.inject.Inject
class CreateDirectRoomActivity : SimpleFragmentActivity() {
private val viewModel: CreateDirectRoomViewModel by viewModel()
private val qrViewModel: QrCodeScannerViewModel by viewModel()
private lateinit var sharedActionViewModel: UserListSharedActionViewModel
@Inject lateinit var errorFormatter: ErrorFormatter
@ -93,11 +100,37 @@ class CreateDirectRoomActivity : SimpleFragmentActivity() {
viewModel.onEach(CreateDirectRoomViewState::createAndInviteState) {
renderCreateAndInviteState(it)
}
viewModel.observeViewEvents {
when (it) {
CreateDirectRoomViewEvents.InvalidCode -> {
Toast.makeText(this, R.string.invalid_qr_code_uri, Toast.LENGTH_SHORT).show()
finish()
}
CreateDirectRoomViewEvents.DmSelf -> {
Toast.makeText(this, R.string.cannot_dm_self, Toast.LENGTH_SHORT).show()
finish()
}
}
}
qrViewModel.observeViewEvents {
when (it) {
is QrCodeScannerEvents.CodeParsed -> {
viewModel.handle(CreateDirectRoomAction.QrScannedAction(it.result))
}
is QrCodeScannerEvents.ParseFailed -> {
Toast.makeText(this, R.string.qr_code_not_scanned, Toast.LENGTH_SHORT).show()
finish()
}
}
}
}
private fun openAddByQrCode() {
if (checkPermissions(PERMISSIONS_FOR_TAKING_PHOTO, this, permissionCameraLauncher)) {
addFragment(views.container, CreateDirectRoomByQrCodeFragment::class.java)
val args = QrScannerArgs(showExtraButtons = false, R.string.add_by_qr_code)
addFragment(views.container, QrCodeScannerFragment::class.java, args)
}
}
@ -118,7 +151,7 @@ class CreateDirectRoomActivity : SimpleFragmentActivity() {
private val permissionCameraLauncher = registerForPermissionsResult { allGranted, deniedPermanently ->
if (allGranted) {
addFragment(views.container, CreateDirectRoomByQrCodeFragment::class.java)
addFragment(views.container, QrCodeScannerFragment::class.java)
} else if (deniedPermanently) {
onPermissionDeniedSnackbar(R.string.permissions_denied_qr_code)
}

View file

@ -1,138 +0,0 @@
/*
* Copyright 2020 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.createdirect
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import com.airbnb.mvrx.activityViewModel
import com.google.zxing.Result
import com.google.zxing.ResultMetadataType
import im.vector.app.R
import im.vector.app.core.extensions.hideKeyboard
import im.vector.app.core.platform.VectorBaseFragment
import im.vector.app.core.utils.PERMISSIONS_FOR_TAKING_PHOTO
import im.vector.app.core.utils.checkPermissions
import im.vector.app.core.utils.onPermissionDeniedDialog
import im.vector.app.core.utils.registerForPermissionsResult
import im.vector.app.databinding.FragmentQrCodeScannerBinding
import im.vector.app.features.userdirectory.PendingSelection
import me.dm7.barcodescanner.zxing.ZXingScannerView
import org.matrix.android.sdk.api.session.permalinks.PermalinkData
import org.matrix.android.sdk.api.session.permalinks.PermalinkParser
import org.matrix.android.sdk.api.session.user.model.User
import javax.inject.Inject
class CreateDirectRoomByQrCodeFragment @Inject constructor() : VectorBaseFragment<FragmentQrCodeScannerBinding>(), ZXingScannerView.ResultHandler {
private val viewModel: CreateDirectRoomViewModel by activityViewModel()
override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentQrCodeScannerBinding {
return FragmentQrCodeScannerBinding.inflate(inflater, container, false)
}
private val openCameraActivityResultLauncher = registerForPermissionsResult { allGranted, deniedPermanently ->
if (allGranted) {
startCamera()
} else if (deniedPermanently) {
activity?.onPermissionDeniedDialog(R.string.denied_permission_camera)
}
}
private fun startCamera() {
// Start camera on resume
views.scannerView.startCamera()
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupToolbar(views.qrScannerToolbar)
.setTitle(R.string.add_by_qr_code)
.allowBack(useCross = true)
}
override fun onResume() {
super.onResume()
view?.hideKeyboard()
// Register ourselves as a handler for scan results.
views.scannerView.setResultHandler(this)
// Start camera on resume
if (checkPermissions(PERMISSIONS_FOR_TAKING_PHOTO, requireActivity(), openCameraActivityResultLauncher)) {
startCamera()
}
}
override fun onPause() {
super.onPause()
// Unregister ourselves as a handler for scan results.
views.scannerView.setResultHandler(null)
// Stop camera on pause
views.scannerView.stopCamera()
}
// Copied from https://github.com/markusfisch/BinaryEye/blob/
// 9d57889b810dcaa1a91d7278fc45c262afba1284/app/src/main/kotlin/de/markusfisch/android/binaryeye/activity/CameraActivity.kt#L434
private fun getRawBytes(result: Result): ByteArray? {
val metadata = result.resultMetadata ?: return null
val segments = metadata[ResultMetadataType.BYTE_SEGMENTS] ?: return null
var bytes = ByteArray(0)
@Suppress("UNCHECKED_CAST")
for (seg in segments as Iterable<ByteArray>) {
bytes += seg
}
// byte segments can never be shorter than the text.
// Zxing cuts off content prefixes like "WIFI:"
return if (bytes.size >= result.text.length) bytes else null
}
private fun addByQrCode(value: String) {
val mxid = (PermalinkParser.parse(value) as? PermalinkData.UserLink)?.userId
if (mxid === null) {
Toast.makeText(requireContext(), R.string.invalid_qr_code_uri, Toast.LENGTH_SHORT).show()
requireActivity().finish()
} else {
// The following assumes MXIDs are case insensitive
if (mxid.equals(other = viewModel.session.myUserId, ignoreCase = true)) {
Toast.makeText(requireContext(), R.string.cannot_dm_self, Toast.LENGTH_SHORT).show()
requireActivity().finish()
} else {
// Try to get user from known users and fall back to creating a User object from MXID
val qrInvitee = if (viewModel.session.getUser(mxid) != null) viewModel.session.getUser(mxid)!! else User(mxid, null, null)
viewModel.handle(
CreateDirectRoomAction.CreateRoomAndInviteSelectedUsers(setOf(PendingSelection.UserPendingSelection(qrInvitee)))
)
}
}
}
override fun handleResult(result: Result?) {
if (result === null) {
Toast.makeText(requireContext(), R.string.qr_code_not_scanned, Toast.LENGTH_SHORT).show()
requireActivity().finish()
} else {
val rawBytes = getRawBytes(result)
val rawBytesStr = rawBytes?.toString(Charsets.ISO_8859_1)
val value = rawBytesStr ?: result.text
addByQrCode(value)
}
}
}

View file

@ -16,6 +16,10 @@
package im.vector.app.features.createdirect
import com.airbnb.mvrx.Async
import im.vector.app.core.platform.VectorViewEvents
sealed class CreateDirectRoomViewEvents : VectorViewEvents
sealed class CreateDirectRoomViewEvents : VectorViewEvents {
object InvalidCode: CreateDirectRoomViewEvents()
object DmSelf: CreateDirectRoomViewEvents()
}

View file

@ -34,7 +34,10 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.matrix.android.sdk.api.raw.RawService
import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.permalinks.PermalinkData
import org.matrix.android.sdk.api.session.permalinks.PermalinkParser
import org.matrix.android.sdk.api.session.room.model.create.CreateRoomParams
import org.matrix.android.sdk.api.session.user.model.User
class CreateDirectRoomViewModel @AssistedInject constructor(@Assisted
initialState: CreateDirectRoomViewState,
@ -51,15 +54,33 @@ class CreateDirectRoomViewModel @AssistedInject constructor(@Assisted
override fun handle(action: CreateDirectRoomAction) {
when (action) {
is CreateDirectRoomAction.CreateRoomAndInviteSelectedUsers -> onSubmitInvitees(action)
is CreateDirectRoomAction.CreateRoomAndInviteSelectedUsers -> onSubmitInvitees(action.selections)
is CreateDirectRoomAction.QrScannedAction -> onCodeParsed(action)
}.exhaustive
}
private fun onCodeParsed(action: CreateDirectRoomAction.QrScannedAction) {
val mxid = (PermalinkParser.parse(action.result) as? PermalinkData.UserLink)?.userId
if (mxid === null) {
_viewEvents.post(CreateDirectRoomViewEvents.InvalidCode)
} else {
// The following assumes MXIDs are case insensitive
if (mxid.equals(other = session.myUserId, ignoreCase = true)) {
_viewEvents.post(CreateDirectRoomViewEvents.DmSelf)
} else {
// Try to get user from known users and fall back to creating a User object from MXID
val qrInvitee = if (session.getUser(mxid) != null) session.getUser(mxid)!! else User(mxid, null, null)
onSubmitInvitees(setOf(PendingSelection.UserPendingSelection(qrInvitee)))
}
}
}
/**
* If users already have a DM room then navigate to it instead of creating a new room.
*/
private fun onSubmitInvitees(action: CreateDirectRoomAction.CreateRoomAndInviteSelectedUsers) {
val existingRoomId = action.selections.singleOrNull()?.getMxId()?.let { userId ->
private fun onSubmitInvitees(selections: Set<PendingSelection>) {
val existingRoomId = selections.singleOrNull()?.getMxId()?.let { userId ->
session.getExistingDirectRoomWithUser(userId)
}
if (existingRoomId != null) {
@ -69,7 +90,7 @@ class CreateDirectRoomViewModel @AssistedInject constructor(@Assisted
}
} else {
// Create the DM
createRoomAndInviteSelectedUsers(action.selections)
createRoomAndInviteSelectedUsers(selections)
}
}

View file

@ -16,7 +16,6 @@
package im.vector.app.features.home
import com.airbnb.mvrx.MavericksState
import com.airbnb.mvrx.MavericksViewModelFactory
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
@ -25,6 +24,7 @@ import im.vector.app.core.di.MavericksAssistedViewModelFactory
import im.vector.app.core.di.hiltMavericksViewModelFactory
import im.vector.app.core.platform.EmptyAction
import im.vector.app.core.platform.EmptyViewEvents
import im.vector.app.core.platform.VectorDummyViewState
import im.vector.app.core.platform.VectorViewModel
import im.vector.app.features.home.room.detail.timeline.helper.MatrixItemColorProvider
import kotlinx.coroutines.flow.launchIn
@ -37,22 +37,18 @@ import org.matrix.android.sdk.flow.flow
import org.matrix.android.sdk.flow.unwrap
import timber.log.Timber
data class DummyState(
val dummy: Boolean = false
) : MavericksState
class UserColorAccountDataViewModel @AssistedInject constructor(
@Assisted initialState: DummyState,
@Assisted initialState: VectorDummyViewState,
private val session: Session,
private val matrixItemColorProvider: MatrixItemColorProvider
) : VectorViewModel<DummyState, EmptyAction, EmptyViewEvents>(initialState) {
) : VectorViewModel<VectorDummyViewState, EmptyAction, EmptyViewEvents>(initialState) {
@AssistedFactory
interface Factory : MavericksAssistedViewModelFactory<UserColorAccountDataViewModel, DummyState> {
override fun create(initialState: DummyState): UserColorAccountDataViewModel
interface Factory : MavericksAssistedViewModelFactory<UserColorAccountDataViewModel, VectorDummyViewState> {
override fun create(initialState: VectorDummyViewState): UserColorAccountDataViewModel
}
companion object : MavericksViewModelFactory<UserColorAccountDataViewModel, DummyState> by hiltMavericksViewModelFactory()
companion object : MavericksViewModelFactory<UserColorAccountDataViewModel, VectorDummyViewState> by hiltMavericksViewModelFactory()
init {
observeAccountData()

View file

@ -0,0 +1,30 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.qrcode
import im.vector.app.core.platform.VectorViewModelAction
sealed class QrCodeScannerAction : VectorViewModelAction {
data class CodeDecoded(
val result: String,
val isQrCode: Boolean
) : QrCodeScannerAction()
object ScanFailed : QrCodeScannerAction()
object SwitchMode : QrCodeScannerAction()
}

View file

@ -19,56 +19,53 @@ package im.vector.app.features.qrcode
import android.app.Activity
import android.content.Intent
import android.os.Bundle
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import com.google.zxing.BarcodeFormat
import com.google.zxing.Result
import com.google.zxing.ResultMetadataType
import com.airbnb.mvrx.viewModel
import dagger.hilt.android.AndroidEntryPoint
import im.vector.app.R
import im.vector.app.core.extensions.replaceFragment
import im.vector.app.core.platform.VectorBaseActivity
import im.vector.app.databinding.ActivitySimpleBinding
@AndroidEntryPoint
class QrCodeScannerActivity : VectorBaseActivity<ActivitySimpleBinding>() {
class QrCodeScannerActivity(): VectorBaseActivity<ActivitySimpleBinding>() {
override fun getBinding() = ActivitySimpleBinding.inflate(layoutInflater)
override fun getCoordinatorLayout() = views.coordinatorLayout
private val qrViewModel: QrCodeScannerViewModel by viewModel()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
qrViewModel.observeViewEvents {
when (it) {
is QrCodeScannerEvents.CodeParsed -> {
setResultAndFinish(it.result, it.isQrCode)
}
is QrCodeScannerEvents.ParseFailed -> {
Toast.makeText(this, R.string.qr_code_not_scanned, Toast.LENGTH_SHORT).show()
finish()
}
}
}
if (isFirstCreation()) {
replaceFragment(views.simpleFragmentContainer, QrCodeScannerFragment::class.java)
val args = QrScannerArgs(showExtraButtons = false, R.string.verification_scan_their_code)
replaceFragment(views.simpleFragmentContainer, QrCodeScannerFragment::class.java, args)
}
}
fun setResultAndFinish(result: Result?) {
if (result != null) {
val rawBytes = getRawBytes(result)
val rawBytesStr = rawBytes?.toString(Charsets.ISO_8859_1)
private fun setResultAndFinish(result: String, isQrCode: Boolean) {
setResult(RESULT_OK, Intent().apply {
putExtra(EXTRA_OUT_TEXT, rawBytesStr ?: result.text)
putExtra(EXTRA_OUT_IS_QR_CODE, result.barcodeFormat == BarcodeFormat.QR_CODE)
putExtra(EXTRA_OUT_TEXT, result)
putExtra(EXTRA_OUT_IS_QR_CODE, isQrCode)
})
}
finish()
}
// Copied from https://github.com/markusfisch/BinaryEye/blob/
// 9d57889b810dcaa1a91d7278fc45c262afba1284/app/src/main/kotlin/de/markusfisch/android/binaryeye/activity/CameraActivity.kt#L434
private fun getRawBytes(result: Result): ByteArray? {
val metadata = result.resultMetadata ?: return null
val segments = metadata[ResultMetadataType.BYTE_SEGMENTS] ?: return null
var bytes = ByteArray(0)
@Suppress("UNCHECKED_CAST")
for (seg in segments as Iterable<ByteArray>) {
bytes += seg
}
// byte segments can never be shorter than the text.
// Zxing cuts off content prefixes like "WIFI:"
return if (bytes.size >= result.text.length) bytes else null
}
companion object {
private const val EXTRA_OUT_TEXT = "EXTRA_OUT_TEXT"

View file

@ -0,0 +1,25 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.qrcode
import im.vector.app.core.platform.VectorViewEvents
sealed class QrCodeScannerEvents : VectorViewEvents {
data class CodeParsed(val result: String, val isQrCode: Boolean): QrCodeScannerEvents()
object ParseFailed: QrCodeScannerEvents()
object SwitchMode: QrCodeScannerEvents()
}

View file

@ -1,5 +1,5 @@
/*
* Copyright 2020 New Vector Ltd
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -16,50 +16,157 @@
package im.vector.app.features.qrcode
import android.app.Activity
import android.os.Bundle
import android.os.Parcelable
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.annotation.StringRes
import androidx.core.view.isVisible
import com.airbnb.mvrx.activityViewModel
import com.airbnb.mvrx.args
import com.google.zxing.BarcodeFormat
import com.google.zxing.Result
import com.google.zxing.ResultMetadataType
import im.vector.app.R
import im.vector.app.core.extensions.hideKeyboard
import im.vector.app.core.extensions.registerStartForActivityResult
import im.vector.app.core.platform.VectorBaseFragment
import im.vector.app.core.utils.PERMISSIONS_FOR_TAKING_PHOTO
import im.vector.app.core.utils.checkPermissions
import im.vector.app.core.utils.onPermissionDeniedDialog
import im.vector.app.core.utils.registerForPermissionsResult
import im.vector.app.databinding.FragmentQrCodeScannerBinding
import im.vector.app.features.usercode.QRCodeBitmapDecodeHelper
import im.vector.lib.multipicker.MultiPicker
import im.vector.lib.multipicker.utils.ImageUtils
import kotlinx.parcelize.Parcelize
import me.dm7.barcodescanner.zxing.ZXingScannerView
import org.matrix.android.sdk.api.extensions.tryOrNull
import javax.inject.Inject
class QrCodeScannerFragment @Inject constructor() :
VectorBaseFragment<FragmentQrCodeScannerBinding>(),
ZXingScannerView.ResultHandler {
@Parcelize
data class QrScannerArgs(
val showExtraButtons: Boolean,
@StringRes val titleRes: Int
) : Parcelable
open class QrCodeScannerFragment @Inject constructor(): VectorBaseFragment<FragmentQrCodeScannerBinding>(), ZXingScannerView.ResultHandler {
private val qrViewModel: QrCodeScannerViewModel by activityViewModel()
private val scannerArgs: QrScannerArgs? by args()
override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentQrCodeScannerBinding {
return FragmentQrCodeScannerBinding.inflate(inflater, container, false)
}
private val openCameraActivityResultLauncher = registerForPermissionsResult { allGranted, deniedPermanently ->
if (allGranted) {
startCamera()
} else if (deniedPermanently) {
activity?.onPermissionDeniedDialog(R.string.denied_permission_camera)
}
}
private val pickImageActivityResultLauncher = registerStartForActivityResult { activityResult ->
if (activityResult.resultCode == Activity.RESULT_OK) {
MultiPicker
.get(MultiPicker.IMAGE)
.getSelectedFiles(requireActivity(), activityResult.data)
.firstOrNull()
?.contentUri
?.let { uri ->
// try to see if it is a valid matrix code
val bitmap = ImageUtils.getBitmap(requireContext(), uri)
?: return@let Unit.also {
Toast.makeText(requireContext(), getString(R.string.qr_code_not_scanned), Toast.LENGTH_SHORT).show()
}
handleResult(tryOrNull { QRCodeBitmapDecodeHelper.decodeQRFromBitmap(bitmap) })
}
}
}
private var autoFocus = true
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val title = scannerArgs?.titleRes?.let { getString(it) }
setupToolbar(views.qrScannerToolbar)
.setTitle(R.string.verification_scan_their_code)
.setTitle(title)
.allowBack(useCross = true)
scannerArgs?.showExtraButtons?.let { showButtons ->
views.userCodeMyCodeButton.isVisible = showButtons
views.userCodeOpenGalleryButton.isVisible = showButtons
if (showButtons) {
views.userCodeOpenGalleryButton.debouncedClicks {
MultiPicker.get(MultiPicker.IMAGE).single().startWith(pickImageActivityResultLauncher)
}
views.userCodeMyCodeButton.debouncedClicks {
qrViewModel.handle(QrCodeScannerAction.SwitchMode)
}
}
}
}
private fun startCamera() {
with(views.qrScannerView) {
startCamera()
setAutoFocus(autoFocus)
debouncedClicks {
autoFocus = !autoFocus
setAutoFocus(autoFocus)
}
}
}
override fun onResume() {
super.onResume()
view?.hideKeyboard()
// Register ourselves as a handler for scan results.
views.scannerView.setResultHandler(this)
// Start camera on resume
views.scannerView.startCamera()
views.qrScannerView.setResultHandler(this)
if (checkPermissions(PERMISSIONS_FOR_TAKING_PHOTO, requireActivity(), openCameraActivityResultLauncher)) {
startCamera()
}
}
override fun onPause() {
super.onPause()
// Stop camera on pause
views.scannerView.stopCamera()
views.qrScannerView.setResultHandler(null)
views.qrScannerView.stopCamera()
}
// Copied from https://github.com/markusfisch/BinaryEye/blob/
// 9d57889b810dcaa1a91d7278fc45c262afba1284/app/src/main/kotlin/de/markusfisch/android/binaryeye/activity/CameraActivity.kt#L434
private fun getRawBytes(result: Result): ByteArray? {
val metadata = result.resultMetadata ?: return null
val segments = metadata[ResultMetadataType.BYTE_SEGMENTS] ?: return null
var bytes = ByteArray(0)
@Suppress("UNCHECKED_CAST")
for (seg in segments as Iterable<ByteArray>) {
bytes += seg
}
// byte segments can never be shorter than the text.
// Zxing cuts off content prefixes like "WIFI:"
return if (bytes.size >= result.text.length) bytes else null
}
override fun handleResult(rawResult: Result?) {
// Do something with the result here
// This is not intended to be used outside of QrCodeScannerActivity for the moment
(requireActivity() as? QrCodeScannerActivity)?.setResultAndFinish(rawResult)
if (rawResult == null) {
qrViewModel.handle(QrCodeScannerAction.ScanFailed)
} else {
val rawBytes = getRawBytes(rawResult)
val rawBytesStr = rawBytes?.toString(Charsets.ISO_8859_1)
val result = rawBytesStr ?: rawResult.text
val isQrCode = rawResult.barcodeFormat == BarcodeFormat.QR_CODE
qrViewModel.handle(QrCodeScannerAction.CodeDecoded(result, isQrCode))
}
}
}

View file

@ -0,0 +1,50 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.qrcode
import com.airbnb.mvrx.MavericksViewModelFactory
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import im.vector.app.core.di.MavericksAssistedViewModelFactory
import im.vector.app.core.di.hiltMavericksViewModelFactory
import im.vector.app.core.extensions.exhaustive
import im.vector.app.core.platform.VectorDummyViewState
import im.vector.app.core.platform.VectorViewModel
import org.matrix.android.sdk.api.session.Session
class QrCodeScannerViewModel @AssistedInject constructor(
@Assisted initialState: VectorDummyViewState,
val session: Session
) : VectorViewModel<VectorDummyViewState, QrCodeScannerAction, QrCodeScannerEvents>(initialState) {
@AssistedFactory
interface Factory : MavericksAssistedViewModelFactory<QrCodeScannerViewModel, VectorDummyViewState> {
override fun create(initialState: VectorDummyViewState): QrCodeScannerViewModel
}
companion object : MavericksViewModelFactory<QrCodeScannerViewModel, VectorDummyViewState> by hiltMavericksViewModelFactory()
override fun handle(action: QrCodeScannerAction) {
when (action) {
is QrCodeScannerAction.CodeDecoded -> _viewEvents.post(QrCodeScannerEvents.CodeParsed(action.result, action.isQrCode))
is QrCodeScannerAction.SwitchMode -> _viewEvents.post(QrCodeScannerEvents.SwitchMode)
is QrCodeScannerAction.ScanFailed -> _viewEvents.post(QrCodeScannerEvents.ParseFailed)
}.exhaustive
}
}

View file

@ -1,156 +0,0 @@
/*
* Copyright (c) 2020 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.usercode
import android.Manifest
import android.app.Activity
import android.content.pm.PackageManager
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.core.content.ContextCompat
import com.airbnb.mvrx.activityViewModel
import com.google.zxing.Result
import com.google.zxing.ResultMetadataType
import im.vector.app.R
import im.vector.app.core.extensions.registerStartForActivityResult
import im.vector.app.core.platform.VectorBaseFragment
import im.vector.app.core.utils.PERMISSIONS_FOR_TAKING_PHOTO
import im.vector.app.core.utils.checkPermissions
import im.vector.app.core.utils.registerForPermissionsResult
import im.vector.app.databinding.FragmentQrCodeScannerWithButtonBinding
import im.vector.lib.multipicker.MultiPicker
import im.vector.lib.multipicker.utils.ImageUtils
import me.dm7.barcodescanner.zxing.ZXingScannerView
import org.matrix.android.sdk.api.extensions.tryOrNull
import javax.inject.Inject
class ScanUserCodeFragment @Inject constructor() :
VectorBaseFragment<FragmentQrCodeScannerWithButtonBinding>(),
ZXingScannerView.ResultHandler {
override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentQrCodeScannerWithButtonBinding {
return FragmentQrCodeScannerWithButtonBinding.inflate(inflater, container, false)
}
val sharedViewModel: UserCodeSharedViewModel by activityViewModel()
var autoFocus = true
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupToolbar(views.qrScannerToolbar)
.allowBack(useCross = true)
views.userCodeMyCodeButton.debouncedClicks {
sharedViewModel.handle(UserCodeActions.SwitchMode(UserCodeState.Mode.SHOW))
}
views.userCodeOpenGalleryButton.debouncedClicks {
MultiPicker.get(MultiPicker.IMAGE).single().startWith(pickImageActivityResultLauncher)
}
}
private val openCameraActivityResultLauncher = registerForPermissionsResult { allGranted, _ ->
if (allGranted) {
startCamera()
} else {
// For now just go back
sharedViewModel.handle(UserCodeActions.SwitchMode(UserCodeState.Mode.SHOW))
}
}
private val pickImageActivityResultLauncher = registerStartForActivityResult { activityResult ->
if (activityResult.resultCode == Activity.RESULT_OK) {
MultiPicker
.get(MultiPicker.IMAGE)
.getSelectedFiles(requireActivity(), activityResult.data)
.firstOrNull()
?.contentUri
?.let { uri ->
// try to see if it is a valid matrix code
val bitmap = ImageUtils.getBitmap(requireContext(), uri)
?: return@let Unit.also {
Toast.makeText(requireContext(), getString(R.string.qr_code_not_scanned), Toast.LENGTH_SHORT).show()
}
handleResult(tryOrNull { QRCodeBitmapDecodeHelper.decodeQRFromBitmap(bitmap) })
}
}
}
private fun startCamera() {
views.userCodeScannerView.startCamera()
views.userCodeScannerView.setAutoFocus(autoFocus)
views.userCodeScannerView.debouncedClicks {
this.autoFocus = !autoFocus
views.userCodeScannerView.setAutoFocus(autoFocus)
}
}
override fun onStart() {
super.onStart()
if (checkPermissions(PERMISSIONS_FOR_TAKING_PHOTO, requireActivity(), openCameraActivityResultLauncher)) {
startCamera()
}
}
override fun onResume() {
super.onResume()
// Register ourselves as a handler for scan results.
views.userCodeScannerView.setResultHandler(this)
if (ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
startCamera()
}
}
override fun onPause() {
super.onPause()
views.userCodeScannerView.setResultHandler(null)
// Stop camera on pause
views.userCodeScannerView.stopCamera()
}
override fun handleResult(result: Result?) {
if (result === null) {
Toast.makeText(requireContext(), R.string.qr_code_not_scanned, Toast.LENGTH_SHORT).show()
requireActivity().finish()
} else {
val rawBytes = getRawBytes(result)
val rawBytesStr = rawBytes?.toString(Charsets.ISO_8859_1)
val value = rawBytesStr ?: result.text
sharedViewModel.handle(UserCodeActions.DecodedQRCode(value))
}
}
// Copied from https://github.com/markusfisch/BinaryEye/blob/
// 9d57889b810dcaa1a91d7278fc45c262afba1284/app/src/main/kotlin/de/markusfisch/android/binaryeye/activity/CameraActivity.kt#L434
private fun getRawBytes(result: Result): ByteArray? {
val metadata = result.resultMetadata ?: return null
val segments = metadata[ResultMetadataType.BYTE_SEGMENTS] ?: return null
var bytes = ByteArray(0)
@Suppress("UNCHECKED_CAST")
for (seg in segments as Iterable<ByteArray>) {
bytes += seg
}
// byte segments can never be shorter than the text.
// Zxing cuts off content prefixes like "WIFI:"
return if (bytes.size >= result.text.length) bytes else null
}
}

View file

@ -30,12 +30,16 @@ import com.airbnb.mvrx.viewModel
import com.airbnb.mvrx.withState
import dagger.hilt.android.AndroidEntryPoint
import im.vector.app.R
import im.vector.app.core.extensions.commitTransaction
import im.vector.app.core.extensions.exhaustive
import im.vector.app.core.extensions.replaceFragment
import im.vector.app.core.platform.VectorBaseActivity
import im.vector.app.core.utils.onPermissionDeniedSnackbar
import im.vector.app.databinding.ActivitySimpleBinding
import im.vector.app.features.matrixto.MatrixToBottomSheet
import im.vector.app.features.qrcode.QrCodeScannerEvents
import im.vector.app.features.qrcode.QrCodeScannerFragment
import im.vector.app.features.qrcode.QrCodeScannerViewModel
import im.vector.app.features.qrcode.QrScannerArgs
import kotlinx.parcelize.Parcelize
import kotlin.reflect.KClass
@ -44,6 +48,7 @@ class UserCodeActivity : VectorBaseActivity<ActivitySimpleBinding>(),
MatrixToBottomSheet.InteractionListener {
val sharedViewModel: UserCodeSharedViewModel by viewModel()
private val qrViewModel: QrCodeScannerViewModel by viewModel()
@Parcelize
data class Args(
@ -81,10 +86,13 @@ class UserCodeActivity : VectorBaseActivity<ActivitySimpleBinding>(),
sharedViewModel.onEach(UserCodeState::mode) { mode ->
when (mode) {
UserCodeState.Mode.SHOW -> showFragment(ShowUserCodeFragment::class, Bundle.EMPTY)
UserCodeState.Mode.SCAN -> showFragment(ScanUserCodeFragment::class, Bundle.EMPTY)
UserCodeState.Mode.SHOW -> showFragment(ShowUserCodeFragment::class)
UserCodeState.Mode.SCAN -> {
val args = QrScannerArgs(showExtraButtons = true, R.string.user_code_scan)
showFragment(QrCodeScannerFragment::class, args)
}
is UserCodeState.Mode.RESULT -> {
showFragment(ShowUserCodeFragment::class, Bundle.EMPTY)
showFragment(ShowUserCodeFragment::class)
MatrixToBottomSheet.withLink(mode.rawLink).show(supportFragmentManager, "MatrixToBottomSheet")
}
}
@ -106,6 +114,21 @@ class UserCodeActivity : VectorBaseActivity<ActivitySimpleBinding>(),
}
}
}
qrViewModel.observeViewEvents {
when (it) {
is QrCodeScannerEvents.CodeParsed -> {
sharedViewModel.handle(UserCodeActions.DecodedQRCode(it.result))
}
QrCodeScannerEvents.SwitchMode -> {
sharedViewModel.handle(UserCodeActions.SwitchMode(UserCodeState.Mode.SHOW))
}
is QrCodeScannerEvents.ParseFailed -> {
Toast.makeText(this, R.string.qr_code_not_scanned, Toast.LENGTH_SHORT).show()
finish()
}
}
}
}
override fun onDestroy() {
@ -113,16 +136,9 @@ class UserCodeActivity : VectorBaseActivity<ActivitySimpleBinding>(),
super.onDestroy()
}
private fun showFragment(fragmentClass: KClass<out Fragment>, bundle: Bundle) {
private fun showFragment(fragmentClass: KClass<out Fragment>, params: Parcelable? = null) {
if (supportFragmentManager.findFragmentByTag(fragmentClass.simpleName) == null) {
supportFragmentManager.commitTransaction {
setCustomAnimations(R.anim.fade_in, R.anim.fade_out, R.anim.fade_in, R.anim.fade_out)
replace(views.simpleFragmentContainer.id,
fragmentClass.java,
bundle,
fragmentClass.simpleName
)
}
replaceFragment(views.simpleFragmentContainer, fragmentClass.java, params, fragmentClass.simpleName, useCustomAnimation = true)
}
}

View file

@ -21,7 +21,7 @@
</com.google.android.material.appbar.AppBarLayout>
<me.dm7.barcodescanner.zxing.ZXingScannerView
android:id="@+id/scannerView"
android:id="@+id/qrScannerView"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
@ -29,30 +29,42 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/appBarLayout" />
<!-- TODO In the future we could add a toggle to switch the flash, and other possible settings -->
<Button
android:id="@+id/userCodeMyCodeButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="@dimen/layout_vertical_margin_big"
android:maxWidth="160dp"
android:text="@string/user_code_my_code"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<!-- TODO add take from album option.. -->
<!-- <Button-->
<!-- android:id="@+id/openAlbumButton"-->
<!-- style="@style/Widget.MaterialComponents.Button.Icon"-->
<!-- android:layout_width="34dp"-->
<!-- android:layout_height="34dp"-->
<!-- android:layout_marginEnd="@dimen/layout_horizontal_margin"-->
<!-- android:layout_marginBottom="@dimen/layout_vertical_margin_big"-->
<!-- android:backgroundTint="?vctr_bottom_nav_background_color"-->
<!-- android:elevation="0dp"-->
<!-- android:insetLeft="0dp"-->
<!-- android:insetTop="0dp"-->
<!-- android:insetRight="0dp"-->
<!-- android:insetBottom="0dp"-->
<!-- android:padding="0dp"-->
<!-- app:cornerRadius="17dp"-->
<!-- app:icon="@drawable/ic_picture_icon"-->
<!-- app:iconGravity="textStart"-->
<!-- app:iconPadding="0dp"-->
<!-- app:iconSize="20dp"-->
<!-- app:iconTint="?colorPrimary"-->
<!-- app:layout_constraintBottom_toBottomOf="parent"-->
<!-- app:layout_constraintEnd_toEndOf="parent"/>-->
<Button
android:id="@+id/userCodeOpenGalleryButton"
style="@style/Widget.MaterialComponents.Button.Icon"
android:layout_width="34dp"
android:layout_height="34dp"
android:layout_marginTop="2dp"
android:layout_marginEnd="@dimen/layout_horizontal_margin"
android:backgroundTint="?colorSurface"
android:elevation="0dp"
android:insetLeft="0dp"
android:insetTop="0dp"
android:insetRight="0dp"
android:insetBottom="0dp"
android:padding="0dp"
android:visibility="gone"
app:cornerRadius="17dp"
app:icon="@drawable/ic_picture_icon"
app:iconGravity="textStart"
app:iconPadding="0dp"
app:iconSize="20dp"
app:iconTint="?colorPrimary"
app:layout_constraintBottom_toBottomOf="@id/userCodeMyCodeButton"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintEnd_toStartOf="@id/userCodeMyCodeButton"
app:layout_constraintTop_toTopOf="@id/userCodeMyCodeButton" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -1,67 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/appBarLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/qrScannerToolbar"
android:layout_width="match_parent"
android:layout_height="?actionBarSize"
app:title="@string/user_code_scan" />
</com.google.android.material.appbar.AppBarLayout>
<me.dm7.barcodescanner.zxing.ZXingScannerView
android:id="@+id/userCodeScannerView"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/appBarLayout" />
<Button
android:id="@+id/userCodeMyCodeButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="@dimen/layout_vertical_margin_big"
android:maxWidth="160dp"
android:text="@string/user_code_my_code"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<Button
android:id="@+id/userCodeOpenGalleryButton"
style="@style/Widget.MaterialComponents.Button.Icon"
android:layout_width="34dp"
android:layout_height="34dp"
android:layout_marginTop="2dp"
android:layout_marginEnd="@dimen/layout_horizontal_margin"
android:backgroundTint="?colorSurface"
android:elevation="0dp"
android:insetLeft="0dp"
android:insetTop="0dp"
android:insetRight="0dp"
android:insetBottom="0dp"
android:padding="0dp"
app:cornerRadius="17dp"
app:icon="@drawable/ic_picture_icon"
app:iconGravity="textStart"
app:iconPadding="0dp"
app:iconSize="20dp"
app:iconTint="?colorPrimary"
app:layout_constraintBottom_toBottomOf="@id/userCodeMyCodeButton"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintEnd_toStartOf="@id/userCodeMyCodeButton"
app:layout_constraintTop_toTopOf="@id/userCodeMyCodeButton" />
</androidx.constraintlayout.widget.ConstraintLayout>