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);