Detect username QR codes in our camera-first capture flow.

This commit is contained in:
Greyson Parrelli
2024-02-28 10:42:13 -05:00
committed by Alex Hart
parent 3f89acf9bd
commit 6104ef62df
11 changed files with 184 additions and 17 deletions

View File

@@ -3,6 +3,7 @@ package org.thoughtcrime.securesms;
import android.Manifest;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Bundle;
@@ -48,6 +49,8 @@ public class DeviceActivity extends PassphraseRequiredActivity
private static final String TAG = Log.tag(DeviceActivity.class);
private static final String EXTRA_DIRECT_TO_SCANNER = "add";
private final DynamicTheme dynamicTheme = new DynamicNoActionBarTheme();
private final DynamicLanguage dynamicLanguage = new DynamicLanguage();
@@ -56,6 +59,13 @@ public class DeviceActivity extends PassphraseRequiredActivity
private DeviceLinkFragment deviceLinkFragment;
private MenuItem cameraSwitchItem = null;
public static Intent getIntentForScanner(Context context) {
Intent intent = new Intent(context, DeviceActivity.class);
intent.putExtra(EXTRA_DIRECT_TO_SCANNER, true);
return intent;
}
@Override
public void onPreCreate() {
dynamicTheme.onCreate(this);
@@ -79,7 +89,7 @@ public class DeviceActivity extends PassphraseRequiredActivity
this.deviceListFragment.setAddDeviceButtonListener(this);
this.deviceAddFragment.setScanListener(this);
if (getIntent().getBooleanExtra("add", false)) {
if (getIntent().getBooleanExtra(EXTRA_DIRECT_TO_SCANNER, false)) {
initFragment(R.id.fragment_container, deviceAddFragment, dynamicLanguage.getCurrentLocale());
} else {
initFragment(R.id.fragment_container, deviceListFragment, dynamicLanguage.getCurrentLocale());

View File

@@ -26,9 +26,7 @@ public class DeviceProvisioningActivity extends PassphraseRequiredActivity {
.setTitle(getString(R.string.DeviceProvisioningActivity_link_a_signal_device))
.setMessage(getString(R.string.DeviceProvisioningActivity_it_looks_like_youre_trying_to_link_a_signal_device_using_a_3rd_party_scanner))
.setPositiveButton(R.string.DeviceProvisioningActivity_continue, (dialog1, which) -> {
Intent intent = new Intent(DeviceProvisioningActivity.this, DeviceActivity.class);
intent.putExtra("add", true);
startActivity(intent);
startActivity(DeviceActivity.getIntentForScanner(this));
finish();
})
.setNegativeButton(android.R.string.cancel, (dialog12, which) -> {

View File

@@ -181,18 +181,20 @@ public class AvatarSelectionActivity extends AppCompatActivity implements Camera
@Override
public void onMainImageLoaded() {
}
@Override
public void onMainImageFailedToLoad() {
}
@Override
public void restoreState() {
}
@Override
public void onQrCodeFound(@NonNull String data) {
}
public boolean popToRoot() {
final int backStackCount = getSupportFragmentManager().getBackStackEntryCount();
if (backStackCount == 0) {

View File

@@ -22,9 +22,9 @@ public interface CameraFragment {
float PORTRAIT_ASPECT_RATIO = 9 / 16f;
@SuppressLint({ "RestrictedApi", "UnsafeOptInUsageError" })
static Fragment newInstance() {
static Fragment newInstance(boolean qrScanEnabled) {
if (CameraXUtil.isSupported()) {
return CameraXFragment.newInstance();
return CameraXFragment.newInstance(qrScanEnabled);
} else {
return Camera1Fragment.newInstance();
}
@@ -63,6 +63,7 @@ public interface CameraFragment {
void onVideoCaptureError();
void onGalleryClicked();
void onCameraCountButtonClicked();
void onQrCodeFound(@NonNull String data);
@NonNull Flowable<Optional<Media>> getMostRecentMediaItem();
@NonNull MediaConstraints getMediaConstraints();
int getMaxVideoDuration();

View File

@@ -28,9 +28,12 @@ import android.widget.ImageView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.camera.core.CameraSelector;
import androidx.camera.core.ImageAnalysis;
import androidx.camera.core.ImageCapture;
import androidx.camera.core.ImageCaptureException;
import androidx.camera.core.ImageProxy;
import androidx.camera.core.resolutionselector.ResolutionSelector;
import androidx.camera.core.resolutionselector.ResolutionStrategy;
import androidx.camera.view.PreviewView;
import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.constraintlayout.widget.ConstraintSet;
@@ -42,6 +45,7 @@ import com.google.android.material.card.MaterialCardView;
import org.signal.core.util.Stopwatch;
import org.signal.core.util.concurrent.SimpleTask;
import org.signal.core.util.logging.Log;
import org.signal.qr.QrProcessor;
import org.thoughtcrime.securesms.LoggingFragment;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.animation.AnimationCompleteListener;
@@ -61,6 +65,8 @@ import org.thoughtcrime.securesms.video.VideoUtil;
import java.io.FileDescriptor;
import java.io.IOException;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.disposables.Disposable;
@@ -71,8 +77,9 @@ import io.reactivex.rxjava3.disposables.Disposable;
*/
public class CameraXFragment extends LoggingFragment implements CameraFragment {
private static final String TAG = Log.tag(CameraXFragment.class);
private static final String IS_VIDEO_ENABLED = "is_video_enabled";
private static final String TAG = Log.tag(CameraXFragment.class);
private static final String IS_VIDEO_ENABLED = "is_video_enabled";
private static final String IS_QR_SCAN_ENABLED = "is_qr_scan_enabled";
private static final Rational ASPECT_RATIO_16_9 = new Rational(16, 9);
@@ -90,24 +97,30 @@ public class CameraXFragment extends LoggingFragment implements CameraFragment {
private CameraScreenBrightnessController cameraScreenBrightnessController;
private boolean isMediaSelected;
private final Executor qrAnalysisExecutor = Executors.newSingleThreadExecutor();
private final QrProcessor qrProcessor = new QrProcessor();
public static CameraXFragment newInstanceForAvatarCapture() {
CameraXFragment fragment = new CameraXFragment();
Bundle args = new Bundle();
args.putBoolean(IS_VIDEO_ENABLED, false);
args.putBoolean(IS_QR_SCAN_ENABLED, false);
fragment.setArguments(args);
return fragment;
}
public static CameraXFragment newInstance() {
public static CameraXFragment newInstance(boolean qrScanEnabled) {
CameraXFragment fragment = new CameraXFragment();
fragment.setArguments(new Bundle());
Bundle args = new Bundle();
args.putBoolean(IS_QR_SCAN_ENABLED, qrScanEnabled);
fragment.setArguments(args);
return fragment;
}
@Override
public void onAttach(@NonNull Context context) {
super.onAttach(context);
@@ -156,6 +169,26 @@ public class CameraXFragment extends LoggingFragment implements CameraFragment {
onOrientationChanged();
cameraController.bindToLifecycle(() -> Log.d(TAG, "Camera init complete from onViewCreated"));
if (requireArguments().getBoolean(IS_QR_SCAN_ENABLED, false)) {
ImageAnalysis imageAnalysis = new ImageAnalysis.Builder()
.setResolutionSelector(new ResolutionSelector.Builder().setResolutionStrategy(ResolutionStrategy.HIGHEST_AVAILABLE_STRATEGY).build())
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build();
imageAnalysis.setAnalyzer(qrAnalysisExecutor, imageProxy -> {
try {
String data = qrProcessor.getScannedData(imageProxy);
if (data != null) {
controller.onQrCodeFound(data);
}
} finally {
imageProxy.close();
}
});
cameraController.addUseCase(imageAnalysis);
}
view.addOnLayoutChangeListener((v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> {
// Let's assume portrait for now, so 9:16
float aspectRatio = CameraFragment.getAspectRatioForOrientation(Configuration.ORIENTATION_PORTRAIT);

View File

@@ -15,7 +15,6 @@ import androidx.annotation.MainThread
import androidx.annotation.RequiresApi
import androidx.annotation.RequiresPermission
import androidx.camera.core.Camera
import androidx.camera.core.CameraProvider
import androidx.camera.core.CameraSelector
import androidx.camera.core.FocusMeteringAction
import androidx.camera.core.FocusMeteringResult
@@ -72,6 +71,7 @@ class SignalCameraController(val context: Context, val lifecycleOwner: Lifecycle
private val cameraProviderFuture: ListenableFuture<ProcessCameraProvider> = ProcessCameraProvider.getInstance(context)
private val viewPort: ViewPort? = previewView.getViewPort(Surface.ROTATION_0)
private val initializationCompleteListeners: MutableSet<InitializationListener> = mutableSetOf()
private val customUseCases: MutableList<UseCase> = mutableListOf()
private var imageRotation = 0
private var recording: Recording? = null
@@ -129,6 +129,19 @@ class SignalCameraController(val context: Context, val lifecycleOwner: Lifecycle
}
}
@MainThread
fun addUseCase(useCase: UseCase) {
ThreadUtil.assertMainThread()
customUseCases += useCase
if (isRecording()) {
stopRecording()
}
tryToBindCamera()
}
@MainThread
fun takePicture(executor: Executor, callback: ImageCapture.OnImageCapturedCallback) {
ThreadUtil.assertMainThread()
@@ -285,7 +298,7 @@ class SignalCameraController(val context: Context, val lifecycleOwner: Lifecycle
}
@MainThread
private fun tryToBindCamera(restoreStateRunnable: (() -> Unit)?) {
private fun tryToBindCamera(restoreStateRunnable: (() -> Unit)? = null) {
ThreadUtil.assertMainThread()
try {
bindToLifecycleInternal()
@@ -353,6 +366,11 @@ class SignalCameraController(val context: Context, val lifecycleOwner: Lifecycle
} else {
cameraProvider.unbind(videoCaptureUseCase)
}
for (useCase in customUseCases) {
addUseCase(useCase)
}
if (viewPort != null) {
setViewPort(viewPort)
} else {
@@ -425,6 +443,6 @@ class SignalCameraController(val context: Context, val lifecycleOwner: Lifecycle
}
interface InitializationListener {
fun onInitialized(cameraProvider: CameraProvider)
fun onInitialized(cameraProvider: ProcessCameraProvider)
}
}

View File

@@ -1,8 +1,11 @@
package org.thoughtcrime.securesms.mediasend.v2.capture
import org.thoughtcrime.securesms.mediasend.Media
import org.thoughtcrime.securesms.recipients.Recipient
sealed class MediaCaptureEvent {
data class MediaCaptureRendered(val media: Media) : MediaCaptureEvent()
data class UsernameScannedFromQrCode(val recipient: Recipient, val username: String) : MediaCaptureEvent()
object DeviceLinkScannedFromQrCode : MediaCaptureEvent()
object MediaCaptureRenderFailed : MediaCaptureEvent()
}

View File

@@ -8,9 +8,11 @@ import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import app.cash.exhaustive.Exhaustive
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import io.reactivex.rxjava3.core.Flowable
import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.DeviceActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.mediasend.CameraFragment
import org.thoughtcrime.securesms.mediasend.Media
@@ -21,6 +23,7 @@ import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionViewModel
import org.thoughtcrime.securesms.mms.MediaConstraints
import org.thoughtcrime.securesms.permissions.Permissions
import org.thoughtcrime.securesms.stories.Stories
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import java.io.FileDescriptor
import java.util.Optional
@@ -47,7 +50,7 @@ class MediaCaptureFragment : Fragment(R.layout.fragment_container), CameraFragme
private val lifecycleDisposable = LifecycleDisposable()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
captureChildFragment = CameraFragment.newInstance() as CameraFragment
captureChildFragment = CameraFragment.newInstance(sharedViewModel.isContactSelectionRequired) as CameraFragment
navigator = MediaSelectionNavigator(
toGallery = R.id.action_mediaCaptureFragment_to_mediaGalleryFragment
@@ -74,6 +77,28 @@ class MediaCaptureFragment : Fragment(R.layout.fragment_container), CameraFragme
navigator.goToReview(findNavController())
}
is MediaCaptureEvent.UsernameScannedFromQrCode -> {
MaterialAlertDialogBuilder(requireContext())
.setTitle(getString(R.string.MediaCaptureFragment_username_dialog_title, event.username))
.setMessage(getString(R.string.MediaCaptureFragment_username_dialog_body, event.username))
.setPositiveButton(R.string.MediaCaptureFragment_username_dialog_go_to_chat_button) { d, _ ->
CommunicationActions.startConversation(requireContext(), event.recipient, "")
requireActivity().finish()
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
is MediaCaptureEvent.DeviceLinkScannedFromQrCode -> {
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.MediaCaptureFragment_device_link_dialog_title)
.setMessage(R.string.MediaCaptureFragment_device_link_dialog_body)
.setPositiveButton(R.string.MediaCaptureFragment_device_link_dialog_continue) { d, _ ->
startActivity(DeviceActivity.getIntentForScanner(requireContext()))
requireActivity().finish()
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
}
}
@@ -149,6 +174,10 @@ class MediaCaptureFragment : Fragment(R.layout.fragment_container), CameraFragme
}
}
override fun onQrCodeFound(data: String) {
viewModel.onQrCodeFound(data)
}
override fun getMostRecentMediaItem(): Flowable<Optional<Media>> {
return viewModel.getMostRecentMedia()
}

View File

@@ -5,20 +5,34 @@ import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.schedulers.Schedulers
import io.reactivex.rxjava3.subjects.PublishSubject
import io.reactivex.rxjava3.subjects.Subject
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.main.QrScanResult
import org.thoughtcrime.securesms.mediasend.Media
import org.thoughtcrime.securesms.profiles.manage.UsernameRepository
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.rx.RxStore
import java.io.FileDescriptor
import java.util.Optional
import java.util.concurrent.TimeUnit
class MediaCaptureViewModel(private val repository: MediaCaptureRepository) : ViewModel() {
companion object {
private val TAG = Log.tag(MediaCaptureViewModel::class.java)
}
private val store: RxStore<MediaCaptureState> = RxStore(MediaCaptureState())
private val internalEvents: Subject<MediaCaptureEvent> = PublishSubject.create()
private val qrData: Subject<String> = PublishSubject.create()
val events: Observable<MediaCaptureEvent> = internalEvents.observeOn(AndroidSchedulers.mainThread())
val disposables = CompositeDisposable()
init {
repository.getMostRecentItem { media ->
@@ -26,10 +40,42 @@ class MediaCaptureViewModel(private val repository: MediaCaptureRepository) : Vi
state.copy(mostRecentMedia = media)
}
}
disposables += qrData
.throttleFirst(5, TimeUnit.SECONDS)
.filter { UsernameRepository.isValidLink(it) }
.subscribeOn(Schedulers.io())
.flatMapSingle { url ->
UsernameRepository.fetchUsernameAndAciFromLink(url)
.map { result ->
when (result) {
is UsernameRepository.UsernameLinkConversionResult.Success -> QrScanResult.Success(result.username.toString(), Recipient.externalUsername(result.aci, result.username.toString()))
is UsernameRepository.UsernameLinkConversionResult.Invalid,
is UsernameRepository.UsernameLinkConversionResult.NotFound,
is UsernameRepository.UsernameLinkConversionResult.NetworkError -> QrScanResult.Failure
}
}
}
.observeOn(AndroidSchedulers.mainThread())
.subscribe { data ->
if (data is QrScanResult.Success) {
internalEvents.onNext(MediaCaptureEvent.UsernameScannedFromQrCode(data.recipient, data.username))
} else {
Log.w(TAG, "Failed to scan QR code.")
}
}
disposables += qrData
.throttleFirst(5, TimeUnit.SECONDS)
.filter { it.startsWith("sgnl://linkdevice") }
.subscribe { data ->
internalEvents.onNext(MediaCaptureEvent.DeviceLinkScannedFromQrCode)
}
}
override fun onCleared() {
store.dispose()
disposables.dispose()
}
fun onImageCaptured(data: ByteArray, width: Int, height: Int) {
@@ -44,6 +90,10 @@ class MediaCaptureViewModel(private val repository: MediaCaptureRepository) : Vi
return store.stateFlowable.map { Optional.ofNullable(it.mostRecentMedia) }
}
fun onQrCodeFound(data: String) {
qrData.onNext(data)
}
private fun onMediaRendered(media: Media) {
internalEvents.onNext(MediaCaptureEvent.MediaCaptureRendered(media))
}
@@ -52,6 +102,11 @@ class MediaCaptureViewModel(private val repository: MediaCaptureRepository) : Vi
internalEvents.onNext(MediaCaptureEvent.MediaCaptureRenderFailed)
}
private sealed class QrScanResult {
data class Success(val username: String, val recipient: Recipient) : QrScanResult()
object Failure : QrScanResult()
}
class Factory(private val repository: MediaCaptureRepository) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return requireNotNull(modelClass.cast(MediaCaptureViewModel(repository)))

View File

@@ -311,6 +311,10 @@ object UsernameRepository {
return BASE_URL + base64
}
fun isValidLink(url: String): Boolean {
return parseLink(url) != null
}
@JvmStatic
fun onUsernameConsistencyValidated() {
SignalStore.account().usernameSyncState = AccountValues.UsernameSyncState.IN_SYNC

View File

@@ -4906,6 +4906,20 @@
<string name="MediaReviewImagePageFragment__discard_changes">Discard changes?</string>
<string name="MediaReviewImagePageFragment__youll_lose_any_changes">You\'ll lose any changes you\'ve made to this photo.</string>
<!-- The title of a dialog notifying that a user was found matching a scanned QR code. The placeholder is a username. Usernames are always latin characters. -->
<string name="MediaCaptureFragment_username_dialog_title">Found %1$s</string>
<!-- The body of a dialog notifying that a user was found matching a scanned QR code, prompting the user to start a chat with them. The placeholder is a username. Usernames are always latin characters. -->
<string name="MediaCaptureFragment_username_dialog_body">Start a chat with \"%1$s\"</string>
<!-- The label of a dialog asking the user if they would like to start a chat with a specific user. -->
<string name="MediaCaptureFragment_username_dialog_go_to_chat_button">Go to chat</string>
<!-- The title of a dialog notifying that the user scanned a QR code that could be used to link a Signal device. -->
<string name="MediaCaptureFragment_device_link_dialog_title">Link device?</string>
<!-- The body of a dialog notifying that the user scanned a QR code that could be used to link a Signal device. -->
<string name="MediaCaptureFragment_device_link_dialog_body">It looks like you\'re trying to link a Signal device. Tap continue and scan the code again to link it.</string>
<!-- The label of a dialog asking the user if they would like to continue to the linked device settings screen. -->
<string name="MediaCaptureFragment_device_link_dialog_continue">Continue</string>
<string name="BadgesOverviewFragment__my_badges">My badges</string>
<string name="BadgesOverviewFragment__featured_badge">Featured badge</string>