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.Manifest;
import android.annotation.SuppressLint; import android.annotation.SuppressLint;
import android.content.Context; import android.content.Context;
import android.content.Intent;
import android.net.Uri; import android.net.Uri;
import android.os.AsyncTask; import android.os.AsyncTask;
import android.os.Bundle; 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 TAG = Log.tag(DeviceActivity.class);
private static final String EXTRA_DIRECT_TO_SCANNER = "add";
private final DynamicTheme dynamicTheme = new DynamicNoActionBarTheme(); private final DynamicTheme dynamicTheme = new DynamicNoActionBarTheme();
private final DynamicLanguage dynamicLanguage = new DynamicLanguage(); private final DynamicLanguage dynamicLanguage = new DynamicLanguage();
@@ -56,6 +59,13 @@ public class DeviceActivity extends PassphraseRequiredActivity
private DeviceLinkFragment deviceLinkFragment; private DeviceLinkFragment deviceLinkFragment;
private MenuItem cameraSwitchItem = null; 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 @Override
public void onPreCreate() { public void onPreCreate() {
dynamicTheme.onCreate(this); dynamicTheme.onCreate(this);
@@ -79,7 +89,7 @@ public class DeviceActivity extends PassphraseRequiredActivity
this.deviceListFragment.setAddDeviceButtonListener(this); this.deviceListFragment.setAddDeviceButtonListener(this);
this.deviceAddFragment.setScanListener(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()); initFragment(R.id.fragment_container, deviceAddFragment, dynamicLanguage.getCurrentLocale());
} else { } else {
initFragment(R.id.fragment_container, deviceListFragment, dynamicLanguage.getCurrentLocale()); 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)) .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)) .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) -> { .setPositiveButton(R.string.DeviceProvisioningActivity_continue, (dialog1, which) -> {
Intent intent = new Intent(DeviceProvisioningActivity.this, DeviceActivity.class); startActivity(DeviceActivity.getIntentForScanner(this));
intent.putExtra("add", true);
startActivity(intent);
finish(); finish();
}) })
.setNegativeButton(android.R.string.cancel, (dialog12, which) -> { .setNegativeButton(android.R.string.cancel, (dialog12, which) -> {

View File

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

View File

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

View File

@@ -28,9 +28,12 @@ import android.widget.ImageView;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.camera.core.CameraSelector; import androidx.camera.core.CameraSelector;
import androidx.camera.core.ImageAnalysis;
import androidx.camera.core.ImageCapture; import androidx.camera.core.ImageCapture;
import androidx.camera.core.ImageCaptureException; import androidx.camera.core.ImageCaptureException;
import androidx.camera.core.ImageProxy; import androidx.camera.core.ImageProxy;
import androidx.camera.core.resolutionselector.ResolutionSelector;
import androidx.camera.core.resolutionselector.ResolutionStrategy;
import androidx.camera.view.PreviewView; import androidx.camera.view.PreviewView;
import androidx.constraintlayout.widget.ConstraintLayout; import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.constraintlayout.widget.ConstraintSet; 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.Stopwatch;
import org.signal.core.util.concurrent.SimpleTask; import org.signal.core.util.concurrent.SimpleTask;
import org.signal.core.util.logging.Log; import org.signal.core.util.logging.Log;
import org.signal.qr.QrProcessor;
import org.thoughtcrime.securesms.LoggingFragment; import org.thoughtcrime.securesms.LoggingFragment;
import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.animation.AnimationCompleteListener; import org.thoughtcrime.securesms.animation.AnimationCompleteListener;
@@ -61,6 +65,8 @@ import org.thoughtcrime.securesms.video.VideoUtil;
import java.io.FileDescriptor; import java.io.FileDescriptor;
import java.io.IOException; 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.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.disposables.Disposable; import io.reactivex.rxjava3.disposables.Disposable;
@@ -73,6 +79,7 @@ public class CameraXFragment extends LoggingFragment implements CameraFragment {
private static final String TAG = Log.tag(CameraXFragment.class); private static final String TAG = Log.tag(CameraXFragment.class);
private static final String IS_VIDEO_ENABLED = "is_video_enabled"; 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); 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 CameraScreenBrightnessController cameraScreenBrightnessController;
private boolean isMediaSelected; private boolean isMediaSelected;
private final Executor qrAnalysisExecutor = Executors.newSingleThreadExecutor();
private final QrProcessor qrProcessor = new QrProcessor();
public static CameraXFragment newInstanceForAvatarCapture() { public static CameraXFragment newInstanceForAvatarCapture() {
CameraXFragment fragment = new CameraXFragment(); CameraXFragment fragment = new CameraXFragment();
Bundle args = new Bundle(); Bundle args = new Bundle();
args.putBoolean(IS_VIDEO_ENABLED, false); args.putBoolean(IS_VIDEO_ENABLED, false);
args.putBoolean(IS_QR_SCAN_ENABLED, false);
fragment.setArguments(args); fragment.setArguments(args);
return fragment; return fragment;
} }
public static CameraXFragment newInstance() { public static CameraXFragment newInstance(boolean qrScanEnabled) {
CameraXFragment fragment = new CameraXFragment(); CameraXFragment fragment = new CameraXFragment();
fragment.setArguments(new Bundle()); Bundle args = new Bundle();
args.putBoolean(IS_QR_SCAN_ENABLED, qrScanEnabled);
fragment.setArguments(args);
return fragment; return fragment;
} }
@Override @Override
public void onAttach(@NonNull Context context) { public void onAttach(@NonNull Context context) {
super.onAttach(context); super.onAttach(context);
@@ -156,6 +169,26 @@ public class CameraXFragment extends LoggingFragment implements CameraFragment {
onOrientationChanged(); onOrientationChanged();
cameraController.bindToLifecycle(() -> Log.d(TAG, "Camera init complete from onViewCreated")); 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) -> { view.addOnLayoutChangeListener((v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> {
// Let's assume portrait for now, so 9:16 // Let's assume portrait for now, so 9:16
float aspectRatio = CameraFragment.getAspectRatioForOrientation(Configuration.ORIENTATION_PORTRAIT); float aspectRatio = CameraFragment.getAspectRatioForOrientation(Configuration.ORIENTATION_PORTRAIT);

View File

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

View File

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

View File

@@ -8,9 +8,11 @@ import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import app.cash.exhaustive.Exhaustive import app.cash.exhaustive.Exhaustive
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import io.reactivex.rxjava3.core.Flowable import io.reactivex.rxjava3.core.Flowable
import org.signal.core.util.concurrent.LifecycleDisposable import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.core.util.logging.Log import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.DeviceActivity
import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.mediasend.CameraFragment import org.thoughtcrime.securesms.mediasend.CameraFragment
import org.thoughtcrime.securesms.mediasend.Media 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.mms.MediaConstraints
import org.thoughtcrime.securesms.permissions.Permissions import org.thoughtcrime.securesms.permissions.Permissions
import org.thoughtcrime.securesms.stories.Stories import org.thoughtcrime.securesms.stories.Stories
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.navigation.safeNavigate import org.thoughtcrime.securesms.util.navigation.safeNavigate
import java.io.FileDescriptor import java.io.FileDescriptor
import java.util.Optional import java.util.Optional
@@ -47,7 +50,7 @@ class MediaCaptureFragment : Fragment(R.layout.fragment_container), CameraFragme
private val lifecycleDisposable = LifecycleDisposable() private val lifecycleDisposable = LifecycleDisposable()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
captureChildFragment = CameraFragment.newInstance() as CameraFragment captureChildFragment = CameraFragment.newInstance(sharedViewModel.isContactSelectionRequired) as CameraFragment
navigator = MediaSelectionNavigator( navigator = MediaSelectionNavigator(
toGallery = R.id.action_mediaCaptureFragment_to_mediaGalleryFragment toGallery = R.id.action_mediaCaptureFragment_to_mediaGalleryFragment
@@ -74,6 +77,28 @@ class MediaCaptureFragment : Fragment(R.layout.fragment_container), CameraFragme
navigator.goToReview(findNavController()) 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>> { override fun getMostRecentMediaItem(): Flowable<Optional<Media>> {
return viewModel.getMostRecentMedia() return viewModel.getMostRecentMedia()
} }

View File

@@ -5,20 +5,34 @@ import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Flowable import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.core.Observable 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.PublishSubject
import io.reactivex.rxjava3.subjects.Subject 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.mediasend.Media
import org.thoughtcrime.securesms.profiles.manage.UsernameRepository
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.rx.RxStore import org.thoughtcrime.securesms.util.rx.RxStore
import java.io.FileDescriptor import java.io.FileDescriptor
import java.util.Optional import java.util.Optional
import java.util.concurrent.TimeUnit
class MediaCaptureViewModel(private val repository: MediaCaptureRepository) : ViewModel() { 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 store: RxStore<MediaCaptureState> = RxStore(MediaCaptureState())
private val internalEvents: Subject<MediaCaptureEvent> = PublishSubject.create() private val internalEvents: Subject<MediaCaptureEvent> = PublishSubject.create()
private val qrData: Subject<String> = PublishSubject.create()
val events: Observable<MediaCaptureEvent> = internalEvents.observeOn(AndroidSchedulers.mainThread()) val events: Observable<MediaCaptureEvent> = internalEvents.observeOn(AndroidSchedulers.mainThread())
val disposables = CompositeDisposable()
init { init {
repository.getMostRecentItem { media -> repository.getMostRecentItem { media ->
@@ -26,10 +40,42 @@ class MediaCaptureViewModel(private val repository: MediaCaptureRepository) : Vi
state.copy(mostRecentMedia = media) 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() { override fun onCleared() {
store.dispose() store.dispose()
disposables.dispose()
} }
fun onImageCaptured(data: ByteArray, width: Int, height: Int) { 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) } return store.stateFlowable.map { Optional.ofNullable(it.mostRecentMedia) }
} }
fun onQrCodeFound(data: String) {
qrData.onNext(data)
}
private fun onMediaRendered(media: Media) { private fun onMediaRendered(media: Media) {
internalEvents.onNext(MediaCaptureEvent.MediaCaptureRendered(media)) internalEvents.onNext(MediaCaptureEvent.MediaCaptureRendered(media))
} }
@@ -52,6 +102,11 @@ class MediaCaptureViewModel(private val repository: MediaCaptureRepository) : Vi
internalEvents.onNext(MediaCaptureEvent.MediaCaptureRenderFailed) 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 { class Factory(private val repository: MediaCaptureRepository) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T { override fun <T : ViewModel> create(modelClass: Class<T>): T {
return requireNotNull(modelClass.cast(MediaCaptureViewModel(repository))) return requireNotNull(modelClass.cast(MediaCaptureViewModel(repository)))

View File

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

View File

@@ -4906,6 +4906,20 @@
<string name="MediaReviewImagePageFragment__discard_changes">Discard changes?</string> <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> <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__my_badges">My badges</string>
<string name="BadgesOverviewFragment__featured_badge">Featured badge</string> <string name="BadgesOverviewFragment__featured_badge">Featured badge</string>