diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java index 650d35c910..9d3d84597a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java @@ -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() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/AccountSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/AccountSettingsFragment.kt index d1be8bdecb..0b7afd76fd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/AccountSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/AccountSettingsFragment.kt @@ -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 = { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/export/ExportAccountDataFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/export/ExportAccountDataFragment.kt new file mode 100644 index 0000000000..d567a9b53e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/export/ExportAccountDataFragment.kt @@ -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) + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/export/ExportAccountDataRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/export/ExportAccountDataRepository.kt new file mode 100644 index 0000000000..2801209ed4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/export/ExportAccountDataRepository.kt @@ -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) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/export/ExportAccountDataState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/export/ExportAccountDataState.kt new file mode 100644 index 0000000000..4092e8b78b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/export/ExportAccountDataState.kt @@ -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 +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/export/ExportAccountDataViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/export/ExportAccountDataViewModel.kt new file mode 100644 index 0000000000..c03964b704 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/export/ExportAccountDataViewModel.kt @@ -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 = _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() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/AccountValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/AccountValues.kt index c127dadabc..4805bacc76 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/AccountValues.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/AccountValues.kt @@ -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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/providers/BlobContentProvider.java b/app/src/main/java/org/thoughtcrime/securesms/providers/BlobContentProvider.java index a11a3dd017..ac8c63cb8d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/providers/BlobContentProvider.java +++ b/app/src/main/java/org/thoughtcrime/securesms/providers/BlobContentProvider.java @@ -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); diff --git a/app/src/main/java/org/thoughtcrime/securesms/providers/BlobProvider.java b/app/src/main/java/org/thoughtcrime/securesms/providers/BlobProvider.java index 20a12827b9..0bc38b10a7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/providers/BlobProvider.java +++ b/app/src/main/java/org/thoughtcrime/securesms/providers/BlobProvider.java @@ -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(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java index 4da4999c38..bafba59856 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java @@ -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 getMemoryValues() { return new TreeMap<>(REMOTE_VALUES); diff --git a/app/src/main/res/drawable/export_account_data.xml b/app/src/main/res/drawable/export_account_data.xml new file mode 100644 index 0000000000..680a26d606 --- /dev/null +++ b/app/src/main/res/drawable/export_account_data.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/navigation/app_settings.xml b/app/src/main/res/navigation/app_settings.xml index 01f714561e..af98baebb9 100644 --- a/app/src/main/res/navigation/app_settings.xml +++ b/app/src/main/res/navigation/app_settings.xml @@ -155,6 +155,13 @@ app:exitAnim="@anim/fragment_open_exit" app:popEnterAnim="@anim/fragment_close_enter" app:popExitAnim="@anim/fragment_close_exit" /> + @@ -170,6 +177,11 @@ android:label="delete_account_fragment" tools:layout="@layout/delete_account_fragment" /> + + You\'ll be asked less frequently over time Require your Signal PIN to register your phone number with Signal again Change phone number + + Request account data + + + + Your account data + + Download and export a report of your Signal account data. This report does not include any messages or media. %1$s + + Learn more + + Download report + + Export report + + Delete report + + + Export as TXT + + Easy-to-read text file + + Export as JSON + + Machine-readable file + + + Cancel + + + OK + + Your report could not be downloaded due to a network error. Check your connection and try again. + + + Export data? + + Only share your Signal account data with people or apps you trust. + + Export + + + Delete + + Delete report? + + This will not delete any data from your account. + + + Report deleted + + Downloading report… + + Your report will be available for export for 30 days and will then be automatically deleted. Use this to change your current phone number to a new phone number. You can’t undo this change.\n\nBefore continuing, make sure your new number can receive SMS or calls. diff --git a/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/account/export/ExportAccountDataTest.kt b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/account/export/ExportAccountDataTest.kt new file mode 100644 index 0000000000..f04dbb9506 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/account/export/ExportAccountDataTest.kt @@ -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) + } +} diff --git a/core-ui/src/main/java/org/signal/core/ui/Dialogs.kt b/core-ui/src/main/java/org/signal/core/ui/Dialogs.kt new file mode 100644 index 0000000000..ad9b055258 --- /dev/null +++ b/core-ui/src/main/java/org/signal/core/ui/Dialogs.kt @@ -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 = {} + ) +} diff --git a/core-ui/src/main/java/org/signal/core/ui/Rows.kt b/core-ui/src/main/java/org/signal/core/ui/Rows.kt index 0c09231de8..7e2b9ab689 100644 --- a/core-ui/src/main/java/org/signal/core/ui/Rows.kt +++ b/core-ui/src/main/java/org/signal/core/ui/Rows.kt @@ -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 } diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceAccountManager.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceAccountManager.java index 9410417aa7..599c384f82 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceAccountManager.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceAccountManager.java @@ -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(); diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java index cc76e18ec0..ff5bfe1d46 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java @@ -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);