mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-02-24 19:56:00 +00:00
Add ability to scan username qr from gallery.
This commit is contained in:
committed by
Alex Hart
parent
6104ef62df
commit
86afa988a0
@@ -13,4 +13,6 @@ sealed class QrScanResult {
|
||||
object InvalidData : QrScanResult()
|
||||
|
||||
object NetworkError : QrScanResult()
|
||||
|
||||
object QrNotFound : QrScanResult()
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user