diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7d840a9b0c..7911227fc4 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1084,6 +1084,11 @@ android:theme="@style/Theme.Signal.WallpaperCropper" android:exported="false"/> + + + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + galleryLauncher = registerForActivityResult(UsernameQrImageSelectionActivity.Contract()) { uri -> + if (uri != null) { + viewModel.scanImage(requireContext(), uri) + } + } + } + override fun onStart() { super.onStart() setFragmentResultListener(UsernameLinkShareBottomSheet.REQUEST_KEY) { key, bundle -> @@ -99,18 +116,28 @@ class UsernameLinkSettingsFragment : ComposeFragment() { viewModel.onTabSelected(ActiveTab.Scan) } + val galleryPermissionState: MultiplePermissionsState = rememberMultiplePermissionsState(permissions = PermissionCompat.forImages().toList()) { grants -> + if (grants.values.all { it }) { + galleryLauncher.launch(Unit) + } else { + Toast.makeText(requireContext(), R.string.ChatWallpaperPreviewActivity__viewing_your_gallery_requires_the_storage_permission, Toast.LENGTH_SHORT).show() + } + } + MainScreen( state = state, navController = navController, lifecycleOwner = viewLifecycleOwner, disposables = disposables.disposables, cameraPermissionState = cameraPermissionState, + galleryPermissionState = galleryPermissionState, onCodeTabSelected = { viewModel.onTabSelected(ActiveTab.Code) }, onScanTabSelected = { viewModel.onTabSelected(ActiveTab.Scan) }, onUsernameLinkResetResultHandled = { viewModel.onUsernameLinkResetResultHandled() }, onShareBadge = { shareQrBadge(requireActivity(), viewModel.generateQrCodeImage(helpText)) }, onQrCodeScanned = { data -> viewModel.onQrCodeScanned(data) }, onQrResultHandled = { viewModel.onQrResultHandled() }, + onOpenGalleryClicked = { galleryLauncher.launch(Unit) }, onLinkReset = { viewModel.onUsernameLinkReset() }, onBackNavigationPressed = { requireActivity().onBackPressed() }, linkCopiedEvent = linkCopiedEvent @@ -127,6 +154,7 @@ class UsernameLinkSettingsFragment : ComposeFragment() { } } +@OptIn(ExperimentalPermissionsApi::class) @Composable private fun MainScreen( state: UsernameLinkSettingsState, @@ -134,12 +162,14 @@ private fun MainScreen( lifecycleOwner: LifecycleOwner = previewLifecycleOwner, disposables: CompositeDisposable = CompositeDisposable(), cameraPermissionState: PermissionState = previewPermissionState(), + galleryPermissionState: MultiplePermissionsState = previewMultiplePermissionState(), onCodeTabSelected: () -> Unit = {}, onScanTabSelected: () -> Unit = {}, onUsernameLinkResetResultHandled: () -> Unit = {}, onShareBadge: () -> Unit = {}, onQrCodeScanned: (String) -> Unit = {}, onQrResultHandled: () -> Unit = {}, + onOpenGalleryClicked: () -> Unit = {}, onLinkReset: () -> Unit = {}, onBackNavigationPressed: () -> Unit = {}, linkCopiedEvent: UUID? = null @@ -201,9 +231,11 @@ private fun MainScreen( UsernameQrScanScreen( lifecycleOwner = lifecycleOwner, disposables = disposables, + galleryPermissionState = galleryPermissionState, qrScanResult = state.qrScanResult, onQrCodeScanned = onQrCodeScanned, onQrResultHandled = onQrResultHandled, + onOpenGalleryClicked = onOpenGalleryClicked, modifier = Modifier.padding(contentPadding) ) } @@ -355,6 +387,16 @@ private fun previewPermissionState(): PermissionState { } } +private fun previewMultiplePermissionState(): MultiplePermissionsState { + return object : MultiplePermissionsState { + override val allPermissionsGranted: Boolean = true + override val permissions: List = emptyList() + override val revokedPermissions: List = emptyList() + override val shouldShowRationale: Boolean = false + override fun launchMultiplePermissionRequest() = Unit + } +} + private val previewLifecycleOwner: LifecycleOwner = object : LifecycleOwner { override val lifecycle: Lifecycle get() = throw UnsupportedOperationException("Only for tests") diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkSettingsViewModel.kt index 8483195714..d8d852814c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkSettingsViewModel.kt @@ -1,5 +1,6 @@ package org.thoughtcrime.securesms.components.settings.app.usernamelinks.main +import android.content.Context import android.graphics.Bitmap import android.graphics.BitmapFactory import android.graphics.Color @@ -9,6 +10,7 @@ import android.graphics.PorterDuffColorFilter import android.graphics.Rect import android.graphics.RectF import android.graphics.Typeface +import android.net.Uri import android.os.Build import android.text.Layout import android.text.StaticLayout @@ -25,13 +27,18 @@ import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.LayoutDirection import androidx.core.graphics.withTranslation import androidx.lifecycle.ViewModel +import com.bumptech.glide.Glide +import com.bumptech.glide.load.DecodeFormat import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.kotlin.plusAssign +import io.reactivex.rxjava3.kotlin.subscribeBy import io.reactivex.rxjava3.schedulers.Schedulers import io.reactivex.rxjava3.subjects.BehaviorSubject import org.signal.core.util.logging.Log +import org.signal.core.util.toOptional +import org.signal.qr.QrProcessor import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeData import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeState @@ -193,6 +200,29 @@ class UsernameLinkSettingsViewModel : ViewModel() { _linkCopiedEvent.value = UUID.randomUUID() } + fun scanImage(context: Context, uri: Uri) { + val loadBitmap = Glide.with(context) + .asBitmap() + .format(DecodeFormat.PREFER_ARGB_8888) + .load(uri) + .submit() + + disposable += Single.fromFuture(loadBitmap) + .subscribeOn(Schedulers.io()) + .map { QrProcessor().getScannedData(it).toOptional() } + .observeOn(AndroidSchedulers.mainThread()) + .subscribeBy { + if (it.isPresent) { + onQrCodeScanned(it.get()) + } else { + _state.value = _state.value.copy( + qrScanResult = QrScanResult.QrNotFound, + indeterminateProgress = false + ) + } + } + } + private fun generateQrCodeData(url: Optional): Single> { return Single.fromCallable { url.map { QrCodeData.forData(it, 64) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameQrImageSelectionActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameQrImageSelectionActivity.kt new file mode 100644 index 0000000000..773d957e65 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameQrImageSelectionActivity.kt @@ -0,0 +1,64 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.usernamelinks.main + +import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.WindowManager +import androidx.activity.result.contract.ActivityResultContract +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.app.AppCompatDelegate +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.mediasend.Media +import org.thoughtcrime.securesms.mediasend.v2.gallery.MediaGalleryFragment + +/** + * Select username qr code from gallery instead of using camera. + */ +class UsernameQrImageSelectionActivity : AppCompatActivity(), MediaGalleryFragment.Callbacks { + + override fun attachBaseContext(newBase: Context) { + delegate.localNightMode = AppCompatDelegate.MODE_NIGHT_YES + super.attachBaseContext(newBase) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + window.addFlags(WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS or WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN) + setContentView(R.layout.username_qr_image_selection_activity) + } + + @SuppressLint("LogTagInlined") + override fun onMediaSelected(media: Media) { + setResult(RESULT_OK, Intent().setData(media.uri)) + finish() + } + + override fun onToolbarNavigationClicked() { + setResult(RESULT_CANCELED) + finish() + } + + override fun isCameraEnabled() = false + override fun isMultiselectEnabled() = false + + class Contract : ActivityResultContract() { + override fun createIntent(context: Context, input: Unit): Intent { + return Intent(context, UsernameQrImageSelectionActivity::class.java) + } + + override fun parseResult(resultCode: Int, intent: Intent?): Uri? { + return if (resultCode == RESULT_OK) { + intent?.data + } else { + null + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameQrScanScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameQrScanScreen.kt index 42052a36e6..d5939441e9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameQrScanScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameQrScanScreen.kt @@ -1,11 +1,15 @@ package org.thoughtcrime.securesms.components.settings.app.usernamelinks.main +import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -15,19 +19,26 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.PathEffect import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.app.TaskStackBuilder import androidx.lifecycle.LifecycleOwner +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.MultiplePermissionsState import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.kotlin.plusAssign import org.signal.core.ui.Dialogs +import org.signal.core.ui.theme.SignalTheme import org.signal.qr.QrScannerView +import org.thoughtcrime.securesms.MainActivity import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.mediasend.camerax.CameraXModelBlocklist import org.thoughtcrime.securesms.util.CommunicationActions @@ -36,36 +47,51 @@ import java.util.concurrent.TimeUnit /** * A screen that allows you to scan a QR code to start a chat. */ +@OptIn(ExperimentalPermissionsApi::class) @Composable fun UsernameQrScanScreen( lifecycleOwner: LifecycleOwner, disposables: CompositeDisposable, + galleryPermissionState: MultiplePermissionsState, qrScanResult: QrScanResult?, onQrCodeScanned: (String) -> Unit, onQrResultHandled: () -> Unit, + onOpenGalleryClicked: () -> Unit, modifier: Modifier = Modifier ) { val path = remember { Path() } when (qrScanResult) { QrScanResult.InvalidData -> { - QrScanResultDialog(stringResource(R.string.UsernameLinkSettings_qr_result_invalid), onDismiss = onQrResultHandled) + QrScanResultDialog(message = stringResource(R.string.UsernameLinkSettings_qr_result_invalid), onDismiss = onQrResultHandled) } QrScanResult.NetworkError -> { - QrScanResultDialog(stringResource(R.string.UsernameLinkSettings_qr_result_network_error), onDismiss = onQrResultHandled) + QrScanResultDialog(message = stringResource(R.string.UsernameLinkSettings_qr_result_network_error), onDismiss = onQrResultHandled) + } + + QrScanResult.QrNotFound -> { + QrScanResultDialog( + title = stringResource(R.string.UsernameLinkSettings_qr_code_not_found), + message = stringResource(R.string.UsernameLinkSettings_try_scanning_another_image_containing_a_signal_qr_code), + onDismiss = onQrResultHandled + ) } is QrScanResult.NotFound -> { if (qrScanResult.username != null) { - QrScanResultDialog(stringResource(R.string.UsernameLinkSettings_qr_result_not_found, qrScanResult.username), onDismiss = onQrResultHandled) + QrScanResultDialog(message = stringResource(R.string.UsernameLinkSettings_qr_result_not_found, qrScanResult.username), onDismiss = onQrResultHandled) } else { - QrScanResultDialog(stringResource(R.string.UsernameLinkSettings_qr_result_not_found_no_username), onDismiss = onQrResultHandled) + QrScanResultDialog(message = stringResource(R.string.UsernameLinkSettings_qr_result_not_found_no_username), onDismiss = onQrResultHandled) } } is QrScanResult.Success -> { - CommunicationActions.startConversation(LocalContext.current, qrScanResult.recipient, null) + val taskStack = TaskStackBuilder + .create(LocalContext.current) + .addNextIntent(MainActivity.clearTop(LocalContext.current)) + + CommunicationActions.startConversation(LocalContext.current, qrScanResult.recipient, null, taskStack) onQrResultHandled() } @@ -77,25 +103,52 @@ fun UsernameQrScanScreen( .fillMaxWidth() .fillMaxHeight() ) { - AndroidView( - factory = { context -> - val view = QrScannerView(context) - disposables += view.qrData.throttleFirst(3000, TimeUnit.MILLISECONDS).subscribe { data -> - onQrCodeScanned(data) - } - view - }, - update = { view -> - view.start(lifecycleOwner = lifecycleOwner, forceLegacy = CameraXModelBlocklist.isBlocklisted()) - }, + Box( modifier = Modifier .fillMaxWidth() .weight(1f, true) - .drawWithContent { - drawContent() - drawQrCrosshair(path) + ) { + AndroidView( + factory = { context -> + val view = QrScannerView(context) + disposables += view.qrData.throttleFirst(3000, TimeUnit.MILLISECONDS).subscribe { data -> + onQrCodeScanned(data) + } + view + }, + update = { view -> + view.start(lifecycleOwner = lifecycleOwner, forceLegacy = CameraXModelBlocklist.isBlocklisted()) + }, + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + .drawWithContent { + drawContent() + drawQrCrosshair(path) + } + ) + + FloatingActionButton( + shape = CircleShape, + containerColor = SignalTheme.colors.colorSurface1, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 24.dp), + onClick = { + if (galleryPermissionState.allPermissionsGranted) { + onOpenGalleryClicked() + } else { + galleryPermissionState.launchMultiplePermissionRequest() + } } - ) + ) { + Image( + painter = painterResource(id = R.drawable.symbol_album_24), + contentDescription = null, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurface) + ) + } + } Row( modifier = Modifier @@ -114,8 +167,9 @@ fun UsernameQrScanScreen( } @Composable -private fun QrScanResultDialog(message: String, onDismiss: () -> Unit) { +private fun QrScanResultDialog(title: String? = null, message: String, onDismiss: () -> Unit) { Dialogs.SimpleMessageDialog( + title = title, message = message, dismiss = stringResource(id = android.R.string.ok), onDismiss = onDismiss diff --git a/app/src/main/res/drawable/symbol_album_24.xml b/app/src/main/res/drawable/symbol_album_24.xml new file mode 100644 index 0000000000..2b4f7b027a --- /dev/null +++ b/app/src/main/res/drawable/symbol_album_24.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/layout/username_qr_image_selection_activity.xml b/app/src/main/res/layout/username_qr_image_selection_activity.xml new file mode 100644 index 0000000000..e584e495fb --- /dev/null +++ b/app/src/main/res/layout/username_qr_image_selection_activity.xml @@ -0,0 +1,8 @@ + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 22eff6b0e2..591b46b9a7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -6526,6 +6526,10 @@ Your QR code and link have been reset and a new QR code and link has been created. Scan this QR code with your phone to chat with me on Signal. + + QR code not found + + Try scanning another image containing a Signal QR code. Anyone with this link can view your username and start a chat with you. Only share it with people you trust. diff --git a/qr/lib/src/main/java/org/signal/qr/QrProcessor.kt b/qr/lib/src/main/java/org/signal/qr/QrProcessor.kt index eb58325237..7bb3edec35 100644 --- a/qr/lib/src/main/java/org/signal/qr/QrProcessor.kt +++ b/qr/lib/src/main/java/org/signal/qr/QrProcessor.kt @@ -1,5 +1,6 @@ package org.signal.qr +import android.graphics.Bitmap import androidx.camera.core.ImageProxy import com.google.zxing.BinaryBitmap import com.google.zxing.ChecksumException @@ -8,10 +9,12 @@ import com.google.zxing.FormatException import com.google.zxing.LuminanceSource import com.google.zxing.NotFoundException import com.google.zxing.PlanarYUVLuminanceSource +import com.google.zxing.RGBLuminanceSource import com.google.zxing.Result import com.google.zxing.common.HybridBinarizer import com.google.zxing.qrcode.QRCodeReader import org.signal.core.util.logging.Log +import java.nio.IntBuffer /** * Wraps [QRCodeReader] for use from API19 or API21+. @@ -35,6 +38,16 @@ class QrProcessor { return getScannedData(PlanarYUVLuminanceSource(data, width, height, 0, 0, width, height, false)) } + fun getScannedData(bitmap: Bitmap?): String? { + if (bitmap == null) { + return null + } + + val buffer = IntBuffer.allocate((bitmap.byteCount / 4) + 1) + bitmap.copyPixelsToBuffer(buffer) + return getScannedData(RGBLuminanceSource(bitmap.width, bitmap.height, buffer.array())) + } + private fun getScannedData(source: LuminanceSource): String? { try { if (source.width != previousWidth || source.height != previousHeight) {