diff --git a/app/src/main/java/org/thoughtcrime/securesms/DeviceActivity.java b/app/src/main/java/org/thoughtcrime/securesms/DeviceActivity.java index b4f6825031..e11a0ecf82 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/DeviceActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/DeviceActivity.java @@ -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()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/DeviceProvisioningActivity.java b/app/src/main/java/org/thoughtcrime/securesms/DeviceProvisioningActivity.java index 02ae868dd5..d48e2d140f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/DeviceProvisioningActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/DeviceProvisioningActivity.java @@ -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) -> { diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/AvatarSelectionActivity.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/AvatarSelectionActivity.java index d8b359d585..3251783233 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/AvatarSelectionActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/AvatarSelectionActivity.java @@ -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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraFragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraFragment.java index b6bc6c4681..aa64a91b2d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraFragment.java @@ -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> getMostRecentMediaItem(); @NonNull MediaConstraints getMediaConstraints(); int getMaxVideoDuration(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXFragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXFragment.java index 7e0aff0688..fc967b156e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXFragment.java @@ -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); diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/SignalCameraController.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/SignalCameraController.kt index 125a95e860..e32058d4aa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/SignalCameraController.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/SignalCameraController.kt @@ -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.getInstance(context) private val viewPort: ViewPort? = previewView.getViewPort(Surface.ROTATION_0) private val initializationCompleteListeners: MutableSet = mutableSetOf() + private val customUseCases: MutableList = 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) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/capture/MediaCaptureEvent.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/capture/MediaCaptureEvent.kt index 00ef65f141..13a93919ef 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/capture/MediaCaptureEvent.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/capture/MediaCaptureEvent.kt @@ -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() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/capture/MediaCaptureFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/capture/MediaCaptureFragment.kt index c6868b375a..ddd3c82ed6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/capture/MediaCaptureFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/capture/MediaCaptureFragment.kt @@ -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> { return viewModel.getMostRecentMedia() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/capture/MediaCaptureViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/capture/MediaCaptureViewModel.kt index 8c29068356..3db6595108 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/capture/MediaCaptureViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/capture/MediaCaptureViewModel.kt @@ -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 = RxStore(MediaCaptureState()) private val internalEvents: Subject = PublishSubject.create() + private val qrData: Subject = PublishSubject.create() val events: Observable = 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 create(modelClass: Class): T { return requireNotNull(modelClass.cast(MediaCaptureViewModel(repository))) diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameRepository.kt index 27dd0d60a5..660e2221b4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameRepository.kt @@ -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 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 37b5810850..22eff6b0e2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -4906,6 +4906,20 @@ Discard changes? You\'ll lose any changes you\'ve made to this photo. + + Found %1$s + + Start a chat with \"%1$s\" + + Go to chat + + + Link device? + + It looks like you\'re trying to link a Signal device. Tap continue and scan the code again to link it. + + Continue + My badges Featured badge