Add setting for requesting user account data.
This commit is contained in:
parent
b194c0e84b
commit
d6a9ed1a8d
18 changed files with 874 additions and 9 deletions
|
@ -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() {
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
)
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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);
|
||||
|
|
42
app/src/main/res/drawable/export_account_data.xml
Normal file
42
app/src/main/res/drawable/export_account_data.xml
Normal 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>
|
|
@ -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"
|
||||
|
|
|
@ -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 can’t undo this change.\n\nBefore continuing, make sure your new number can receive SMS or calls.</string>
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
96
core-ui/src/main/java/org/signal/core/ui/Dialogs.kt
Normal file
96
core-ui/src/main/java/org/signal/core/ui/Dialogs.kt
Normal 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 = {}
|
||||
)
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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);
|
||||
|
|
Loading…
Add table
Reference in a new issue