mirror of
https://github.com/signalapp/Signal-Android.git
synced 2025-12-23 12:38:33 +00:00
Detect username QR codes in our camera-first capture flow.
This commit is contained in:
committed by
Alex Hart
parent
3f89acf9bd
commit
6104ef62df
@@ -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());
|
||||
|
||||
@@ -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) -> {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user