Scan Mailbox QR code for setup and show progress screen

This commit is contained in:
Torsten Grote
2022-02-08 15:04:51 -03:00
parent e14773985d
commit 73d9e05ada
18 changed files with 416 additions and 72 deletions

View File

@@ -44,6 +44,7 @@ import org.briarproject.briar.android.hotspot.ManualHotspotFragment;
import org.briarproject.briar.android.hotspot.QrHotspotFragment;
import org.briarproject.briar.android.logging.CachingLogHandler;
import org.briarproject.briar.android.login.SignInReminderReceiver;
import org.briarproject.briar.android.mailbox.MailboxScanFragment;
import org.briarproject.briar.android.removabledrive.ChooserFragment;
import org.briarproject.briar.android.removabledrive.ReceiveFragment;
import org.briarproject.briar.android.removabledrive.SendFragment;
@@ -239,4 +240,6 @@ public interface AndroidComponent
void inject(ReceiveFragment receiveFragment);
void inject(BluetoothIntroFragment bluetoothIntroFragment);
void inject(MailboxScanFragment mailboxScanFragment);
}

View File

@@ -3,11 +3,10 @@ package org.briarproject.briar.android.contact.add.nearby;
import android.content.Context;
import org.briarproject.briar.R;
import org.briarproject.briar.android.util.Permission;
import java.util.Map;
import androidx.annotation.StringRes;
import androidx.appcompat.app.AlertDialog;
import androidx.core.util.Consumer;
import androidx.fragment.app.FragmentActivity;
@@ -17,16 +16,13 @@ import static android.content.pm.PackageManager.PERMISSION_GRANTED;
import static android.os.Build.VERSION.SDK_INT;
import static androidx.core.app.ActivityCompat.shouldShowRequestPermissionRationale;
import static androidx.core.content.ContextCompat.checkSelfPermission;
import static org.briarproject.briar.android.util.UiUtils.getGoToSettingsListener;
import static org.briarproject.briar.android.util.UiUtils.isLocationEnabled;
import static org.briarproject.briar.android.util.UiUtils.showDenialDialog;
import static org.briarproject.briar.android.util.UiUtils.showLocationDialog;
import static org.briarproject.briar.android.util.UiUtils.showRationale;
class AddNearbyContactPermissionManager {
private enum Permission {
UNKNOWN, GRANTED, SHOW_RATIONALE, PERMANENTLY_DENIED
}
private Permission cameraPermission = Permission.UNKNOWN;
private Permission locationPermission = Permission.UNKNOWN;
@@ -68,27 +64,30 @@ class AddNearbyContactPermissionManager {
// If an essential permission has been permanently denied, ask the
// user to change the setting
if (cameraPermission == Permission.PERMANENTLY_DENIED) {
showDenialDialog(R.string.permission_camera_title,
showDenialDialog(ctx, R.string.permission_camera_title,
R.string.permission_camera_denied_body);
return false;
}
if (isBluetoothSupported &&
locationPermission == Permission.PERMANENTLY_DENIED) {
showDenialDialog(R.string.permission_location_title,
showDenialDialog(ctx, R.string.permission_location_title,
R.string.permission_location_denied_body);
return false;
}
// Should we show the rationale for one or both permissions?
if (cameraPermission == Permission.SHOW_RATIONALE &&
locationPermission == Permission.SHOW_RATIONALE) {
showRationale(R.string.permission_camera_location_title,
R.string.permission_camera_location_request_body);
showRationale(ctx, R.string.permission_camera_location_title,
R.string.permission_camera_location_request_body,
this::requestPermissions);
} else if (cameraPermission == Permission.SHOW_RATIONALE) {
showRationale(R.string.permission_camera_title,
R.string.permission_camera_request_body);
showRationale(ctx, R.string.permission_camera_title,
R.string.permission_camera_request_body,
this::requestPermissions);
} else if (locationPermission == Permission.SHOW_RATIONALE) {
showRationale(R.string.permission_location_title,
R.string.permission_location_request_body);
showRationale(ctx, R.string.permission_location_title,
R.string.permission_location_request_body,
this::requestPermissions);
} else if (locationEnabled) {
requestPermissions();
} else {
@@ -97,27 +96,6 @@ class AddNearbyContactPermissionManager {
return false;
}
private void showDenialDialog(@StringRes int title, @StringRes int body) {
AlertDialog.Builder builder =
new AlertDialog.Builder(ctx, R.style.BriarDialogTheme);
builder.setTitle(title);
builder.setMessage(body);
builder.setPositiveButton(R.string.ok, getGoToSettingsListener(ctx));
builder.setNegativeButton(R.string.cancel,
(dialog, which) -> ctx.supportFinishAfterTransition());
builder.show();
}
private void showRationale(@StringRes int title, @StringRes int body) {
AlertDialog.Builder builder =
new AlertDialog.Builder(ctx, R.style.BriarDialogTheme);
builder.setTitle(title);
builder.setMessage(body);
builder.setNeutralButton(R.string.continue_button,
(dialog, which) -> requestPermissions());
builder.show();
}
private void requestPermissions() {
String[] permissions;
if (isBluetoothSupported) {

View File

@@ -4,6 +4,7 @@ import android.app.Activity;
import android.content.Context;
import org.briarproject.briar.R;
import org.briarproject.briar.android.util.Permission;
import androidx.activity.result.ActivityResultLauncher;
import androidx.annotation.Nullable;
@@ -19,10 +20,6 @@ import static org.briarproject.briar.android.util.UiUtils.showLocationDialog;
class BluetoothConditionManager {
private enum Permission {
UNKNOWN, GRANTED, SHOW_RATIONALE, PERMANENTLY_DENIED
}
private Permission locationPermission = Permission.UNKNOWN;
/**

View File

@@ -20,11 +20,7 @@ import static android.content.Context.WIFI_SERVICE;
*/
abstract class AbstractConditionManager {
enum Permission {
UNKNOWN, GRANTED, SHOW_RATIONALE, PERMANENTLY_DENIED
}
protected final Consumer<Boolean> permissionUpdateCallback;
final Consumer<Boolean> permissionUpdateCallback;
protected FragmentActivity ctx;
WifiManager wifiManager;

View File

@@ -4,6 +4,7 @@ import android.content.Intent;
import android.provider.Settings;
import org.briarproject.briar.R;
import org.briarproject.briar.android.util.Permission;
import java.util.logging.Logger;

View File

@@ -0,0 +1,83 @@
package org.briarproject.briar.android.mailbox;
import android.content.Context;
import org.briarproject.briar.R;
import org.briarproject.briar.android.util.Permission;
import java.util.Map;
import androidx.core.util.Consumer;
import androidx.fragment.app.FragmentActivity;
import static android.Manifest.permission.CAMERA;
import static android.content.pm.PackageManager.PERMISSION_GRANTED;
import static androidx.core.app.ActivityCompat.shouldShowRequestPermissionRationale;
import static androidx.core.content.ContextCompat.checkSelfPermission;
import static org.briarproject.briar.android.util.UiUtils.showDenialDialog;
import static org.briarproject.briar.android.util.UiUtils.showRationale;
class CameraPermissionManager {
private Permission cameraPermission = Permission.UNKNOWN;
private final FragmentActivity ctx;
private final Consumer<String[]> requestPermissions;
CameraPermissionManager(FragmentActivity ctx,
Consumer<String[]> requestPermissions) {
this.ctx = ctx;
this.requestPermissions = requestPermissions;
}
void resetPermissions() {
cameraPermission = Permission.UNKNOWN;
}
private static boolean areEssentialPermissionsGranted(Context ctx) {
return checkSelfPermission(ctx, CAMERA) == PERMISSION_GRANTED;
}
private boolean areEssentialPermissionsGranted() {
return cameraPermission == Permission.GRANTED;
}
boolean checkPermissions() {
if (areEssentialPermissionsGranted()) return true;
// If an essential permission has been permanently denied, ask the
// user to change the setting
if (cameraPermission == Permission.PERMANENTLY_DENIED) {
showDenialDialog(ctx, R.string.permission_camera_title,
R.string.permission_camera_qr_denied_body);
} else if (cameraPermission == Permission.SHOW_RATIONALE) {
showRationale(ctx, R.string.permission_camera_title,
R.string.permission_camera_request_body,
this::requestPermissions);
} else {
requestPermissions();
}
return false;
}
private void requestPermissions() {
String[] permissions = new String[] {CAMERA};
requestPermissions.accept(permissions);
}
void onRequestPermissionResult(Map<String, Boolean> result) {
if (gotPermission(result)) {
cameraPermission = Permission.GRANTED;
} else if (shouldShowRequestPermissionRationale(ctx, CAMERA)) {
cameraPermission = Permission.SHOW_RATIONALE;
} else {
cameraPermission = Permission.PERMANENTLY_DENIED;
}
}
private boolean gotPermission(Map<String, Boolean> result) {
Boolean permissionResult = result.get(CAMERA);
return permissionResult == null ? areEssentialPermissionsGranted(ctx) :
permissionResult;
}
}

View File

@@ -17,6 +17,7 @@ import androidx.lifecycle.ViewModelProvider;
import static android.view.View.INVISIBLE;
import static android.view.View.VISIBLE;
import static org.briarproject.briar.android.util.UiUtils.showFragment;
@MethodsNotNullByDefault
@ParametersNotNullByDefault
@@ -46,13 +47,13 @@ public class MailboxActivity extends BriarActivity {
progressBar.setVisibility(VISIBLE);
}
if (savedInstanceState == null) {
viewModel.getState().observe(this, state -> {
if (state instanceof MailboxState.NotSetup) {
onNotSetup();
}
});
}
viewModel.getState().observe(this, state -> {
if (state instanceof MailboxState.NotSetup) {
if (savedInstanceState == null) onNotSetup();
} else if (state instanceof MailboxState.SettingUp) {
onCodeScanned();
}
});
}
@Override
@@ -64,6 +65,17 @@ public class MailboxActivity extends BriarActivity {
return super.onOptionsItemSelected(item);
}
@Override
public void onBackPressed() {
if (viewModel.getState()
.getValue() instanceof MailboxState.SettingUp) {
// don't go back in flow if we are already setting up mailbox
supportFinishAfterTransition();
} else {
super.onBackPressed();
}
}
private void onNotSetup() {
progressBar.setVisibility(INVISIBLE);
getSupportFragmentManager().beginTransaction()
@@ -72,4 +84,10 @@ public class MailboxActivity extends BriarActivity {
.commit();
}
private void onCodeScanned() {
showFragment(getSupportFragmentManager(),
new MailboxConnectingFragment(),
MailboxConnectingFragment.TAG, false);
}
}

View File

@@ -0,0 +1,36 @@
package org.briarproject.briar.android.mailbox;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import org.briarproject.bramble.api.nullsafety.MethodsNotNullByDefault;
import org.briarproject.bramble.api.nullsafety.ParametersNotNullByDefault;
import org.briarproject.briar.R;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
@MethodsNotNullByDefault
@ParametersNotNullByDefault
public class MailboxConnectingFragment extends Fragment {
static final String TAG = MailboxConnectingFragment.class.getName();
@Nullable
@Override
public View onCreateView(LayoutInflater inflater,
@Nullable ViewGroup container,
@Nullable Bundle savedInstanceState) {
return inflater.inflate(R.layout.fragment_mailbox_connecting,
container, false);
}
@Override
public void onStart() {
super.onStart();
requireActivity().setTitle(R.string.mailbox_setup_title);
}
}

View File

@@ -0,0 +1,100 @@
package org.briarproject.briar.android.mailbox;
import android.content.Context;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Toast;
import org.briarproject.bramble.api.nullsafety.MethodsNotNullByDefault;
import org.briarproject.bramble.api.nullsafety.ParametersNotNullByDefault;
import org.briarproject.briar.R;
import org.briarproject.briar.android.qrcode.CameraException;
import org.briarproject.briar.android.qrcode.CameraView;
import java.util.logging.Logger;
import javax.annotation.Nullable;
import javax.inject.Inject;
import androidx.annotation.UiThread;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentActivity;
import androidx.lifecycle.ViewModelProvider;
import static android.widget.Toast.LENGTH_LONG;
import static java.util.logging.Level.WARNING;
import static org.briarproject.bramble.util.LogUtils.logException;
import static org.briarproject.briar.android.AppModule.getAndroidComponent;
@MethodsNotNullByDefault
@ParametersNotNullByDefault
public class MailboxScanFragment extends Fragment {
static final String TAG = MailboxScanFragment.class.getName();
private static final Logger LOG = Logger.getLogger(TAG);
@Inject
ViewModelProvider.Factory viewModelFactory;
private MailboxViewModel viewModel;
private CameraView cameraView;
@Override
public void onAttach(Context context) {
super.onAttach(context);
FragmentActivity activity = requireActivity();
getAndroidComponent(activity).inject(this);
viewModel = new ViewModelProvider(activity, viewModelFactory)
.get(MailboxViewModel.class);
}
@Nullable
@Override
public View onCreateView(LayoutInflater inflater,
@Nullable ViewGroup container,
@Nullable Bundle savedInstanceState) {
return inflater.inflate(R.layout.fragment_mailbox_scan, container,
false);
}
@Override
public void onViewCreated(View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
cameraView = view.findViewById(R.id.camera_view);
cameraView.setPreviewConsumer(viewModel.getQrCodeDecoder());
}
@Override
public void onStart() {
super.onStart();
requireActivity().setTitle(R.string.mailbox_setup_button_scan);
try {
cameraView.start();
} catch (CameraException e) {
logCameraExceptionAndFinish(e);
}
}
@Override
public void onStop() {
super.onStop();
try {
cameraView.stop();
} catch (CameraException e) {
logCameraExceptionAndFinish(e);
}
}
@UiThread
private void logCameraExceptionAndFinish(CameraException e) {
logException(LOG, WARNING, e);
Toast.makeText(requireContext(), R.string.camera_error,
LENGTH_LONG).show();
requireActivity().getSupportFragmentManager().popBackStack();
}
}

View File

@@ -5,6 +5,9 @@ class MailboxState {
static class NotSetup extends MailboxState {
}
static class SettingUp extends MailboxState {
}
// TODO add other states
}

View File

@@ -9,9 +9,10 @@ import org.briarproject.bramble.api.db.DatabaseExecutor;
import org.briarproject.bramble.api.db.TransactionManager;
import org.briarproject.bramble.api.lifecycle.IoExecutor;
import org.briarproject.bramble.api.lifecycle.LifecycleManager;
import org.briarproject.bramble.api.mailbox.MailboxAuthToken;
import org.briarproject.bramble.api.mailbox.MailboxProperties;
import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
import org.briarproject.bramble.api.system.AndroidExecutor;
import org.briarproject.bramble.util.StringUtils;
import org.briarproject.briar.android.mailbox.MailboxState.NotSetup;
import org.briarproject.briar.android.qrcode.QrCodeDecoder;
import org.briarproject.briar.android.viewmodel.DbViewModel;
@@ -23,7 +24,6 @@ import java.util.logging.Logger;
import javax.inject.Inject;
import androidx.annotation.Nullable;
import androidx.annotation.UiThread;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
@@ -47,11 +47,6 @@ class MailboxViewModel extends DbViewModel
private final MutableLiveData<MailboxState> state = new MutableLiveData<>();
@Nullable
private String onionAddress = null;
@Nullable
private String setupToken = null;
@Inject
MailboxViewModel(
Application app,
@@ -80,11 +75,6 @@ class MailboxViewModel extends DbViewModel
});
}
@UiThread
LiveData<MailboxState> getState() {
return state;
}
@Override
@IoExecutor
public void onQrCodeDecoded(Result result) {
@@ -105,15 +95,25 @@ class MailboxViewModel extends DbViewModel
return;
}
byte[] onionPubKey = Arrays.copyOfRange(bytes, 1, 33);
onionAddress = crypto.encodeOnionAddress(onionPubKey);
setupToken = StringUtils.toHexString(Arrays.copyOfRange(bytes, 33, 65))
.toLowerCase();
LOG.info("QR code is valid");
byte[] onionPubKey = Arrays.copyOfRange(bytes, 1, 33);
String onionAddress = crypto.encodeOnionAddress(onionPubKey);
byte[] tokenBytes = Arrays.copyOfRange(bytes, 33, 65);
MailboxAuthToken setupToken = new MailboxAuthToken(tokenBytes);
MailboxProperties props =
new MailboxProperties(onionAddress, setupToken, true);
// TODO pass props to core (maybe even do payload parsing there)
state.postValue(new MailboxState.SettingUp());
}
@UiThread
QrCodeDecoder getQrCodeDecoder() {
return qrCodeDecoder;
}
@UiThread
LiveData<MailboxState> getState() {
return state;
}
}

View File

@@ -14,12 +14,16 @@ import org.briarproject.bramble.api.nullsafety.MethodsNotNullByDefault;
import org.briarproject.bramble.api.nullsafety.ParametersNotNullByDefault;
import org.briarproject.briar.R;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts.RequestMultiplePermissions;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager;
import static android.content.Intent.ACTION_SEND;
import static android.content.Intent.EXTRA_TEXT;
import static android.widget.Toast.LENGTH_LONG;
import static org.briarproject.briar.android.util.UiUtils.showFragment;
@MethodsNotNullByDefault
@ParametersNotNullByDefault
@@ -27,6 +31,16 @@ public class SetupDownloadFragment extends Fragment {
static final String TAG = SetupDownloadFragment.class.getName();
private CameraPermissionManager permissionManager;
private final ActivityResultLauncher<String[]> permissionLauncher =
registerForActivityResult(new RequestMultiplePermissions(), r -> {
permissionManager.onRequestPermissionResult(r);
if (permissionManager.checkPermissions()) {
scanCode();
}
});
@Nullable
@Override
public View onCreateView(LayoutInflater inflater,
@@ -34,10 +48,19 @@ public class SetupDownloadFragment extends Fragment {
@Nullable Bundle savedInstanceState) {
View v = inflater.inflate(R.layout.fragment_mailbox_setup_download,
container, false);
permissionManager = new CameraPermissionManager(requireActivity(),
permissionLauncher::launch);
Button shareLinkButton = v.findViewById(R.id.shareLinkButton);
Button scanButton = v.findViewById(R.id.scanButton);
shareLinkButton.setOnClickListener(this::shareLink);
scanButton.setOnClickListener(this::scanCode);
Button scanButton = v.findViewById(R.id.scanButton);
scanButton.setOnClickListener(view -> {
if (permissionManager.checkPermissions()) {
scanCode();
}
});
return v;
}
@@ -45,6 +68,8 @@ public class SetupDownloadFragment extends Fragment {
public void onStart() {
super.onStart();
requireActivity().setTitle(R.string.mailbox_setup_title);
// Permissions may have been granted manually while we were stopped
permissionManager.resetPermissions();
}
private void shareLink(View v) {
@@ -69,8 +94,10 @@ public class SetupDownloadFragment extends Fragment {
}
}
private void scanCode(View v) {
Toast.makeText(requireContext(), "TODO", LENGTH_LONG).show();
private void scanCode() {
FragmentManager fm = getParentFragmentManager();
Fragment f = new MailboxScanFragment();
showFragment(fm, f, MailboxScanFragment.TAG);
}
}

View File

@@ -0,0 +1,5 @@
package org.briarproject.briar.android.util;
public enum Permission {
UNKNOWN, GRANTED, SHOW_RATIONALE, PERMANENTLY_DENIED
}

View File

@@ -52,6 +52,7 @@ import androidx.annotation.ColorRes;
import androidx.annotation.DrawableRes;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.annotation.StringRes;
import androidx.annotation.UiThread;
import androidx.appcompat.app.AlertDialog;
import androidx.core.content.ContextCompat;
@@ -574,4 +575,26 @@ public class UiUtils {
SOFT_INPUT_STATE_HIDDEN);
}
public static void showDenialDialog(FragmentActivity ctx,
@StringRes int title, @StringRes int body) {
AlertDialog.Builder builder =
new AlertDialog.Builder(ctx, R.style.BriarDialogTheme);
builder.setTitle(title);
builder.setMessage(body);
builder.setPositiveButton(R.string.ok, getGoToSettingsListener(ctx));
builder.setNegativeButton(R.string.cancel,
(dialog, which) -> ctx.supportFinishAfterTransition());
builder.show();
}
public static void showRationale(FragmentActivity ctx, @StringRes int title,
@StringRes int body, Runnable requestPermissions) {
AlertDialog.Builder builder =
new AlertDialog.Builder(ctx, R.style.BriarDialogTheme);
builder.setTitle(title);
builder.setMessage(body);
builder.setNeutralButton(R.string.continue_button,
(dialog, which) -> requestPermissions.run());
builder.show();
}
}

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:dither="true"
android:shape="rectangle">
<corners android:radius="32dp" />
<solid android:color="@android:color/transparent" />
<stroke
android:width="4dp"
android:color="#ffffff" />
</shape>

View File

@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ProgressBar
android:id="@+id/progressBar"
style="?android:attr/progressBarStyleLarge"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="16dp"
app:layout_constraintBottom_toTopOf="@+id/textView"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed" />
<TextView
android:id="@+id/textView"
style="@style/TextAppearance.AppCompat.Large"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:text="@string/mailbox_setup_connecting"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/progressBar" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<org.briarproject.briar.android.qrcode.CameraView
android:id="@+id/camera_view"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<View
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_margin="16dp"
android:background="@drawable/border_qr_scanner"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintDimensionRatio="1,1"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -628,6 +628,8 @@
<string name="mailbox_share_fdroid" translatable="false">https://f-droid.org/packages/org.briarproject.mailbox/</string>
<string name="mailbox_share_gplay" translatable="false">https://play.google.com/store/apps/details?id=org.briarproject.mailbox</string>
<string name="mailbox_share_download" translatable="false">https://briarproject.org/apk</string>
<string name="permission_camera_qr_denied_body">You have denied access to the camera, but scanning a QR code requires using the camera.\n\nPlease consider granting access.</string>
<string name="mailbox_setup_connecting">Connecting…</string>
<!-- Conversation Settings -->
<string name="disappearing_messages_title">Disappearing messages</string>