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) {