mirror of
https://github.com/signalapp/Signal-Android.git
synced 2025-12-23 20:48:43 +00:00
Handle rate limits when rotating recovery key.
This commit is contained in:
@@ -142,6 +142,7 @@ import org.whispersystems.signalservice.api.ApplicationErrorAction
|
|||||||
import org.whispersystems.signalservice.api.NetworkResult
|
import org.whispersystems.signalservice.api.NetworkResult
|
||||||
import org.whispersystems.signalservice.api.StatusCodeErrorAction
|
import org.whispersystems.signalservice.api.StatusCodeErrorAction
|
||||||
import org.whispersystems.signalservice.api.archive.ArchiveGetMediaItemsResponse
|
import org.whispersystems.signalservice.api.archive.ArchiveGetMediaItemsResponse
|
||||||
|
import org.whispersystems.signalservice.api.archive.ArchiveKeyRotationLimitResponse
|
||||||
import org.whispersystems.signalservice.api.archive.ArchiveMediaRequest
|
import org.whispersystems.signalservice.api.archive.ArchiveMediaRequest
|
||||||
import org.whispersystems.signalservice.api.archive.ArchiveMediaResponse
|
import org.whispersystems.signalservice.api.archive.ArchiveMediaResponse
|
||||||
import org.whispersystems.signalservice.api.archive.ArchiveServiceAccess
|
import org.whispersystems.signalservice.api.archive.ArchiveServiceAccess
|
||||||
@@ -2063,6 +2064,10 @@ object BackupRepository {
|
|||||||
.then { SignalNetwork.archive.getSvrBAuthorization(SignalStore.account.requireAci(), it.messageBackupAccess) }
|
.then { SignalNetwork.archive.getSvrBAuthorization(SignalStore.account.requireAci(), it.messageBackupAccess) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getKeyRotationLimit(): NetworkResult<ArchiveKeyRotationLimitResponse> {
|
||||||
|
return SignalNetwork.archive.getKeyRotationLimit()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* During normal operation, ensures that the backupId has been reserved and that your public key has been set,
|
* During normal operation, ensures that the backupId has been reserved and that your public key has been set,
|
||||||
* while also returning an archive access data. Should be the basis of all backup operations.
|
* while also returning an archive access data. Should be the basis of all backup operations.
|
||||||
|
|||||||
@@ -70,7 +70,8 @@ sealed interface MessageBackupsKeyRecordMode {
|
|||||||
data class CreateNewKey(
|
data class CreateNewKey(
|
||||||
val onCreateNewKeyClick: () -> Unit,
|
val onCreateNewKeyClick: () -> Unit,
|
||||||
val onTurnOffAndDownloadClick: () -> Unit,
|
val onTurnOffAndDownloadClick: () -> Unit,
|
||||||
val isOptimizedStorageEnabled: Boolean
|
val isOptimizedStorageEnabled: Boolean,
|
||||||
|
val canRotateKey: Boolean
|
||||||
) : MessageBackupsKeyRecordMode
|
) : MessageBackupsKeyRecordMode
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,10 +264,17 @@ private fun CreateNewKeyButton(
|
|||||||
mode: MessageBackupsKeyRecordMode.CreateNewKey
|
mode: MessageBackupsKeyRecordMode.CreateNewKey
|
||||||
) {
|
) {
|
||||||
var displayBottomSheet by remember { mutableStateOf(false) }
|
var displayBottomSheet by remember { mutableStateOf(false) }
|
||||||
var displayDialog by remember { mutableStateOf(false) }
|
var displayDownloadMediaDialog by remember { mutableStateOf(false) }
|
||||||
|
var displayKeyLimitDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = { displayBottomSheet = true },
|
onClick = {
|
||||||
|
if (!mode.canRotateKey) {
|
||||||
|
displayKeyLimitDialog = true
|
||||||
|
} else {
|
||||||
|
displayBottomSheet = true
|
||||||
|
}
|
||||||
|
},
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(bottom = 24.dp)
|
.padding(bottom = 24.dp)
|
||||||
.horizontalGutters()
|
.horizontalGutters()
|
||||||
@@ -276,10 +284,16 @@ private fun CreateNewKeyButton(
|
|||||||
Text(text = stringResource(R.string.MessageBackupsKeyRecordScreen__create_new_key))
|
Text(text = stringResource(R.string.MessageBackupsKeyRecordScreen__create_new_key))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (displayDialog) {
|
if (displayKeyLimitDialog) {
|
||||||
|
KeyLimitExceededDialog(
|
||||||
|
onClick = { displayKeyLimitDialog = false }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (displayDownloadMediaDialog) {
|
||||||
DownloadMediaDialog(
|
DownloadMediaDialog(
|
||||||
onTurnOffAndDownloadClick = mode.onTurnOffAndDownloadClick,
|
onTurnOffAndDownloadClick = mode.onTurnOffAndDownloadClick,
|
||||||
onCancelClick = { displayDialog = false }
|
onCancelClick = { displayDownloadMediaDialog = false }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,7 +305,7 @@ private fun CreateNewKeyButton(
|
|||||||
CreateNewBackupKeySheetContent(
|
CreateNewBackupKeySheetContent(
|
||||||
onContinueClick = {
|
onContinueClick = {
|
||||||
if (mode.isOptimizedStorageEnabled) {
|
if (mode.isOptimizedStorageEnabled) {
|
||||||
displayDialog = true
|
displayDownloadMediaDialog = true
|
||||||
} else {
|
} else {
|
||||||
mode.onCreateNewKeyClick()
|
mode.onCreateNewKeyClick()
|
||||||
}
|
}
|
||||||
@@ -448,6 +462,19 @@ private fun DownloadMediaDialog(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun KeyLimitExceededDialog(
|
||||||
|
onClick: () -> Unit = {}
|
||||||
|
) {
|
||||||
|
Dialogs.SimpleAlertDialog(
|
||||||
|
title = stringResource(R.string.MessageBackupsKeyRecordScreen__limit_exceeded_title),
|
||||||
|
body = stringResource(R.string.MessageBackupsKeyRecordScreen__limit_exceeded_body),
|
||||||
|
confirm = stringResource(R.string.MessageBackupsKeyRecordScreen__ok),
|
||||||
|
onConfirm = {},
|
||||||
|
onDismiss = onClick
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun saveKeyToCredentialManager(
|
private suspend fun saveKeyToCredentialManager(
|
||||||
@UiContext activityContext: Context,
|
@UiContext activityContext: Context,
|
||||||
backupKey: String
|
backupKey: String
|
||||||
@@ -470,7 +497,8 @@ private fun MessageBackupsKeyRecordScreenPreview() {
|
|||||||
mode = MessageBackupsKeyRecordMode.CreateNewKey(
|
mode = MessageBackupsKeyRecordMode.CreateNewKey(
|
||||||
onCreateNewKeyClick = {},
|
onCreateNewKeyClick = {},
|
||||||
onTurnOffAndDownloadClick = {},
|
onTurnOffAndDownloadClick = {},
|
||||||
isOptimizedStorageEnabled = true
|
isOptimizedStorageEnabled = true,
|
||||||
|
canRotateKey = true
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -507,3 +535,11 @@ private fun DownloadMediaDialogPreview() {
|
|||||||
DownloadMediaDialog()
|
DownloadMediaDialog()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@DayNightPreviews
|
||||||
|
@Composable
|
||||||
|
private fun KeyLimitExceededDialogPreview() {
|
||||||
|
Previews.Preview {
|
||||||
|
KeyLimitExceededDialog()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ class BackupKeyDisplayFragment : ComposeFragment() {
|
|||||||
displayWarningDialog = true
|
displayWarningDialog = true
|
||||||
}
|
}
|
||||||
|
|
||||||
val mode = remember(state.rotationState) {
|
val mode = remember(state.rotationState, state.canRotateKey) {
|
||||||
if (state.rotationState == BackupKeyRotationState.NOT_STARTED) {
|
if (state.rotationState == BackupKeyRotationState.NOT_STARTED) {
|
||||||
MessageBackupsKeyRecordMode.CreateNewKey(
|
MessageBackupsKeyRecordMode.CreateNewKey(
|
||||||
onCreateNewKeyClick = {
|
onCreateNewKeyClick = {
|
||||||
@@ -85,7 +85,8 @@ class BackupKeyDisplayFragment : ComposeFragment() {
|
|||||||
viewModel.turnOffOptimizedStorageAndDownloadMedia()
|
viewModel.turnOffOptimizedStorageAndDownloadMedia()
|
||||||
findNavController().popBackStack()
|
findNavController().popBackStack()
|
||||||
},
|
},
|
||||||
isOptimizedStorageEnabled = state.isOptimizedStorageEnabled
|
isOptimizedStorageEnabled = state.isOptimizedStorageEnabled,
|
||||||
|
canRotateKey = state.canRotateKey
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
MessageBackupsKeyRecordMode.Next(
|
MessageBackupsKeyRecordMode.Next(
|
||||||
|
|||||||
@@ -14,14 +14,21 @@ import kotlinx.coroutines.flow.update
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.signal.core.util.concurrent.SignalDispatchers
|
import org.signal.core.util.concurrent.SignalDispatchers
|
||||||
|
import org.signal.core.util.logging.Log
|
||||||
import org.thoughtcrime.securesms.backup.v2.BackupRepository
|
import org.thoughtcrime.securesms.backup.v2.BackupRepository
|
||||||
import org.thoughtcrime.securesms.backup.v2.StagedBackupKeyRotations
|
import org.thoughtcrime.securesms.backup.v2.StagedBackupKeyRotations
|
||||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||||
import org.thoughtcrime.securesms.jobs.RestoreOptimizedMediaJob
|
import org.thoughtcrime.securesms.jobs.RestoreOptimizedMediaJob
|
||||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||||
import org.whispersystems.signalservice.api.AccountEntropyPool
|
import org.whispersystems.signalservice.api.AccountEntropyPool
|
||||||
|
import org.whispersystems.signalservice.api.NetworkResult
|
||||||
|
|
||||||
class BackupKeyDisplayViewModel : ViewModel(), BackupKeyCredentialManagerHandler {
|
class BackupKeyDisplayViewModel : ViewModel(), BackupKeyCredentialManagerHandler {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val TAG = Log.tag(BackupKeyDisplayViewModel::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
private val internalUiState = MutableStateFlow(BackupKeyDisplayUiState())
|
private val internalUiState = MutableStateFlow(BackupKeyDisplayUiState())
|
||||||
val uiState: StateFlow<BackupKeyDisplayUiState> = internalUiState.asStateFlow()
|
val uiState: StateFlow<BackupKeyDisplayUiState> = internalUiState.asStateFlow()
|
||||||
|
|
||||||
@@ -29,6 +36,10 @@ class BackupKeyDisplayViewModel : ViewModel(), BackupKeyCredentialManagerHandler
|
|||||||
internalUiState.update { it.copy(keySaveState = newState) }
|
internalUiState.update { it.copy(keySaveState = newState) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
getKeyRotationLimit()
|
||||||
|
}
|
||||||
|
|
||||||
fun rotateBackupKey() {
|
fun rotateBackupKey() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
internalUiState.update { it.copy(rotationState = BackupKeyRotationState.GENERATING_KEY) }
|
internalUiState.update { it.copy(rotationState = BackupKeyRotationState.GENERATING_KEY) }
|
||||||
@@ -61,6 +72,21 @@ class BackupKeyDisplayViewModel : ViewModel(), BackupKeyCredentialManagerHandler
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getKeyRotationLimit() {
|
||||||
|
viewModelScope.launch(SignalDispatchers.IO) {
|
||||||
|
val result = BackupRepository.getKeyRotationLimit()
|
||||||
|
if (result is NetworkResult.Success) {
|
||||||
|
internalUiState.update {
|
||||||
|
it.copy(
|
||||||
|
canRotateKey = result.result.hasPermitsRemaining ?: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "Error while getting rotation limit: $result. Default to allowing key rotations.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun turnOffOptimizedStorageAndDownloadMedia() {
|
fun turnOffOptimizedStorageAndDownloadMedia() {
|
||||||
SignalStore.backup.optimizeStorage = false
|
SignalStore.backup.optimizeStorage = false
|
||||||
// TODO - flag to notify when complete.
|
// TODO - flag to notify when complete.
|
||||||
@@ -73,7 +99,8 @@ data class BackupKeyDisplayUiState(
|
|||||||
val keySaveState: BackupKeySaveState? = null,
|
val keySaveState: BackupKeySaveState? = null,
|
||||||
val isOptimizedStorageEnabled: Boolean = SignalStore.backup.optimizeStorage,
|
val isOptimizedStorageEnabled: Boolean = SignalStore.backup.optimizeStorage,
|
||||||
val rotationState: BackupKeyRotationState = BackupKeyRotationState.NOT_STARTED,
|
val rotationState: BackupKeyRotationState = BackupKeyRotationState.NOT_STARTED,
|
||||||
val stagedKeyRotations: StagedBackupKeyRotations? = null
|
val stagedKeyRotations: StagedBackupKeyRotations? = null,
|
||||||
|
val canRotateKey: Boolean = true
|
||||||
)
|
)
|
||||||
|
|
||||||
enum class BackupKeyRotationState {
|
enum class BackupKeyRotationState {
|
||||||
|
|||||||
@@ -324,6 +324,7 @@ class RemoteBackupsSettingsFragment : ComposeFragment() {
|
|||||||
setFragmentResultListener(BackupKeyDisplayFragment.AEP_ROTATION_KEY) { _, bundle ->
|
setFragmentResultListener(BackupKeyDisplayFragment.AEP_ROTATION_KEY) { _, bundle ->
|
||||||
val didRotate = bundle.getBoolean(BackupKeyDisplayFragment.AEP_ROTATION_KEY, false)
|
val didRotate = bundle.getBoolean(BackupKeyDisplayFragment.AEP_ROTATION_KEY, false)
|
||||||
if (didRotate) {
|
if (didRotate) {
|
||||||
|
viewModel.getKeyRotationLimit()
|
||||||
viewModel.requestSnackbar(RemoteBackupsSettingsState.Snackbar.AEP_KEY_ROTATED)
|
viewModel.requestSnackbar(RemoteBackupsSettingsState.Snackbar.AEP_KEY_ROTATED)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -656,6 +657,16 @@ private fun RemoteBackupsSettingsContent(
|
|||||||
onDeny = contentCallbacks::onFreeTierBackupSizeLearnMore
|
onDeny = contentCallbacks::onFreeTierBackupSizeLearnMore
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
RemoteBackupsSettingsState.Dialog.KEY_ROTATION_LIMIT_REACHED -> {
|
||||||
|
Dialogs.SimpleAlertDialog(
|
||||||
|
title = stringResource(R.string.MessageBackupsKeyRecordScreen__limit_reached_title),
|
||||||
|
body = stringResource(R.string.MessageBackupsKeyRecordScreen__limit_reached_body),
|
||||||
|
confirm = stringResource(R.string.MessageBackupsKeyRecordScreen__ok),
|
||||||
|
onConfirm = {},
|
||||||
|
onDismiss = contentCallbacks::onDialogDismissed
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val snackbarMessageId = remember(state.snackbar) {
|
val snackbarMessageId = remember(state.snackbar) {
|
||||||
|
|||||||
@@ -53,7 +53,8 @@ data class RemoteBackupsSettingsState(
|
|||||||
SKIP_MEDIA_RESTORE_PROTECTION,
|
SKIP_MEDIA_RESTORE_PROTECTION,
|
||||||
CANCEL_MEDIA_RESTORE_PROTECTION,
|
CANCEL_MEDIA_RESTORE_PROTECTION,
|
||||||
RESTORE_OVER_CELLULAR_PROTECTION,
|
RESTORE_OVER_CELLULAR_PROTECTION,
|
||||||
FREE_TIER_MEDIA_EXPLAINER
|
FREE_TIER_MEDIA_EXPLAINER,
|
||||||
|
KEY_ROTATION_LIMIT_REACHED
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class Snackbar {
|
enum class Snackbar {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import kotlinx.coroutines.launch
|
|||||||
import kotlinx.coroutines.reactive.asFlow
|
import kotlinx.coroutines.reactive.asFlow
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.signal.core.util.bytes
|
import org.signal.core.util.bytes
|
||||||
|
import org.signal.core.util.concurrent.SignalDispatchers
|
||||||
import org.signal.core.util.logging.Log
|
import org.signal.core.util.logging.Log
|
||||||
import org.signal.core.util.mebiBytes
|
import org.signal.core.util.mebiBytes
|
||||||
import org.signal.core.util.throttleLatest
|
import org.signal.core.util.throttleLatest
|
||||||
@@ -232,6 +233,22 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
|
|||||||
_state.update { it.copy(snackbar = snackbar) }
|
_state.update { it.copy(snackbar = snackbar) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getKeyRotationLimit() {
|
||||||
|
viewModelScope.launch(SignalDispatchers.IO) {
|
||||||
|
val result = BackupRepository.getKeyRotationLimit()
|
||||||
|
val canRotateKey = if (result is NetworkResult.Success) {
|
||||||
|
result.result.hasPermitsRemaining!!
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "Error while getting rotation limit: $result. Default to allowing key rotations.")
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canRotateKey) {
|
||||||
|
requestDialog(RemoteBackupsSettingsState.Dialog.KEY_ROTATION_LIMIT_REACHED)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun refresh() {
|
fun refresh() {
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
val id = SignalDatabase.inAppPayments.getLatestInAppPaymentByType(InAppPaymentType.RECURRING_BACKUP)?.id
|
val id = SignalDatabase.inAppPayments.getLatestInAppPaymentByType(InAppPaymentType.RECURRING_BACKUP)?.id
|
||||||
|
|||||||
@@ -8783,6 +8783,16 @@
|
|||||||
<!-- No backup to Restore tonal cta to skip restore -->
|
<!-- No backup to Restore tonal cta to skip restore -->
|
||||||
<string name="NoBackupToRestore_skip_restore">Skip restore</string>
|
<string name="NoBackupToRestore_skip_restore">Skip restore</string>
|
||||||
|
|
||||||
|
<!-- Dialog title when the recovery key change limit is reached -->
|
||||||
|
<string name="MessageBackupsKeyRecordScreen__limit_reached_title">Recovery key limit reached</string>
|
||||||
|
<!-- Dialog body when the recovery key change limit is reached -->
|
||||||
|
<string name="MessageBackupsKeyRecordScreen__limit_reached_body">You successfully created a new recovery key, but to prevent abuse the number of new keys you can create is limited. You will not be able to create another key for 7 days. Store your recovery key somewhere safe and secure.</string>
|
||||||
|
<!-- Dialog title when the recovery key change limit is exhausted and you cannot create a new key -->
|
||||||
|
<string name="MessageBackupsKeyRecordScreen__limit_exceeded_title">Can’t create new key</string>
|
||||||
|
<!-- Dialog body when the recovery key change limit is exhausted and you cannot create a new key. Placeholder is number of days remaining until they can create a new key. -->
|
||||||
|
<string name="MessageBackupsKeyRecordScreen__limit_exceeded_body">You’ve exhausted the number of new keys you can create. You will be able to create a new recovery key in 7 days. If you believe someone may be able to access your backup, you can choose to “Turn off and delete backup.”</string>
|
||||||
|
<!-- Button to acknowledge and dismiss dialog -->
|
||||||
|
<string name="MessageBackupsKeyRecordScreen__ok">OK</string>
|
||||||
<!-- Accessibility label for close search button in MainToolbar -->
|
<!-- Accessibility label for close search button in MainToolbar -->
|
||||||
<string name="MainToolbar__close_search_content_description">Close search</string>
|
<string name="MainToolbar__close_search_content_description">Close search</string>
|
||||||
<!-- Accessibility label for clear search query in MainToolbar -->
|
<!-- Accessibility label for clear search query in MainToolbar -->
|
||||||
|
|||||||
@@ -392,6 +392,18 @@ class ArchiveApi(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether the backup-id can currently be rotated
|
||||||
|
*
|
||||||
|
* GET /v1/archives/backupid/limits
|
||||||
|
* - 200: Successfully retrieved backup-id rotation limits
|
||||||
|
* - 403: Invalid account authentication
|
||||||
|
*/
|
||||||
|
fun getKeyRotationLimit(): NetworkResult<ArchiveKeyRotationLimitResponse> {
|
||||||
|
val request = WebSocketRequestMessage.get("/v1/archives/backupid/limits")
|
||||||
|
return NetworkResult.fromWebSocketRequest(authWebSocket, request, ArchiveKeyRotationLimitResponse::class)
|
||||||
|
}
|
||||||
|
|
||||||
private fun getCredentialPresentation(aci: ACI, archiveServiceAccess: ArchiveServiceAccess<*>): NetworkResult<CredentialPresentationData> {
|
private fun getCredentialPresentation(aci: ACI, archiveServiceAccess: ArchiveServiceAccess<*>): NetworkResult<CredentialPresentationData> {
|
||||||
return NetworkResult.fromLocal {
|
return NetworkResult.fromLocal {
|
||||||
val zkCredential = getZkCredential(aci, archiveServiceAccess)
|
val zkCredential = getZkCredential(aci, archiveServiceAccess)
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package org.whispersystems.signalservice.api.archive
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents the response when fetching the archive backup key rotation limits
|
||||||
|
*/
|
||||||
|
data class ArchiveKeyRotationLimitResponse(
|
||||||
|
@JsonProperty val hasPermitsRemaining: Boolean?,
|
||||||
|
@JsonProperty val retryAfterSeconds: Long?
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user