Add ability to scan username qr from gallery.

This commit is contained in:
Cody Henthorne
2024-02-28 12:44:17 -05:00
committed by Alex Hart
parent 6104ef62df
commit 86afa988a0
10 changed files with 255 additions and 21 deletions

View File

@@ -13,4 +13,6 @@ sealed class QrScanResult {
object InvalidData : QrScanResult()
object NetworkError : QrScanResult()
object QrNotFound : QrScanResult()
}

View File

@@ -8,6 +8,8 @@ import android.content.res.Configuration
import android.graphics.Bitmap
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
@@ -52,9 +54,11 @@ import androidx.lifecycle.LifecycleOwner
import androidx.navigation.NavController
import androidx.navigation.fragment.findNavController
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.MultiplePermissionsState
import com.google.accompanist.permissions.PermissionState
import com.google.accompanist.permissions.PermissionStatus
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberMultiplePermissionsState
import com.google.accompanist.permissions.rememberPermissionState
import io.reactivex.rxjava3.disposables.CompositeDisposable
import kotlinx.coroutines.CoroutineScope
@@ -69,6 +73,7 @@ import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeSt
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.UsernameQrCodeColorScheme
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.main.UsernameLinkSettingsState.ActiveTab
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.permissions.PermissionCompat
import org.thoughtcrime.securesms.providers.BlobProvider
import java.io.ByteArrayOutputStream
import java.util.UUID
@@ -79,6 +84,18 @@ class UsernameLinkSettingsFragment : ComposeFragment() {
private val viewModel: UsernameLinkSettingsViewModel by viewModels()
private val disposables: LifecycleDisposable = LifecycleDisposable()
private lateinit var galleryLauncher: ActivityResultLauncher<Unit>
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<PermissionState> = emptyList()
override val revokedPermissions: List<PermissionState> = 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")

View File

@@ -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<String>): Single<Optional<QrCodeData>> {
return Single.fromCallable {
url.map { QrCodeData.forData(it, 64) }

View File

@@ -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<Unit, Uri?>() {
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
}
}
}
}

View File

@@ -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