diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java index a8fcd210428..ca855cffb73 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java @@ -54,6 +54,7 @@ import org.thoughtcrime.securesms.emoji.JumboEmoji; import org.thoughtcrime.securesms.gcm.FcmFetchManager; import org.thoughtcrime.securesms.jobs.AccountConsistencyWorkerJob; +import org.thoughtcrime.securesms.jobs.BackupRefreshJob; import org.thoughtcrime.securesms.jobs.BackupSubscriptionCheckJob; import org.thoughtcrime.securesms.jobs.BuildExpirationConfirmationJob; import org.thoughtcrime.securesms.jobs.CheckServiceReachabilityJob; @@ -247,6 +248,7 @@ public void onForeground() { startAnrDetector(); SignalExecutors.BOUNDED.execute(() -> { + BackupRefreshJob.enqueueIfNecessary(); InAppPaymentAuthCheckJob.enqueueIfNeeded(); RemoteConfig.refreshIfNecessary(); RetrieveProfileJob.enqueueRoutineFetchIfNecessary(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt index 6433a5261be..6479c9af3df 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt @@ -132,6 +132,19 @@ object BackupRepository { } } + /** + * Refreshes backup via server + */ + fun refreshBackup(): NetworkResult { + return initBackupAndFetchAuth() + .then { accessPair -> + AppDependencies.archiveApi.refreshBackup( + aci = SignalStore.account.requireAci(), + archiveServiceAccess = accessPair.messageBackupAccess + ) + } + } + /** * Gets the free storage space in the device's data partition. */ diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupRefreshJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupRefreshJob.kt new file mode 100644 index 00000000000..5a3c4af7d48 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupRefreshJob.kt @@ -0,0 +1,105 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.jobs + +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.backup.v2.BackupRepository +import org.thoughtcrime.securesms.dependencies.AppDependencies +import org.thoughtcrime.securesms.jobmanager.Job +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.util.RemoteConfig +import org.whispersystems.signalservice.api.NetworkResult +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.milliseconds + +/** + * Notifies the server that the backup for the local user is still being used. + */ +class BackupRefreshJob private constructor( + parameters: Parameters +) : Job(parameters) { + + companion object { + private val TAG = Log.tag(BackupRefreshJob::class) + const val KEY = "BackupRefreshJob" + + private val TIME_BETWEEN_CHECKINS = 3.days + + @JvmStatic + fun enqueueIfNecessary() { + if (!canExecuteJob()) { + return + } + + val now = System.currentTimeMillis().milliseconds + val lastCheckIn = SignalStore.backup.lastCheckInMillis.milliseconds + + if ((now - lastCheckIn) >= TIME_BETWEEN_CHECKINS) { + AppDependencies.jobManager.add( + BackupRefreshJob( + parameters = Parameters.Builder() + .addConstraint(NetworkConstraint.KEY) + .setMaxAttempts(Parameters.UNLIMITED) + .setLifespan(3.days.inWholeMilliseconds) + .setMaxInstancesForFactory(1) + .build() + ) + ) + } + } + + private fun canExecuteJob(): Boolean { + if (!SignalStore.account.isRegistered) { + Log.i(TAG, "Account not registered. Exiting.") + return false + } + + if (!RemoteConfig.messageBackups) { + Log.i(TAG, "Backups are not enabled in remote config. Exiting.") + return false + } + + if (!SignalStore.backup.areBackupsEnabled) { + Log.i(TAG, "Backups have not been enabled on this device. Exiting.") + return false + } + + return true + } + } + + override fun run(): Result { + if (!canExecuteJob()) { + return Result.success() + } + + val result = BackupRepository.refreshBackup() + + return when (result) { + is NetworkResult.Success -> { + SignalStore.backup.lastCheckInMillis = System.currentTimeMillis() + Result.success() + } + else -> { + Log.w(TAG, "Failed to refresh backup with server.", result.getCause()) + Result.failure() + } + } + } + + override fun serialize(): ByteArray? = null + + override fun getFactoryKey(): String = KEY + + override fun onFailure() = Unit + + class Factory : Job.Factory { + override fun create(parameters: Parameters, serializedData: ByteArray?): BackupRefreshJob { + return BackupRefreshJob(parameters) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentRedemptionJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentRedemptionJob.kt index b8b16566fc1..d8d3160c3bf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentRedemptionJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentRedemptionJob.kt @@ -258,6 +258,7 @@ class InAppPaymentRedemptionJob private constructor( if (inAppPayment.type == InAppPaymentType.RECURRING_BACKUP) { Log.i(TAG, "Setting backup tier to PAID", true) SignalStore.backup.backupTier = MessageBackupTier.PAID + SignalStore.backup.lastCheckInMillis = System.currentTimeMillis() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java index 534ccfeeab5..1a6003b7cd9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java @@ -271,6 +271,7 @@ public static Map getJobFactories(@NonNull Application appl put(BackfillDigestsForDuplicatesMigrationJob.KEY, new BackfillDigestsForDuplicatesMigrationJob.Factory()); put(BackupJitterMigrationJob.KEY, new BackupJitterMigrationJob.Factory()); put(BackupNotificationMigrationJob.KEY, new BackupNotificationMigrationJob.Factory()); + put(BackupRefreshJob.KEY, new BackupRefreshJob.Factory()); put(BlobStorageLocationMigrationJob.KEY, new BlobStorageLocationMigrationJob.Factory()); put(CachedAttachmentsMigrationJob.KEY, new CachedAttachmentsMigrationJob.Factory()); put(ClearGlideCacheMigrationJob.KEY, new ClearGlideCacheMigrationJob.Factory()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupValues.kt index 178969e301f..4915219d346 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupValues.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupValues.kt @@ -35,6 +35,7 @@ class BackupValues(store: KeyValueStore) : SignalStoreValues(store) { private const val KEY_BACKUP_LAST_PROTO_SIZE = "backup.lastProtoSize" private const val KEY_BACKUP_TIER = "backup.backupTier" private const val KEY_LATEST_BACKUP_TIER = "backup.latestBackupTier" + private const val KEY_LAST_CHECK_IN_MILLIS = "backup.lastCheckInMilliseconds" private const val KEY_NEXT_BACKUP_TIME = "backup.nextBackupTime" private const val KEY_LAST_BACKUP_TIME = "backup.lastBackupTime" @@ -94,6 +95,8 @@ class BackupValues(store: KeyValueStore) : SignalStoreValues(store) { var userManuallySkippedMediaRestore: Boolean by booleanValue(KEY_USER_MANUALLY_SKIPPED_MEDIA_RESTORE, false) + var lastCheckInMillis: Long by longValue(KEY_LAST_CHECK_IN_MILLIS, 0L) + /** * Key used to backup messages. */ diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/ArchiveApi.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/ArchiveApi.kt index 9e898ebeb3c..d26d931f7cd 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/ArchiveApi.kt +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/ArchiveApi.kt @@ -150,6 +150,18 @@ class ArchiveApi(private val pushServiceSocket: PushServiceSocket) { } } + /** + * Backup keep-alive that informs the server that the backup is still in use. If a backup is not refreshed, it may be deleted + * after 30 days. + */ + fun refreshBackup(aci: ACI, archiveServiceAccess: ArchiveServiceAccess): NetworkResult { + return NetworkResult.fromFetch { + val zkCredential = getZkCredential(aci, archiveServiceAccess) + val presentationData = CredentialPresentationData.from(archiveServiceAccess.backupKey, aci, zkCredential, backupServerPublicParams) + pushServiceSocket.refreshBackup(presentationData.toArchiveCredentialPresentation()) + } + } + /** * Lists the media objects in the backup */ 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 fe1a11e1d33..d4e3817c576 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 @@ -563,6 +563,15 @@ public ArchiveGetBackupInfoResponse getArchiveBackupInfo(ArchiveCredentialPresen return JsonUtil.fromJson(response, ArchiveGetBackupInfoResponse.class); } + /** + * POST credential presentation to the server to keep backup alive. + */ + public void refreshBackup(ArchiveCredentialPresentation credentialPresentation) throws IOException { + Map headers = credentialPresentation.toHeaders(); + + makeServiceRequestWithoutAuthentication(ARCHIVE_INFO, "POST", null, headers, NO_HANDLER); + } + public List debugGetAllArchiveMediaItems(ArchiveCredentialPresentation credentialPresentation) throws IOException { List mediaObjects = new ArrayList<>();