diff --git a/changelog.d/4873.misc b/changelog.d/4873.misc new file mode 100644 index 0000000000..328a62502f --- /dev/null +++ b/changelog.d/4873.misc @@ -0,0 +1 @@ +Qr code scanning fragments merged into one \ No newline at end of file diff --git a/vector/src/main/java/im/vector/app/core/di/MavericksViewModelModule.kt b/vector/src/main/java/im/vector/app/core/di/MavericksViewModelModule.kt index cc31a7dca6..90398255a2 100644 --- a/vector/src/main/java/im/vector/app/core/di/MavericksViewModelModule.kt +++ b/vector/src/main/java/im/vector/app/core/di/MavericksViewModelModule.kt @@ -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) diff --git a/vector/src/main/java/im/vector/app/core/extensions/Activity.kt b/vector/src/main/java/im/vector/app/core/extensions/Activity.kt index aa96a4a30c..829790f857 100644 --- a/vector/src/main/java/im/vector/app/core/extensions/Activity.kt +++ b/vector/src/main/java/im/vector/app/core/extensions/Activity.kt @@ -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 { return registerForActivityResult(ActivityResultContracts.StartActivityForResult(), onResult) @@ -66,8 +67,12 @@ fun AppCompatActivity.replaceFragment( fragmentClass: Class, 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) } } diff --git a/vector/src/main/java/im/vector/app/core/platform/VectorDummyViewState.kt b/vector/src/main/java/im/vector/app/core/platform/VectorDummyViewState.kt new file mode 100644 index 0000000000..3c293b1072 --- /dev/null +++ b/vector/src/main/java/im/vector/app/core/platform/VectorDummyViewState.kt @@ -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 diff --git a/vector/src/main/java/im/vector/app/features/analytics/accountdata/AnalyticsAccountDataViewModel.kt b/vector/src/main/java/im/vector/app/features/analytics/accountdata/AnalyticsAccountDataViewModel.kt index 5d65d7ea42..3b92e7c4de 100644 --- a/vector/src/main/java/im/vector/app/features/analytics/accountdata/AnalyticsAccountDataViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/analytics/accountdata/AnalyticsAccountDataViewModel.kt @@ -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(initialState) { +) : VectorViewModel(initialState) { private var checkDone: Boolean = false @AssistedFactory - interface Factory : MavericksAssistedViewModelFactory { - override fun create(initialState: DummyState): AnalyticsAccountDataViewModel + interface Factory : MavericksAssistedViewModelFactory { + override fun create(initialState: VectorDummyViewState): AnalyticsAccountDataViewModel } - companion object : MavericksViewModelFactory by hiltMavericksViewModelFactory() { + companion object : MavericksViewModelFactory by hiltMavericksViewModelFactory() { private const val ANALYTICS_EVENT_TYPE = "im.vector.analytics" } diff --git a/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomAction.kt b/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomAction.kt index da3425d326..83c7f0a13b 100644 --- a/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomAction.kt +++ b/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomAction.kt @@ -23,4 +23,8 @@ sealed class CreateDirectRoomAction : VectorViewModelAction { data class CreateRoomAndInviteSelectedUsers( val selections: Set ) : CreateDirectRoomAction() + + data class QrScannedAction( + val result: String + ) : CreateDirectRoomAction() } diff --git a/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomActivity.kt b/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomActivity.kt index 0df9426852..16bca44a73 100644 --- a/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomActivity.kt +++ b/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomActivity.kt @@ -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) } diff --git a/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomByQrCodeFragment.kt b/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomByQrCodeFragment.kt deleted file mode 100644 index 766a6f5156..0000000000 --- a/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomByQrCodeFragment.kt +++ /dev/null @@ -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(), 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) { - 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) - } - } -} diff --git a/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomViewEvents.kt b/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomViewEvents.kt index 0c9804e9a4..6125e3173d 100644 --- a/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomViewEvents.kt +++ b/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomViewEvents.kt @@ -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() +} diff --git a/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomViewModel.kt b/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomViewModel.kt index 41360eab93..9dd3ef6a9b 100644 --- a/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomViewModel.kt @@ -34,13 +34,16 @@ 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, private val rawService: RawService, val session: Session) : - VectorViewModel(initialState) { + VectorViewModel(initialState) { @AssistedFactory interface Factory : MavericksAssistedViewModelFactory { @@ -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) { + 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) } } diff --git a/vector/src/main/java/im/vector/app/features/home/UserColorAccountDataViewModel.kt b/vector/src/main/java/im/vector/app/features/home/UserColorAccountDataViewModel.kt index 3d4f219a7c..37e15af8b3 100644 --- a/vector/src/main/java/im/vector/app/features/home/UserColorAccountDataViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/UserColorAccountDataViewModel.kt @@ -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(initialState) { +) : VectorViewModel(initialState) { @AssistedFactory - interface Factory : MavericksAssistedViewModelFactory { - override fun create(initialState: DummyState): UserColorAccountDataViewModel + interface Factory : MavericksAssistedViewModelFactory { + override fun create(initialState: VectorDummyViewState): UserColorAccountDataViewModel } - companion object : MavericksViewModelFactory by hiltMavericksViewModelFactory() + companion object : MavericksViewModelFactory by hiltMavericksViewModelFactory() init { observeAccountData() diff --git a/vector/src/main/java/im/vector/app/features/qrcode/QrCodeScannerAction.kt b/vector/src/main/java/im/vector/app/features/qrcode/QrCodeScannerAction.kt new file mode 100644 index 0000000000..910f0246d3 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/qrcode/QrCodeScannerAction.kt @@ -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() +} diff --git a/vector/src/main/java/im/vector/app/features/qrcode/QrCodeScannerActivity.kt b/vector/src/main/java/im/vector/app/features/qrcode/QrCodeScannerActivity.kt index d347bc0250..de6d16bb21 100644 --- a/vector/src/main/java/im/vector/app/features/qrcode/QrCodeScannerActivity.kt +++ b/vector/src/main/java/im/vector/app/features/qrcode/QrCodeScannerActivity.kt @@ -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() { +class QrCodeScannerActivity(): VectorBaseActivity() { 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) - - setResult(RESULT_OK, Intent().apply { - putExtra(EXTRA_OUT_TEXT, rawBytesStr ?: result.text) - putExtra(EXTRA_OUT_IS_QR_CODE, result.barcodeFormat == BarcodeFormat.QR_CODE) - }) - } + private fun setResultAndFinish(result: String, isQrCode: Boolean) { + setResult(RESULT_OK, Intent().apply { + 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) { - 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" diff --git a/vector/src/main/java/im/vector/app/features/qrcode/QrCodeScannerEvents.kt b/vector/src/main/java/im/vector/app/features/qrcode/QrCodeScannerEvents.kt new file mode 100644 index 0000000000..5d5f67cc75 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/qrcode/QrCodeScannerEvents.kt @@ -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() +} diff --git a/vector/src/main/java/im/vector/app/features/qrcode/QrCodeScannerFragment.kt b/vector/src/main/java/im/vector/app/features/qrcode/QrCodeScannerFragment.kt index a7231a0c5b..937f4c7eb6 100644 --- a/vector/src/main/java/im/vector/app/features/qrcode/QrCodeScannerFragment.kt +++ b/vector/src/main/java/im/vector/app/features/qrcode/QrCodeScannerFragment.kt @@ -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(), - ZXingScannerView.ResultHandler { +@Parcelize +data class QrScannerArgs( + val showExtraButtons: Boolean, + @StringRes val titleRes: Int +) : Parcelable + +open class QrCodeScannerFragment @Inject constructor(): VectorBaseFragment(), 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) { + 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)) + } } } diff --git a/vector/src/main/java/im/vector/app/features/qrcode/QrCodeScannerViewModel.kt b/vector/src/main/java/im/vector/app/features/qrcode/QrCodeScannerViewModel.kt new file mode 100644 index 0000000000..225701f36b --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/qrcode/QrCodeScannerViewModel.kt @@ -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(initialState) { + + @AssistedFactory + interface Factory : MavericksAssistedViewModelFactory { + override fun create(initialState: VectorDummyViewState): QrCodeScannerViewModel + } + + companion object : MavericksViewModelFactory 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 + } +} + diff --git a/vector/src/main/java/im/vector/app/features/usercode/ScanUserCodeFragment.kt b/vector/src/main/java/im/vector/app/features/usercode/ScanUserCodeFragment.kt deleted file mode 100644 index a7d632bd7b..0000000000 --- a/vector/src/main/java/im/vector/app/features/usercode/ScanUserCodeFragment.kt +++ /dev/null @@ -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(), - 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) { - 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 - } -} diff --git a/vector/src/main/java/im/vector/app/features/usercode/UserCodeActivity.kt b/vector/src/main/java/im/vector/app/features/usercode/UserCodeActivity.kt index 7011f8c280..2e3e176180 100644 --- a/vector/src/main/java/im/vector/app/features/usercode/UserCodeActivity.kt +++ b/vector/src/main/java/im/vector/app/features/usercode/UserCodeActivity.kt @@ -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(), MatrixToBottomSheet.InteractionListener { val sharedViewModel: UserCodeSharedViewModel by viewModel() + private val qrViewModel: QrCodeScannerViewModel by viewModel() @Parcelize data class Args( @@ -81,10 +86,13 @@ class UserCodeActivity : VectorBaseActivity(), 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(), } } } + + 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(), super.onDestroy() } - private fun showFragment(fragmentClass: KClass, bundle: Bundle) { + private fun showFragment(fragmentClass: KClass, 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) } } diff --git a/vector/src/main/res/layout/fragment_qr_code_scanner.xml b/vector/src/main/res/layout/fragment_qr_code_scanner.xml index c17c0d90da..937b0706fb 100644 --- a/vector/src/main/res/layout/fragment_qr_code_scanner.xml +++ b/vector/src/main/res/layout/fragment_qr_code_scanner.xml @@ -21,7 +21,7 @@ - +