Add setting for requesting user account data.

This commit is contained in:
Clark 2023-03-23 14:39:31 -04:00 committed by Cody Henthorne
parent b194c0e84b
commit d6a9ed1a8d
18 changed files with 874 additions and 9 deletions

View file

@ -492,6 +492,9 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
private void initializeCleanup() {
int deleted = SignalDatabase.attachments().deleteAbandonedPreuploadedAttachments();
Log.i(TAG, "Deleted " + deleted + " abandoned attachments.");
if (SignalStore.account().clearOldAccountDataReport()) {
Log.i(TAG, "Deleted " + deleted + " expired account data report.");
}
}
private void initializeGlideCodecs() {

View file

@ -30,6 +30,7 @@ import org.thoughtcrime.securesms.lock.v2.CreateKbsPinActivity
import org.thoughtcrime.securesms.lock.v2.KbsConstants
import org.thoughtcrime.securesms.lock.v2.PinKeyboardType
import org.thoughtcrime.securesms.pin.RegistrationLockV2Dialog
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.navigation.safeNavigate
@ -121,6 +122,15 @@ class AccountSettingsFragment : DSLSettingsFragment(R.string.AccountSettingsFrag
}
)
if (FeatureFlags.exportAccountData()) {
clickPref(
title = DSLSettingsText.from(R.string.AccountSettingsFragment__request_account_data),
onClick = {
Navigation.findNavController(requireView()).safeNavigate(R.id.action_accountSettingsFragment_to_exportAccountFragment)
}
)
}
clickPref(
title = DSLSettingsText.from(R.string.preferences__delete_account, ContextCompat.getColor(requireContext(), R.color.signal_alert_primary)),
onClick = {

View file

@ -0,0 +1,264 @@
package org.thoughtcrime.securesms.components.settings.app.account.export
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement.Center
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.core.app.ShareCompat
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import com.google.android.material.snackbar.Snackbar
import org.signal.core.ui.Buttons
import org.signal.core.ui.Dialogs
import org.signal.core.ui.Rows
import org.signal.core.ui.Scaffolds
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.compose.ComposeFragment
class ExportAccountDataFragment : ComposeFragment() {
private val viewModel: ExportAccountDataViewModel by viewModels()
private fun deleteReport() {
viewModel.deleteReport()
Snackbar.make(requireView(), R.string.ExportAccountDataFragment__delete_report_snackbar, Snackbar.LENGTH_SHORT).show()
}
private fun exportReport() {
val report = viewModel.onGenerateReport()
ShareCompat.IntentBuilder(requireContext())
.setStream(report.uri)
.setType(report.mimeType)
.startChooser()
}
private fun dismissExportDialog() {
viewModel.dismissExportConfirmationDialog()
}
private fun dismissDeleteDialog() {
viewModel.dismissDeleteConfirmationDialog()
}
private fun dismissDownloadErrorDialog() {
viewModel.dismissDownloadErrorDialog()
}
@Preview
@Composable
override fun SheetContent() {
val state: ExportAccountDataState by viewModel.state
val onNavigationClick: () -> Unit = remember {
{ findNavController().popBackStack() }
}
Scaffolds.Settings(
title = stringResource(id = R.string.AccountSettingsFragment__request_account_data),
onNavigationClick = onNavigationClick,
navigationIconPainter = painterResource(id = R.drawable.ic_arrow_left_24),
navigationContentDescription = stringResource(id = R.string.Material3SearchToolbar__close)
) { contentPadding ->
Surface(
modifier = Modifier
.padding(contentPadding)
.wrapContentSize()
) {
LazyColumn(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth()) {
item {
Image(
painter = painterResource(id = R.drawable.export_account_data),
contentDescription = stringResource(R.string.ExportAccountDataFragment__your_account_data),
modifier = Modifier.padding(top = 47.dp)
)
}
item {
Text(
text = stringResource(id = R.string.ExportAccountDataFragment__your_account_data),
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(top = 16.dp)
)
}
item {
Text(
text = stringResource(id = R.string.ExportAccountDataFragment__export_explanation, stringResource(id = R.string.ExportAccountDataFragment__learn_more)),
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 12.dp, start = 32.dp, end = 32.dp, bottom = 20.dp)
)
}
item {
if (state.reportDownloaded) {
ExportReportOptions(exportAsJson = state.exportAsJson)
} else {
DownloadReportOptions()
}
}
}
if (state.downloadInProgress) {
DownloadProgressDialog()
} else if (state.showDownloadFailedDialog) {
DownloadFailedDialog()
} else if (state.showExportDialog) {
ExportReportConfirmationDialog()
} else if (state.showDeleteDialog) {
DeleteReportConfirmationDialog()
}
}
}
}
@Composable
private fun DownloadProgressDialog() {
Dialog(
onDismissRequest = {},
DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false)
) {
Card {
Box(contentAlignment = Alignment.Center) {
Column(
verticalArrangement = Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
CircularProgressIndicator(
modifier = Modifier
.padding(top = 50.dp, bottom = 18.dp)
.size(42.dp)
)
Text(text = stringResource(R.string.ExportAccountDataFragment__download_progress), Modifier.padding(bottom = 48.dp, start = 35.dp, end = 35.dp))
}
}
}
}
}
@Composable
private fun DownloadFailedDialog() {
Dialogs.SimpleMessageDialog(
message = stringResource(id = R.string.ExportAccountDataFragment__report_download_failed),
dismiss = stringResource(id = R.string.ExportAccountDataFragment__ok_action),
onDismiss = this::dismissDownloadErrorDialog
)
}
@Composable
private fun DeleteReportConfirmationDialog() {
Dialogs.SimpleAlertDialog(
title = stringResource(R.string.ExportAccountDataFragment__delete_report_confirmation),
body = stringResource(R.string.ExportAccountDataFragment__delete_report_confirmation_message),
confirm = stringResource(R.string.ExportAccountDataFragment__delete_report_action),
dismiss = stringResource(R.string.ExportAccountDataFragment__cancel_action),
onConfirm = this::deleteReport,
onDismiss = this::dismissDeleteDialog,
confirmColor = MaterialTheme.colorScheme.error
)
}
@Composable
private fun ExportReportConfirmationDialog() {
Dialogs.SimpleAlertDialog(
title = stringResource(R.string.ExportAccountDataFragment__export_report_confirmation),
body = stringResource(R.string.ExportAccountDataFragment__export_report_confirmation_message),
confirm = stringResource(R.string.ExportAccountDataFragment__export_report_action),
dismiss = stringResource(R.string.ExportAccountDataFragment__cancel_action),
onConfirm = this::exportReport,
onDismiss = this::dismissExportDialog
)
}
@Composable
private fun DownloadReportOptions() {
Buttons.LargeTonal(
onClick = viewModel::onDownloadReport,
modifier = Modifier
.fillMaxWidth()
.padding(top = 52.dp, start = 32.dp, end = 32.dp)
) {
Text(
text = stringResource(R.string.ExportAccountDataFragment__download_report),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
@Composable
private fun ExportReportOptions(exportAsJson: Boolean) {
Rows.RadioRow(
selected = !exportAsJson,
text = stringResource(id = R.string.ExportAccountDataFragment__export_as_txt),
label = stringResource(id = R.string.ExportAccountDataFragment__export_as_txt_label),
modifier = Modifier
.clickable(onClick = viewModel::setExportAsTxt)
.padding(horizontal = 16.dp)
)
Rows.RadioRow(
selected = exportAsJson,
text = stringResource(id = R.string.ExportAccountDataFragment__export_as_json),
label = stringResource(id = R.string.ExportAccountDataFragment__export_as_json_label),
modifier = Modifier
.clickable(onClick = viewModel::setExportAsJson)
.padding(horizontal = 16.dp)
)
Buttons.LargeTonal(
onClick = viewModel::showExportConfirmationDialog,
modifier = Modifier
.fillMaxWidth()
.padding(top = 24.dp, start = 32.dp, end = 32.dp)
) {
Text(
text = stringResource(R.string.ExportAccountDataFragment__export_report),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
Buttons.LargeTonal(
onClick = viewModel::showDeleteConfirmationDialog,
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error),
modifier = Modifier
.fillMaxWidth()
.padding(top = 14.dp, start = 32.dp, end = 32.dp)
) {
Text(
text = stringResource(R.string.ExportAccountDataFragment__delete_report),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.error
)
}
Text(
text = stringResource(id = R.string.ExportAccountDataFragment__report_deletion_disclaimer, stringResource(id = R.string.ExportAccountDataFragment__learn_more)),
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Start,
modifier = Modifier.padding(top = 16.dp, start = 24.dp, end = 28.dp, bottom = 20.dp)
)
}
}

View file

@ -0,0 +1,59 @@
package org.thoughtcrime.securesms.components.settings.app.account.export
import android.net.Uri
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.node.ObjectNode
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.schedulers.Schedulers
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.providers.BlobProvider
import org.thoughtcrime.securesms.util.JsonUtils
import org.whispersystems.signalservice.api.SignalServiceAccountManager
import java.io.IOException
class ExportAccountDataRepository(
private val accountManager: SignalServiceAccountManager = ApplicationDependencies.getSignalServiceAccountManager()
) {
fun downloadAccountDataReport(): Completable {
return Completable.create {
try {
SignalStore.account().setAccountDataReport(accountManager.accountDataReport, System.currentTimeMillis())
it.onComplete()
} catch (e: IOException) {
it.onError(e)
}
}.subscribeOn(Schedulers.io())
}
fun generateAccountDataReport(exportAsJson: Boolean): ExportedReport {
val mimeType: String
val fileName: String
if (exportAsJson) {
mimeType = "application/json"
fileName = "account-data.json"
} else {
mimeType = "text/plain"
fileName = "account-data.txt"
}
val tree: JsonNode = JsonUtils.getMapper().readTree(SignalStore.account().accountDataReport)
val dataStr = if (exportAsJson) {
(tree as ObjectNode).remove("text")
tree.toString()
} else {
tree["text"].asText()
}
val uri = BlobProvider.getInstance()
.forData(dataStr.encodeToByteArray())
.withMimeType(mimeType)
.withFileName(fileName)
.createForSingleSessionInMemory()
return ExportedReport(mimeType = mimeType, uri = uri)
}
data class ExportedReport(val mimeType: String, val uri: Uri)
}

View file

@ -0,0 +1,10 @@
package org.thoughtcrime.securesms.components.settings.app.account.export
data class ExportAccountDataState(
val reportDownloaded: Boolean,
val downloadInProgress: Boolean,
val exportAsJson: Boolean,
val showDownloadFailedDialog: Boolean = false,
val showDeleteDialog: Boolean = false,
val showExportDialog: Boolean = false
)

View file

@ -0,0 +1,81 @@
package org.thoughtcrime.securesms.components.settings.app.account.export
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.keyvalue.SignalStore
class ExportAccountDataViewModel(
private val repository: ExportAccountDataRepository = ExportAccountDataRepository()
) : ViewModel() {
companion object {
private val TAG = Log.tag(ExportAccountDataViewModel::class.java)
}
private val disposables = CompositeDisposable()
private val _state = mutableStateOf(
ExportAccountDataState(reportDownloaded = false, downloadInProgress = false, exportAsJson = false)
)
val state: State<ExportAccountDataState> = _state
init {
_state.value = _state.value.copy(reportDownloaded = SignalStore.account().hasAccountDataReport())
}
fun onGenerateReport(): ExportAccountDataRepository.ExportedReport = repository.generateAccountDataReport(state.value.exportAsJson)
fun onDownloadReport() {
_state.value = _state.value.copy(downloadInProgress = true)
disposables += repository.downloadAccountDataReport()
.observeOn(AndroidSchedulers.mainThread())
.subscribe({
_state.value = _state.value.copy(downloadInProgress = false, reportDownloaded = true)
}, { throwable ->
Log.e(TAG, throwable)
_state.value = _state.value.copy(downloadInProgress = false, showDownloadFailedDialog = true)
})
}
fun setExportAsJson() {
_state.value = _state.value.copy(exportAsJson = true)
}
fun setExportAsTxt() {
_state.value = _state.value.copy(exportAsJson = false)
}
fun showDeleteConfirmationDialog() {
_state.value = _state.value.copy(showDeleteDialog = true)
}
fun dismissDeleteConfirmationDialog() {
_state.value = _state.value.copy(showDeleteDialog = false)
}
fun dismissDownloadErrorDialog() {
_state.value = _state.value.copy(showDownloadFailedDialog = false)
}
fun showExportConfirmationDialog() {
_state.value = _state.value.copy(showExportDialog = true)
}
fun dismissExportConfirmationDialog() {
_state.value = _state.value.copy(showExportDialog = false)
}
fun deleteReport() {
SignalStore.account().deleteAccountDataReport()
_state.value = _state.value.copy(reportDownloaded = false)
}
override fun onCleared() {
disposables.dispose()
}
}

View file

@ -25,8 +25,8 @@ import org.whispersystems.signalservice.api.push.ACI
import org.whispersystems.signalservice.api.push.PNI
import org.whispersystems.signalservice.api.push.ServiceIds
import org.whispersystems.signalservice.api.push.SignalServiceAddress
import java.lang.IllegalStateException
import java.security.SecureRandom
import kotlin.time.Duration.Companion.days
internal class AccountValues internal constructor(store: KeyValueStore) : SignalStoreValues(store) {
@ -58,6 +58,12 @@ internal class AccountValues internal constructor(store: KeyValueStore) : Signal
private const val KEY_PNI_SIGNED_PREKEY_FAILURE_COUNT = "account.pni_signed_prekey_failure_count"
private const val KEY_PNI_NEXT_ONE_TIME_PREKEY_ID = "account.pni_next_one_time_prekey_id"
@VisibleForTesting
const val KEY_ACCOUNT_DATA_REPORT = "account.data_report"
@VisibleForTesting
const val KEY_ACCOUNT_DATA_REPORT_DOWNLOAD_TIME = "account.data_report_download_time"
@VisibleForTesting
const val KEY_E164 = "account.e164"
@ -318,6 +324,34 @@ internal class AccountValues internal constructor(store: KeyValueStore) : Signal
}
}
val accountDataReport: String?
get() = getString(KEY_ACCOUNT_DATA_REPORT, null)
fun setAccountDataReport(report: String, downloadTime: Long) {
store.beginWrite()
.putString(KEY_ACCOUNT_DATA_REPORT, report)
.putLong(KEY_ACCOUNT_DATA_REPORT_DOWNLOAD_TIME, downloadTime)
.apply()
}
fun hasAccountDataReport(): Boolean = store.containsKey(KEY_ACCOUNT_DATA_REPORT)
fun clearOldAccountDataReport(): Boolean {
return if (hasAccountDataReport() && (getLong(KEY_ACCOUNT_DATA_REPORT_DOWNLOAD_TIME, 0) + 30.days.inWholeMilliseconds) < System.currentTimeMillis()) {
deleteAccountDataReport()
true
} else {
false
}
}
fun deleteAccountDataReport() {
store.beginWrite()
.remove(KEY_ACCOUNT_DATA_REPORT)
.remove(KEY_ACCOUNT_DATA_REPORT_DOWNLOAD_TIME)
.apply()
}
val deviceName: String?
get() = getString(KEY_DEVICE_NAME, null)

View file

@ -64,8 +64,6 @@ public final class BlobContentProvider extends BaseContentProvider {
public Cursor query(@NonNull Uri uri, @Nullable String[] projection, @Nullable String selection, @Nullable String[] selectionArgs, @Nullable String sortOrder) {
Log.i(TAG, "query() called: " + uri);
if (projection == null || projection.length <= 0) return null;
String mimeType = BlobProvider.getMimeType(uri);
String fileName = BlobProvider.getFileName(uri);
Long fileSize = BlobProvider.getFileSize(uri);

View file

@ -11,6 +11,7 @@ import androidx.annotation.IntRange;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.annotation.VisibleForTesting;
import androidx.annotation.WorkerThread;
import org.signal.core.util.StreamUtil;
@ -235,6 +236,11 @@ public class BlobProvider {
});
}
@VisibleForTesting
public synchronized byte[] getMemoryBlob(@NonNull Uri uri) {
return memoryBlobs.get(uri);
}
private static void deleteOrphanedDraftFiles(@NonNull Context context) {
File directory = getOrCreateDirectory(context, DRAFT_ATTACHMENTS_DIRECTORY);
File[] files = directory.listFiles();

View file

@ -108,6 +108,7 @@ public final class FeatureFlags {
private static final String ANY_ADDRESS_PORTS_KILL_SWITCH = "android.calling.fieldTrial.anyAddressPortsKillSwitch";
private static final String CALLS_TAB = "android.calls.tab";
private static final String TEXT_FORMATTING_SPOILER_SEND = "android.textFormatting.spoilerSend";
private static final String EXPORT_ACCOUNT_DATA = "android.exportAccountData";
/**
* We will only store remote values for flags in this set. If you want a flag to be controllable
@ -166,7 +167,8 @@ public final class FeatureFlags {
TEXT_FORMATTING,
ANY_ADDRESS_PORTS_KILL_SWITCH,
CALLS_TAB,
TEXT_FORMATTING_SPOILER_SEND
TEXT_FORMATTING_SPOILER_SEND,
EXPORT_ACCOUNT_DATA
);
@VisibleForTesting
@ -603,6 +605,13 @@ public final class FeatureFlags {
return getBoolean(CALLS_TAB, false);
}
/**
* Whether or not the ability to export account data is enabled
*/
public static boolean exportAccountData() {
return getBoolean(EXPORT_ACCOUNT_DATA, false);
}
/** Only for rendering debug info. */
public static synchronized @NonNull Map<String, Object> getMemoryValues() {
return new TreeMap<>(REMOTE_VALUES);

View file

@ -0,0 +1,42 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="88dp"
android:height="88dp"
android:viewportWidth="88"
android:viewportHeight="88">
<path
android:pathData="M44,44m-44,0a44,44 0,1 1,88 0a44,44 0,1 1,-88 0"
android:fillColor="#CCD1FF"/>
<path
android:pathData="M14.543,57.299C15.625,50.954 22.023,46.412 29.347,46.412C36.67,46.412 43.068,50.954 44.15,57.299C44.476,59.21 42.888,60.566 41.315,60.566H17.378C15.805,60.566 14.217,59.21 14.543,57.299Z"
android:fillColor="#ffffff"
android:fillType="evenOdd"/>
<path
android:pathData="M22.166,35.467C22.166,30.903 25.165,27 29.347,27C33.528,27 36.527,30.903 36.527,35.467C36.527,37.775 35.787,39.959 34.542,41.593C33.298,43.227 31.476,44.39 29.347,44.39C27.217,44.39 25.395,43.227 24.151,41.593C22.906,39.959 22.166,37.775 22.166,35.467Z"
android:fillColor="#ffffff"
android:fillType="evenOdd"/>
<path
android:pathData="M32.183,43.655C33.099,43.158 33.898,42.439 34.542,41.593C35.787,39.959 36.527,37.775 36.527,35.467C36.527,30.903 33.528,27 29.347,27C25.165,27 22.166,30.903 22.166,35.467C22.166,37.775 22.906,39.959 24.151,41.593C24.795,42.439 25.594,43.158 26.51,43.655C27.364,44.119 28.319,44.39 29.347,44.39C30.374,44.39 31.33,44.119 32.183,43.655ZM26.084,46.717C20.199,47.835 15.46,51.919 14.543,57.299C14.217,59.21 15.805,60.566 17.378,60.566H41.315C42.888,60.566 44.476,59.21 44.15,57.299C43.233,51.919 38.494,47.835 32.609,46.717C31.553,46.517 30.461,46.412 29.347,46.412C28.232,46.412 27.14,46.517 26.084,46.717ZM17.491,57.61H41.202C40.324,53.117 35.53,49.368 29.347,49.368C23.163,49.368 18.37,53.117 17.491,57.61ZM29.347,29.956C27.311,29.956 25.122,31.971 25.122,35.467C25.122,37.164 25.672,38.711 26.503,39.802C27.339,40.899 28.364,41.433 29.347,41.433C30.329,41.433 31.354,40.899 32.19,39.802C33.021,38.711 33.571,37.164 33.571,35.467C33.571,31.971 31.382,29.956 29.347,29.956Z"
android:fillColor="#3945AF"
android:fillType="evenOdd"/>
<path
android:pathData="M49.939,32.196C49.939,31.661 50.373,31.228 50.907,31.228H69.627C70.162,31.228 70.595,31.661 70.595,32.196C70.595,32.73 70.162,33.164 69.627,33.164H50.907C50.373,33.164 49.939,32.73 49.939,32.196Z"
android:fillColor="#ffffff"/>
<path
android:pathData="M69.627,33.164C70.162,33.164 70.595,32.73 70.595,32.196C70.595,31.661 70.162,31.228 69.627,31.228H50.907C50.373,31.228 49.939,31.661 49.939,32.196C49.939,32.73 50.373,33.164 50.907,33.164H69.627ZM50.907,28.323H69.627C71.766,28.323 73.5,30.057 73.5,32.196C73.5,34.335 71.766,36.069 69.627,36.069H50.907C48.768,36.069 47.034,34.335 47.034,32.196C47.034,30.057 48.768,28.323 50.907,28.323Z"
android:fillColor="#3945AF"
android:fillType="evenOdd"/>
<path
android:pathData="M49.939,43.873C49.939,43.338 50.373,42.905 50.907,42.905H69.627C70.162,42.905 70.595,43.338 70.595,43.873C70.595,44.408 70.162,44.841 69.627,44.841H50.907C50.373,44.841 49.939,44.408 49.939,43.873Z"
android:fillColor="#ffffff"/>
<path
android:pathData="M69.627,44.841C70.162,44.841 70.595,44.408 70.595,43.873C70.595,43.338 70.162,42.905 69.627,42.905H50.907C50.373,42.905 49.939,43.338 49.939,43.873C49.939,44.408 50.373,44.841 50.907,44.841H69.627ZM50.907,40H69.627C71.766,40 73.5,41.734 73.5,43.873C73.5,46.012 71.766,47.746 69.627,47.746H50.907C48.768,47.746 47.034,46.012 47.034,43.873C47.034,41.734 48.768,40 50.907,40Z"
android:fillColor="#3945AF"
android:fillType="evenOdd"/>
<path
android:pathData="M49.939,55.873C49.939,55.338 50.373,54.905 50.907,54.905H69.627C70.162,54.905 70.595,55.338 70.595,55.873C70.595,56.408 70.162,56.841 69.627,56.841H50.907C50.373,56.841 49.939,56.408 49.939,55.873Z"
android:fillColor="#ffffff"/>
<path
android:pathData="M69.627,56.841C70.162,56.841 70.595,56.408 70.595,55.873C70.595,55.338 70.162,54.905 69.627,54.905H50.907C50.373,54.905 49.939,55.338 49.939,55.873C49.939,56.408 50.373,56.841 50.907,56.841H69.627ZM50.907,52H69.627C71.766,52 73.5,53.734 73.5,55.873C73.5,58.012 71.766,59.746 69.627,59.746H50.907C48.768,59.746 47.034,58.012 47.034,55.873C47.034,53.734 48.768,52 50.907,52Z"
android:fillColor="#3945AF"
android:fillType="evenOdd"/>
</vector>

View file

@ -155,6 +155,13 @@
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
<action
android:id="@+id/action_accountSettingsFragment_to_exportAccountFragment"
app:destination="@id/exportAccountDataFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
</fragment>
<include app:graph="@navigation/app_settings_change_number" />
@ -170,6 +177,11 @@
android:label="delete_account_fragment"
tools:layout="@layout/delete_account_fragment" />
<fragment
android:id="@+id/exportAccountDataFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.account.export.ExportAccountDataFragment"
android:label="export_account_data_fragment" />
<activity
android:id="@+id/oldDeviceTransferActivity"
android:name="org.thoughtcrime.securesms.devicetransfer.olddevice.OldDeviceTransferActivity"

View file

@ -3995,6 +3995,60 @@
<string name="AccountSettingsFragment__youll_be_asked_less_frequently">You\'ll be asked less frequently over time</string>
<string name="AccountSettingsFragment__require_your_signal_pin">Require your Signal PIN to register your phone number with Signal again</string>
<string name="AccountSettingsFragment__change_phone_number">Change phone number</string>
<!-- Account setting that allows user to request and export their signal account data -->
<string name="AccountSettingsFragment__request_account_data">Request account data</string>
<!-- ExportAccountDataFragment -->
<!-- Part of requesting account data flow, this is the section title for requesting that account data -->
<string name="ExportAccountDataFragment__your_account_data">Your account data</string>
<!-- Explanation of account data the user can request. %1$s is replaced with Learn more with a link -->
<string name="ExportAccountDataFragment__export_explanation">Download and export a report of your Signal account data. This report does not include any messages or media. %1$s</string>
<!-- Learn more link to more information about requesting account data -->
<string name="ExportAccountDataFragment__learn_more">Learn more</string>
<!-- Button action to download the report data -->
<string name="ExportAccountDataFragment__download_report">Download report</string>
<!-- Button action to export the report data to another app (e.g. email) -->
<string name="ExportAccountDataFragment__export_report">Export report</string>
<!-- Button action to delete the requested account data -->
<string name="ExportAccountDataFragment__delete_report">Delete report</string>
<!-- Radio option to export the data as a text file .txt -->
<string name="ExportAccountDataFragment__export_as_txt">Export as TXT</string>
<!-- Label for the text file option -->
<string name="ExportAccountDataFragment__export_as_txt_label">Easy-to-read text file</string>
<!-- Radio option to export the data as a json (java script object notation) file .json -->
<string name="ExportAccountDataFragment__export_as_json">Export as JSON</string>
<!-- Label for the json file option, the account data in a machine readable file format -->
<string name="ExportAccountDataFragment__export_as_json_label">Machine-readable file</string>
<!-- Action to cancel (in a dialog) -->
<string name="ExportAccountDataFragment__cancel_action">Cancel</string>
<!-- Acknowledgement for download failure -->
<string name="ExportAccountDataFragment__ok_action">OK</string>
<!-- Message shown when report download fails -->
<string name="ExportAccountDataFragment__report_download_failed">Your report could not be downloaded due to a network error. Check your connection and try again.</string>
<!-- Title for export confirmation dialog -->
<string name="ExportAccountDataFragment__export_report_confirmation">Export data?</string>
<!-- Message for export confirmation dialog -->
<string name="ExportAccountDataFragment__export_report_confirmation_message">Only share your Signal account data with people or apps you trust.</string>
<!-- Action to export in for export confirmation dialog -->
<string name="ExportAccountDataFragment__export_report_action">Export</string>
<!-- Action to delete report in confirmation dialog -->
<string name="ExportAccountDataFragment__delete_report_action">Delete</string>
<!-- Title for delete report confirmation dialog -->
<string name="ExportAccountDataFragment__delete_report_confirmation">Delete report?</string>
<!-- Message for delete report confirmation dialog -->
<string name="ExportAccountDataFragment__delete_report_confirmation_message">This will not delete any data from your account.</string>
<!-- Shown as a snackbar letting the user know the report was deleted -->
<string name="ExportAccountDataFragment__delete_report_snackbar">Report deleted</string>
<!-- Shown in a dialog with a spinner while the report is downloading -->
<string name="ExportAccountDataFragment__download_progress">Downloading report…</string>
<!-- Explanation that the downloaded report will be available for 30 days before it is deleted -->
<string name="ExportAccountDataFragment__report_deletion_disclaimer">Your report will be available for export for 30 days and will then be automatically deleted.</string>
<!-- ChangeNumberFragment -->
<string name="ChangeNumberFragment__use_this_to_change_your_current_phone_number_to_a_new_phone_number">Use this to change your current phone number to a new phone number. You cant undo this change.\n\nBefore continuing, make sure your new number can receive SMS or calls.</string>

View file

@ -0,0 +1,164 @@
package org.thoughtcrime.securesms.components.settings.app.account.export
import android.app.Application
import androidx.test.core.app.ApplicationProvider
import com.fasterxml.jackson.core.JsonParseException
import io.mockk.every
import io.mockk.mockk
import io.reactivex.rxjava3.android.plugins.RxAndroidPlugins
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.schedulers.TestScheduler
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.dependencies.MockApplicationDependencyProvider
import org.thoughtcrime.securesms.keyvalue.AccountValues
import org.thoughtcrime.securesms.keyvalue.KeyValueDataSet
import org.thoughtcrime.securesms.keyvalue.KeyValueStore
import org.thoughtcrime.securesms.keyvalue.MockKeyValuePersistentStorage
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.providers.BlobProvider
import org.thoughtcrime.securesms.util.JsonUtils
import java.io.IOException
@RunWith(RobolectricTestRunner::class)
@Config(manifest = Config.NONE, application = Application::class)
class ExportAccountDataTest {
private val mockJson: String = """
{
"reportId": "4c0ca2aa-151b-4e9e-8bf4-ea2c64345a22",
"reportTimestamp": "2023-03-22T20:21:24Z",
"data": {
"account": {
"phoneNumber": "+14125556950",
"badges": [
{
"id": "R_LOW",
"expiration": "2023-04-27T00:00:00Z",
"visible": true
}
],
"allowSealedSenderFromAnyone": false,
"findAccountByPhoneNumber": true
},
"devices": [
{
"id": 1,
"lastSeen": "2023-03-22T00:00:00Z",
"created": "2023-03-07T19:37:08Z",
"userAgent": "OWA"
},
{
"id": 2,
"lastSeen": "2023-03-21T00:00:00Z",
"created": "2023-03-07T19:40:56Z",
"userAgent": null
}
]
},
"text": "Report ID: 4c0ca2aa-151b-4e9e-8bf4-ea2c64345a22\nReport timestamp: 2023-03-22T20:21:24Z\n\n# Account\nPhone number: +16509246950\nAllow sealed sender from anyone: false\nFind account by phone number: true\nBadges:\n- ID: R_LOW\n Expiration: 2023-04-27T00:00:00Z\n Visible: true\n\n# Devices\n- ID: 1\n Created: 2023-03-07T19:37:08Z\n Last seen: 2023-03-22T00:00:00Z\n User-agent: OWA\n- ID: 2\n Created: 2023-03-07T19:40:56Z\n Last seen: 2023-03-21T00:00:00Z\n User-agent: null\n"
}
"""
@Before
fun setup() {
if (!ApplicationDependencies.isInitialized()) {
ApplicationDependencies.init(ApplicationProvider.getApplicationContext(), MockApplicationDependencyProvider())
}
}
@Test
fun `Successful download flow with progress states and delete flow`() {
val dataSet = KeyValueDataSet()
val scheduler = TestScheduler()
val mockRepository: ExportAccountDataRepository = mockk {
every { downloadAccountDataReport() } returns Completable.create {
dataSet.putString(AccountValues.KEY_ACCOUNT_DATA_REPORT, mockJson)
dataSet.putLong(AccountValues.KEY_ACCOUNT_DATA_REPORT_DOWNLOAD_TIME, 123456L)
it.onComplete()
}.subscribeOn(scheduler)
}
SignalStore.inject(KeyValueStore(MockKeyValuePersistentStorage.withDataSet(dataSet)))
RxAndroidPlugins.setMainThreadSchedulerHandler {
scheduler
}
val viewModel = ExportAccountDataViewModel(mockRepository)
assertFalse(viewModel.state.value.reportDownloaded)
viewModel.onDownloadReport()
assertTrue(viewModel.state.value.downloadInProgress)
scheduler.triggerActions()
assertTrue(viewModel.state.value.reportDownloaded)
assertFalse(viewModel.state.value.downloadInProgress)
assertEquals(SignalStore.account().accountDataReport, mockJson)
viewModel.deleteReport()
assertEquals(SignalStore.account().accountDataReport, null)
assertFalse(viewModel.state.value.reportDownloaded)
}
@Test
fun `Export json without text field`() {
val dataSet = KeyValueDataSet()
dataSet.putString(AccountValues.KEY_ACCOUNT_DATA_REPORT, mockJson)
SignalStore.inject(KeyValueStore(MockKeyValuePersistentStorage.withDataSet(dataSet)))
val mockRepository: ExportAccountDataRepository = mockk {
every { generateAccountDataReport(any()) } answers { callOriginal() }
}
val viewModel = ExportAccountDataViewModel(mockRepository)
viewModel.setExportAsTxt()
val txtReport = viewModel.onGenerateReport()
assertEquals(txtReport.mimeType, "text/plain")
assertThrows(JsonParseException::class.java) {
JsonUtils.getMapper().readTree(BlobProvider.getInstance().getMemoryBlob(txtReport.uri) as ByteArray)
}
viewModel.setExportAsJson()
val jsonReport = viewModel.onGenerateReport()
assertEquals(jsonReport.mimeType, "application/json")
val json = JsonUtils.getMapper().readTree(BlobProvider.getInstance().getMemoryBlob(jsonReport.uri) as ByteArray)
assertFalse(json.has("text"))
assertTrue(json.has("data"))
assertTrue(json.has("reportId"))
assertTrue(json.has("reportTimestamp"))
}
@Test
fun `Failed download error flow`() {
val dataSet = KeyValueDataSet()
val scheduler = TestScheduler()
val mockRepository: ExportAccountDataRepository = mockk {
every { downloadAccountDataReport() } returns Completable.create {
it.onError(IOException())
}.subscribeOn(scheduler)
}
SignalStore.inject(KeyValueStore(MockKeyValuePersistentStorage.withDataSet(dataSet)))
RxAndroidPlugins.setMainThreadSchedulerHandler {
scheduler
}
val viewModel = ExportAccountDataViewModel(mockRepository)
assertEquals(viewModel.state.value.reportDownloaded, false)
viewModel.onDownloadReport()
assertEquals(viewModel.state.value.downloadInProgress, true)
scheduler.triggerActions()
assertEquals(viewModel.state.value.reportDownloaded, false)
assertEquals(viewModel.state.value.downloadInProgress, false)
assertEquals(viewModel.state.value.showDownloadFailedDialog, true)
viewModel.dismissDownloadErrorDialog()
assertEquals(viewModel.state.value.showDownloadFailedDialog, false)
}
}

View file

@ -0,0 +1,96 @@
package org.signal.core.ui
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.window.DialogProperties
import org.signal.core.ui.Dialogs.SimpleAlertDialog
import org.signal.core.ui.Dialogs.SimpleMessageDialog
object Dialogs {
@Composable
fun SimpleMessageDialog(
message: String,
dismiss: String,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
dismissColor: Color = Color.Unspecified,
properties: DialogProperties = DialogProperties()
) {
androidx.compose.material3.AlertDialog(
onDismissRequest = onDismiss,
text = { Text(text = message) },
confirmButton = {
TextButton(onClick = {
onDismiss()
}) {
Text(text = dismiss, color = dismissColor)
}
},
modifier = modifier,
properties = properties
)
}
@Composable
fun SimpleAlertDialog(
title: String,
body: String,
confirm: String,
dismiss: String,
onConfirm: () -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
confirmColor: Color = Color.Unspecified,
dismissColor: Color = Color.Unspecified,
properties: DialogProperties = DialogProperties()
) {
androidx.compose.material3.AlertDialog(
onDismissRequest = onDismiss,
title = { Text(text = title) },
text = { Text(text = body) },
confirmButton = {
TextButton(onClick = {
onConfirm()
onDismiss()
}) {
Text(text = confirm, color = confirmColor)
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(text = dismiss, color = dismissColor)
}
},
modifier = modifier,
properties = properties
)
}
}
@Preview
@Composable
private fun AlertDialogPreview() {
SimpleAlertDialog(
title = "Title Text",
body = "Body text message",
confirm = "Confirm Button",
dismiss = "Dismiss Button",
onConfirm = {},
onDismiss = {}
)
}
@Preview
@Composable
private fun MessageDialogPreview() {
SimpleMessageDialog(
message = "Message here",
dismiss = "OK",
onDismiss = {}
)
}

View file

@ -1,6 +1,7 @@
package org.signal.core.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@ -17,6 +18,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.signal.core.ui.theme.SignalTheme
object Rows {
@ -28,7 +30,8 @@ object Rows {
fun RadioRow(
selected: Boolean,
text: String,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
label: String? = null
) {
Row(
modifier = modifier
@ -45,10 +48,21 @@ object Rows {
modifier = Modifier.padding(end = 24.dp)
)
Text(
text = text,
style = MaterialTheme.typography.bodyLarge
)
Column {
Text(
text = text,
style = MaterialTheme.typography.bodyLarge
)
if (label != null) {
Text(
text = label,
fontSize = 14.sp,
lineHeight = 20.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
@ -62,6 +76,7 @@ private fun RadioRowPreview() {
Rows.RadioRow(
selected,
"RadioRow",
label = "RadioRow Label",
modifier = Modifier.clickable {
selected = !selected
}

View file

@ -620,6 +620,9 @@ public class SignalServiceAccountManager {
return out;
}
public String getAccountDataReport() throws IOException {
return pushServiceSocket.getAccountDataReport();
}
public String getNewDeviceVerificationCode() throws IOException {
return this.pushServiceSocket.getNewDeviceVerificationCode();

View file

@ -213,6 +213,7 @@ public class PushServiceSocket {
private static final String DELETE_ACCOUNT_PATH = "/v1/accounts/me";
private static final String CHANGE_NUMBER_PATH = "/v2/accounts/number";
private static final String IDENTIFIER_REGISTERED_PATH = "/v1/accounts/account/%s";
private static final String REQUEST_ACCOUNT_DATA_PATH = "/v2/accounts/data_report";
private static final String PREKEY_METADATA_PATH = "/v2/keys?identity=%s";
private static final String PREKEY_PATH = "/v2/keys/%s?identity=%s";
@ -418,6 +419,10 @@ public class PushServiceSocket {
}
}
public String getAccountDataReport() throws IOException {
return makeServiceRequest(REQUEST_ACCOUNT_DATA_PATH, "GET", null);
}
public CdsiAuthResponse getCdsiAuth() throws IOException {
String body = makeServiceRequest(CDSI_AUTH, "GET", null);
return JsonUtil.fromJsonResponse(body, CdsiAuthResponse.class);