diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7911227fc4..05e5fc1a30 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1089,6 +1089,11 @@ android:theme="@style/TextSecure.DarkNoActionBar" android:exported="false"/> + + viewModel.onQrCodeScanned(data) }, onQrResultHandled = { viewModel.onQrResultHandled() }, - onOpenGalleryClicked = { galleryLauncher.launch(Unit) }, + onOpenGalleryClicked = { + if (galleryPermissionState.allPermissionsGranted) { + galleryLauncher.launch(Unit) + } else { + galleryPermissionState.launchMultiplePermissionRequest() + } + }, onLinkReset = { viewModel.onUsernameLinkReset() }, onBackNavigationPressed = { requireActivity().onBackPressed() }, linkCopiedEvent = linkCopiedEvent @@ -162,7 +171,6 @@ private fun MainScreen( lifecycleOwner: LifecycleOwner = previewLifecycleOwner, disposables: CompositeDisposable = CompositeDisposable(), cameraPermissionState: PermissionState = previewPermissionState(), - galleryPermissionState: MultiplePermissionsState = previewMultiplePermissionState(), onCodeTabSelected: () -> Unit = {}, onScanTabSelected: () -> Unit = {}, onUsernameLinkResetResultHandled: () -> Unit = {}, @@ -174,6 +182,8 @@ private fun MainScreen( onBackNavigationPressed: () -> Unit = {}, linkCopiedEvent: UUID? = null ) { + val context = LocalContext.current + val snackbarHostState: SnackbarHostState = remember { SnackbarHostState() } val scope: CoroutineScope = rememberCoroutineScope() var showResetDialog: Boolean by remember { mutableStateOf(false) } @@ -231,12 +241,18 @@ private fun MainScreen( UsernameQrScanScreen( lifecycleOwner = lifecycleOwner, disposables = disposables, - galleryPermissionState = galleryPermissionState, qrScanResult = state.qrScanResult, onQrCodeScanned = onQrCodeScanned, onQrResultHandled = onQrResultHandled, onOpenGalleryClicked = onOpenGalleryClicked, - modifier = Modifier.padding(contentPadding) + modifier = Modifier.padding(contentPadding), + onRecipientFound = { recipient -> + val taskStack = TaskStackBuilder + .create(context) + .addNextIntent(MainActivity.clearTop(context)) + + CommunicationActions.startConversation(context, recipient, null, taskStack) + } ) } } @@ -387,16 +403,6 @@ 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 d8d852814c..1630ee32a8 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 @@ -27,8 +27,6 @@ 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 @@ -37,8 +35,6 @@ 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 @@ -48,7 +44,6 @@ import org.thoughtcrime.securesms.dependencies.ApplicationDependencies import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.profiles.manage.UsernameRepository import org.thoughtcrime.securesms.profiles.manage.UsernameRepository.toLink -import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.util.NetworkUtil import org.whispersystems.signalservice.api.push.UsernameLinkComponents import java.util.Optional @@ -171,16 +166,7 @@ class UsernameLinkSettingsViewModel : ViewModel() { indeterminateProgress = true ) - disposable += UsernameRepository.fetchUsernameAndAciFromLink(url) - .map { result -> - when (result) { - is UsernameRepository.UsernameLinkConversionResult.Success -> QrScanResult.Success(Recipient.externalUsername(result.aci, result.username.toString())) - is UsernameRepository.UsernameLinkConversionResult.Invalid -> QrScanResult.InvalidData - is UsernameRepository.UsernameLinkConversionResult.NotFound -> QrScanResult.NotFound(result.username?.toString()) - is UsernameRepository.UsernameLinkConversionResult.NetworkError -> QrScanResult.NetworkError - } - } - .subscribeOn(Schedulers.io()) + disposable += UsernameQrScanRepository.lookupUsernameUrl(url) .observeOn(AndroidSchedulers.mainThread()) .subscribe { result -> _state.value = _state.value.copy( @@ -201,25 +187,17 @@ class UsernameLinkSettingsViewModel : ViewModel() { } fun scanImage(context: Context, uri: Uri) { - val loadBitmap = Glide.with(context) - .asBitmap() - .format(DecodeFormat.PREFER_ARGB_8888) - .load(uri) - .submit() + _state.value = _state.value.copy( + indeterminateProgress = true + ) - disposable += Single.fromFuture(loadBitmap) - .subscribeOn(Schedulers.io()) - .map { QrProcessor().getScannedData(it).toOptional() } + disposable += UsernameQrScanRepository.scanImageUriForQrCode(context, uri) .observeOn(AndroidSchedulers.mainThread()) - .subscribeBy { - if (it.isPresent) { - onQrCodeScanned(it.get()) - } else { - _state.value = _state.value.copy( - qrScanResult = QrScanResult.QrNotFound, - indeterminateProgress = false - ) - } + .subscribeBy { result -> + _state.value = _state.value.copy( + qrScanResult = result, + indeterminateProgress = false + ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameQrScanRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameQrScanRepository.kt new file mode 100644 index 0000000000..a71a72f382 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameQrScanRepository.kt @@ -0,0 +1,62 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.usernamelinks.main + +import android.content.Context +import android.net.Uri +import com.bumptech.glide.Glide +import com.bumptech.glide.load.DecodeFormat +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.kotlin.plusAssign +import io.reactivex.rxjava3.schedulers.Schedulers +import org.signal.core.util.toOptional +import org.signal.qr.QrProcessor +import org.thoughtcrime.securesms.profiles.manage.UsernameRepository +import org.thoughtcrime.securesms.recipients.Recipient + +/** + * A collection of functions to help with scanning QR codes for usernames. + */ +object UsernameQrScanRepository { + + /** + * Given a URL, will attempt to lookup the username, coercing it to a standard set of [QrScanResult]s. + */ + fun lookupUsernameUrl(url: String): Single { + return UsernameRepository.fetchUsernameAndAciFromLink(url) + .map { result -> + when (result) { + is UsernameRepository.UsernameLinkConversionResult.Success -> QrScanResult.Success(Recipient.externalUsername(result.aci, result.username.toString())) + is UsernameRepository.UsernameLinkConversionResult.Invalid -> QrScanResult.InvalidData + is UsernameRepository.UsernameLinkConversionResult.NotFound -> QrScanResult.NotFound(result.username?.toString()) + is UsernameRepository.UsernameLinkConversionResult.NetworkError -> QrScanResult.NetworkError + } + } + .subscribeOn(Schedulers.io()) + } + + /** + * Given a URI pointing to an image that may contain a username QR code, this will attempt to lookup the username, coercing it to a standard set of [QrScanResult]s. + */ + fun scanImageUriForQrCode(context: Context, uri: Uri): Single { + val loadBitmap = Glide.with(context) + .asBitmap() + .format(DecodeFormat.PREFER_ARGB_8888) + .load(uri) + .submit() + + return Single.fromFuture(loadBitmap) + .map { QrProcessor().getScannedData(it).toOptional() } + .flatMap { + if (it.isPresent) { + lookupUsernameUrl(it.get()) + } else { + Single.just(QrScanResult.QrNotFound) + } + } + .subscribeOn(Schedulers.io()) + } +} 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 d5939441e9..16d2e4b1ce 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 @@ -24,39 +24,33 @@ 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 +import org.thoughtcrime.securesms.recipients.Recipient 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, + onRecipientFound: (Recipient) -> Unit, modifier: Modifier = Modifier ) { val path = remember { Path() } @@ -87,12 +81,7 @@ fun UsernameQrScanScreen( } is QrScanResult.Success -> { - val taskStack = TaskStackBuilder - .create(LocalContext.current) - .addNextIntent(MainActivity.clearTop(LocalContext.current)) - - CommunicationActions.startConversation(LocalContext.current, qrScanResult.recipient, null, taskStack) - onQrResultHandled() + onRecipientFound(qrScanResult.recipient) } null -> {} @@ -134,13 +123,7 @@ fun UsernameQrScanScreen( modifier = Modifier .align(Alignment.BottomCenter) .padding(bottom = 24.dp), - onClick = { - if (galleryPermissionState.allPermissionsGranted) { - onOpenGalleryClicked() - } else { - galleryPermissionState.launchMultiplePermissionRequest() - } - } + onClick = onOpenGalleryClicked ) { Image( painter = painterResource(id = R.drawable.symbol_album_24), diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameQrScannerActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameQrScannerActivity.kt new file mode 100644 index 0000000000..ebcc11a95e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameQrScannerActivity.kt @@ -0,0 +1,167 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +@file:OptIn(ExperimentalPermissionsApi::class) + +package org.thoughtcrime.securesms.components.settings.app.usernamelinks.main + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.widget.Toast +import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContract +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.LifecycleOwner +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.MultiplePermissionsState +import com.google.accompanist.permissions.rememberMultiplePermissionsState +import io.reactivex.rxjava3.disposables.CompositeDisposable +import org.signal.core.ui.Dialogs +import org.signal.core.ui.theme.SignalTheme +import org.signal.core.util.concurrent.LifecycleDisposable +import org.signal.core.util.getParcelableExtraCompat +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.permissions.PermissionCompat +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.util.DynamicTheme + +/** + * Prompts the user to scan a username QR code. Uses the activity result to communicate the recipient that was found, or null if no valid usernames were scanned. + * See [Contract]. + */ +class UsernameQrScannerActivity : AppCompatActivity() { + + companion object { + private const val KEY_RECIPIENT_ID = "recipient_id" + } + + private val viewModel: UsernameQrScannerViewModel by viewModels() + private val disposables = LifecycleDisposable() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + disposables.bindTo(this) + + val galleryLauncher = registerForActivityResult(UsernameQrImageSelectionActivity.Contract()) { uri -> + if (uri != null) { + viewModel.onQrImageSelected(this, uri) + } + } + + setContent { + val galleryPermissionState: MultiplePermissionsState = rememberMultiplePermissionsState(permissions = PermissionCompat.forImages().toList()) { grants -> + if (grants.values.all { it }) { + galleryLauncher.launch(Unit) + } else { + Toast.makeText(this, R.string.ChatWallpaperPreviewActivity__viewing_your_gallery_requires_the_storage_permission, Toast.LENGTH_SHORT).show() + } + } + + val state by viewModel.state + + SignalTheme(isDarkMode = DynamicTheme.isDarkTheme(LocalContext.current)) { + Content( + lifecycleOwner = this, + diposables = disposables.disposables, + state = state, + galleryPermissionsState = galleryPermissionState, + onQrScanned = { url -> viewModel.onQrScanned(url) }, + onQrResultHandled = { + finish() + }, + onOpenGalleryClicked = { + if (galleryPermissionState.allPermissionsGranted) { + galleryLauncher.launch(Unit) + } else { + galleryPermissionState.launchMultiplePermissionRequest() + } + }, + onRecipientFound = { recipient -> + val intent = Intent().apply { + putExtra(KEY_RECIPIENT_ID, recipient.id) + } + setResult(RESULT_OK, intent) + finish() + }, + onBackNavigationPressed = { + finish() + } + ) + } + } + } + + class Contract : ActivityResultContract() { + override fun createIntent(context: Context, input: Unit): Intent { + return Intent(context, UsernameQrScannerActivity::class.java) + } + + override fun parseResult(resultCode: Int, intent: Intent?): RecipientId? { + return intent?.getParcelableExtraCompat(KEY_RECIPIENT_ID, RecipientId::class.java) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun Content( + lifecycleOwner: LifecycleOwner, + diposables: CompositeDisposable, + state: UsernameQrScannerViewModel.ScannerState, + galleryPermissionsState: MultiplePermissionsState, + onQrScanned: (String) -> Unit, + onQrResultHandled: () -> Unit, + onOpenGalleryClicked: () -> Unit, + onRecipientFound: (Recipient) -> Unit, + onBackNavigationPressed: () -> Unit +) { + Scaffold( + topBar = { + CenterAlignedTopAppBar( + title = {}, + navigationIcon = { + IconButton( + onClick = onBackNavigationPressed + ) { + Icon( + painter = painterResource(R.drawable.symbol_x_24), + contentDescription = stringResource(android.R.string.cancel) + ) + } + } + ) + } + ) { contentPadding -> + UsernameQrScanScreen( + lifecycleOwner = lifecycleOwner, + disposables = diposables, + qrScanResult = state.qrScanResult, + onQrCodeScanned = onQrScanned, + onQrResultHandled = onQrResultHandled, + onOpenGalleryClicked = onOpenGalleryClicked, + onRecipientFound = onRecipientFound, + modifier = Modifier.padding(contentPadding) + ) + + if (state.indeterminateProgress) { + Dialogs.IndeterminateProgressDialog() + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameQrScannerViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameQrScannerViewModel.kt new file mode 100644 index 0000000000..26d62a6054 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameQrScannerViewModel.kt @@ -0,0 +1,59 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.usernamelinks.main + +import android.content.Context +import android.net.Uri +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.ViewModel +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.kotlin.plusAssign +import io.reactivex.rxjava3.kotlin.subscribeBy + +class UsernameQrScannerViewModel : ViewModel() { + + private val _state = mutableStateOf(ScannerState(qrScanResult = null, indeterminateProgress = false)) + val state: State = _state + + private val disposables = CompositeDisposable() + + fun onQrScanned(url: String) { + _state.value = state.value.copy(indeterminateProgress = true) + + disposables += UsernameQrScanRepository.lookupUsernameUrl(url) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { result -> + _state.value = _state.value.copy( + qrScanResult = result, + indeterminateProgress = false + ) + } + } + + fun onQrImageSelected(context: Context, uri: Uri) { + _state.value = state.value.copy(indeterminateProgress = true) + + disposables += UsernameQrScanRepository.scanImageUriForQrCode(context, uri) + .observeOn(AndroidSchedulers.mainThread()) + .subscribeBy { result -> + _state.value = _state.value.copy( + qrScanResult = result, + indeterminateProgress = false + ) + } + } + + override fun onCleared() { + disposables.clear() + } + + data class ScannerState( + val qrScanResult: QrScanResult?, + val indeterminateProgress: Boolean + ) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/findby/FindByActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/findby/FindByActivity.kt index 817f1601bd..36aa855b3b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/findby/FindByActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/findby/FindByActivity.kt @@ -10,6 +10,7 @@ import android.content.Intent import android.content.res.Configuration import android.os.Bundle import androidx.activity.compose.setContent +import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContract import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally @@ -26,6 +27,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape @@ -77,6 +79,7 @@ import org.signal.core.ui.theme.SignalTheme import org.signal.core.util.getParcelableExtraCompat import org.thoughtcrime.securesms.PassphraseRequiredActivity import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.settings.app.usernamelinks.main.UsernameQrScannerActivity import org.thoughtcrime.securesms.invites.InviteActions import org.thoughtcrime.securesms.phonenumbers.PhoneNumberVisualTransformation import org.thoughtcrime.securesms.recipients.Recipient @@ -101,6 +104,13 @@ class FindByActivity : PassphraseRequiredActivity() { } override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) { + val qrScanLauncher: ActivityResultLauncher = registerForActivityResult(UsernameQrScannerActivity.Contract()) { recipientId -> + if (recipientId != null) { + setResult(RESULT_OK, Intent().putExtra(RECIPIENT_ID, recipientId)) + finishAfterTransition() + } + } + setContent { val state by viewModel.state @@ -145,6 +155,9 @@ class FindByActivity : PassphraseRequiredActivity() { }, onSelectCountryPrefixClick = { navController.navigate("select-country-prefix") + }, + onQrCodeScanClicked = { + qrScanLauncher.launch(Unit) } ) } @@ -273,7 +286,8 @@ private fun Content( state: FindByState, onUserEntryChanged: (String) -> Unit, onNextClick: () -> Unit, - onSelectCountryPrefixClick: () -> Unit + onSelectCountryPrefixClick: () -> Unit, + onQrCodeScanClicked: () -> Unit ) { val placeholderLabel = remember(state.mode) { if (state.mode == FindByMode.PHONE_NUMBER) R.string.FindByActivity__phone_number else R.string.FindByActivity__username @@ -364,6 +378,23 @@ private fun Content( .padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter)) .padding(top = 8.dp) ) + + Spacer(modifier = Modifier.height(32.dp)) + + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + Buttons.Small(onClick = onQrCodeScanClicked) { + Icon(painter = painterResource(id = R.drawable.symbol_qrcode_24), contentDescription = stringResource(id = R.string.FindByActivity__qr_scan_button)) + Spacer(modifier = Modifier.width(10.dp)) + Text( + text = stringResource(id = R.string.FindByActivity__qr_scan_button), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurface + ) + } + } } Spacer(modifier = Modifier.weight(1f)) @@ -541,8 +572,8 @@ private fun CountryPrefixRowItem( } } -@Preview(name = "Light Theme", group = "content", uiMode = Configuration.UI_MODE_NIGHT_NO) -@Preview(name = "Dark Theme", group = "content", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(name = "Light Theme", group = "content - phone", uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Dark Theme", group = "content - phone", uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun ContentPreviewPhoneNumber() { Previews.Preview { @@ -554,13 +585,14 @@ private fun ContentPreviewPhoneNumber() { ), onUserEntryChanged = {}, onNextClick = {}, - onSelectCountryPrefixClick = {} + onSelectCountryPrefixClick = {}, + onQrCodeScanClicked = {} ) } } -@Preview(name = "Light Theme", group = "content", uiMode = Configuration.UI_MODE_NIGHT_NO) -@Preview(name = "Dark Theme", group = "content", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(name = "Light Theme", group = "content - username", uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Dark Theme", group = "content - username", uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun ContentPreviewUsername() { Previews.Preview { @@ -572,7 +604,8 @@ private fun ContentPreviewUsername() { ), onUserEntryChanged = {}, onNextClick = {}, - onSelectCountryPrefixClick = {} + onSelectCountryPrefixClick = {}, + onQrCodeScanClicked = {} ) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5184d7f8f0..3f3c26a620 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -6621,6 +6621,8 @@ %1$s is not a Signal user. Would you like to invite this number? Invite + + Scan QR code