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 67fdca80f5..4416414bac 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 @@ -26,7 +26,6 @@ import org.thoughtcrime.securesms.dependencies.ApplicationDependencies import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.recipients.RecipientId import org.whispersystems.signalservice.api.NetworkResult -import org.whispersystems.signalservice.api.archive.ArchiveGetBackupInfoResponse import org.whispersystems.signalservice.api.archive.ArchiveServiceCredential import org.whispersystems.signalservice.api.push.ServiceId.ACI import org.whispersystems.signalservice.api.push.ServiceId.PNI @@ -168,7 +167,7 @@ object BackupRepository { /** * Returns an object with details about the remote backup state. */ - fun getRemoteBackupState(): NetworkResult { + fun getRemoteBackupState(): NetworkResult { val api = ApplicationDependencies.getSignalServiceAccountManager().archiveApi val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey() @@ -182,6 +181,18 @@ object BackupRepository { } .then { credential -> api.getBackupInfo(backupKey, credential) + .map { it to credential } + } + .then { pair -> + val (info, credential) = pair + api.debugGetUploadedMediaItemMetadata(backupKey, credential) + .also { Log.i(TAG, "MediaItemMetadataResult: $it") } + .map { mediaObjects -> + BackupMetadata( + usedSpace = info.usedSpace ?: 0, + mediaCount = mediaObjects.size.toLong() + ) + } } } @@ -255,3 +266,8 @@ class BackupState { val chatIdToBackupRecipientId = HashMap() val callIdToType = HashMap() } + +class BackupMetadata( + val usedSpace: Long, + val mediaCount: Long +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/backup/InternalBackupPlaygroundFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/backup/InternalBackupPlaygroundFragment.kt index 1fd3173714..5b32b5e547 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/backup/InternalBackupPlaygroundFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/backup/InternalBackupPlaygroundFragment.kt @@ -200,7 +200,7 @@ fun Screen( when (state.remoteBackupState) { is InternalBackupPlaygroundViewModel.RemoteBackupState.Available -> { - StateLabel("Exists/allocated. Space used by media: ${state.remoteBackupState.response.usedSpace ?: 0} bytes (${state.remoteBackupState.response.usedSpace?.bytes?.inMebiBytes?.roundedString(3) ?: 0} MiB)") + StateLabel("Exists/allocated. ${state.remoteBackupState.response.mediaCount} media items, using ${state.remoteBackupState.response.usedSpace} bytes (${state.remoteBackupState.response.usedSpace.bytes.inMebiBytes.roundedString(3)} MiB)") } InternalBackupPlaygroundViewModel.RemoteBackupState.GeneralError -> { StateLabel("Hit an unknown error. Check the logs.") diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/backup/InternalBackupPlaygroundViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/backup/InternalBackupPlaygroundViewModel.kt index b3d8f7478e..58d9626a06 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/backup/InternalBackupPlaygroundViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/backup/InternalBackupPlaygroundViewModel.kt @@ -15,10 +15,10 @@ import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.kotlin.plusAssign import io.reactivex.rxjava3.schedulers.Schedulers import org.signal.libsignal.zkgroup.profiles.ProfileKey +import org.thoughtcrime.securesms.backup.v2.BackupMetadata import org.thoughtcrime.securesms.backup.v2.BackupRepository import org.thoughtcrime.securesms.recipients.Recipient import org.whispersystems.signalservice.api.NetworkResult -import org.whispersystems.signalservice.api.archive.ArchiveGetBackupInfoResponse import java.io.ByteArrayInputStream import java.io.InputStream @@ -137,6 +137,6 @@ class InternalBackupPlaygroundViewModel : ViewModel() { object Unknown : RemoteBackupState() object NotFound : RemoteBackupState() object GeneralError : RemoteBackupState() - data class Available(val response: ArchiveGetBackupInfoResponse) : RemoteBackupState() + data class Available(val response: BackupMetadata) : RemoteBackupState() } } 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 bb89287395..aa00e4cc33 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 @@ -13,6 +13,7 @@ import org.signal.libsignal.zkgroup.backups.BackupAuthCredential import org.signal.libsignal.zkgroup.backups.BackupAuthCredentialRequestContext import org.signal.libsignal.zkgroup.backups.BackupAuthCredentialResponse import org.whispersystems.signalservice.api.NetworkResult +import org.whispersystems.signalservice.api.archive.ArchiveGetMediaItemsResponse.StoredMediaObject import org.whispersystems.signalservice.api.backup.BackupKey import org.whispersystems.signalservice.api.push.ServiceId.ACI import org.whispersystems.signalservice.internal.push.PushServiceSocket @@ -120,6 +121,43 @@ class ArchiveApi( } } + /** + * Retrieves all media items in the user's archive. Note that this could be a very large number of items, making this only suitable for debugging. + * Use [getArchiveMediaItemsPage] in production. + */ + fun debugGetUploadedMediaItemMetadata(backupKey: BackupKey, serviceCredential: ArchiveServiceCredential): NetworkResult> { + return NetworkResult.fromFetch { + val zkCredential = getZkCredential(backupKey, serviceCredential) + val presentationData = CredentialPresentationData.from(backupKey, zkCredential, backupServerPublicParams) + val credentialPresentation = presentationData.toArchiveCredentialPresentation() + + val mediaObjects: MutableList = ArrayList() + + var cursor: String? = null + do { + val response: ArchiveGetMediaItemsResponse = pushServiceSocket.getArchiveMediaItemsPage(credentialPresentation, 512, cursor) + mediaObjects += response.storedMediaObjects + cursor = response.cursor + } while (cursor != null) + + mediaObjects + } + } + + /** + * Retrieves a page of media items in the user's archive. + * @param limit The maximum number of items to return. + * @param cursor A token that can be read from your previous response, telling the server where to start the next page. + */ + fun getArchiveMediaItemsPage(backupKey: BackupKey, serviceCredential: ArchiveServiceCredential, limit: Int, cursor: String): NetworkResult { + return NetworkResult.fromFetch { + val zkCredential = getZkCredential(backupKey, serviceCredential) + val presentationData = CredentialPresentationData.from(backupKey, zkCredential, backupServerPublicParams) + + pushServiceSocket.getArchiveMediaItemsPage(presentationData.toArchiveCredentialPresentation(), 512, cursor) + } + } + private fun getZkCredential(backupKey: BackupKey, serviceCredential: ArchiveServiceCredential): BackupAuthCredential { val backupAuthResponse = BackupAuthCredentialResponse(serviceCredential.credential) val backupRequestContext = BackupAuthCredentialRequestContext.create(backupKey.value, aci.rawUuid) @@ -127,7 +165,7 @@ class ArchiveApi( return backupRequestContext.receiveResponse( backupAuthResponse, backupServerPublicParams, - 10 + 20 ) } diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/ArchiveGetMediaItemsResponse.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/ArchiveGetMediaItemsResponse.kt new file mode 100644 index 0000000000..a55b4d3bc3 --- /dev/null +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/ArchiveGetMediaItemsResponse.kt @@ -0,0 +1,22 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.signalservice.api.archive + +import com.fasterxml.jackson.annotation.JsonProperty + +/** + * Response body for getting the media items stored in the user's archive. + */ +class ArchiveGetMediaItemsResponse( + @JsonProperty val storedMediaObjects: List, + @JsonProperty val cursor: String? +) { + class StoredMediaObject( + @JsonProperty val cdn: Int, + @JsonProperty val mediaId: String, + @JsonProperty val objectLength: Long + ) +} 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 204b7503a3..f845851842 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 @@ -44,6 +44,7 @@ import org.whispersystems.signalservice.api.account.PniKeyDistributionRequest; import org.whispersystems.signalservice.api.account.PreKeyCollection; import org.whispersystems.signalservice.api.account.PreKeyUpload; import org.whispersystems.signalservice.api.archive.ArchiveCredentialPresentation; +import org.whispersystems.signalservice.api.archive.ArchiveGetMediaItemsResponse; import org.whispersystems.signalservice.api.archive.ArchiveServiceCredentialsResponse; import org.whispersystems.signalservice.api.archive.ArchiveGetBackupInfoResponse; import org.whispersystems.signalservice.api.archive.ArchiveMessageBackupUploadFormResponse; @@ -161,6 +162,7 @@ import java.nio.charset.StandardCharsets; import java.security.KeyManagementException; import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; +import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; @@ -311,6 +313,7 @@ public class PushServiceSocket { private static final String ARCHIVE_PUBLIC_KEY = "/v1/archives/keys"; private static final String ARCHIVE_INFO = "/v1/archives"; private static final String ARCHIVE_MESSAGE_UPLOAD_FORM = "/v1/archives/upload/form"; + private static final String ARCHIVE_MEDIA_LIST = "/v1/archives/media?limit=%d"; private static final String CALL_LINK_CREATION_AUTH = "/v1/call-link/create-auth"; private static final String SERVER_DELIVERED_TIMESTAMP_HEADER = "X-Signal-Timestamp"; @@ -512,6 +515,39 @@ public class PushServiceSocket { return JsonUtil.fromJson(response, ArchiveGetBackupInfoResponse.class); } + public List debugGetAllArchiveMediaItems(ArchiveCredentialPresentation credentialPresentation) throws IOException { + List mediaObjects = new ArrayList<>(); + + String cursor = null; + do { + ArchiveGetMediaItemsResponse response = getArchiveMediaItemsPage(credentialPresentation, 512, cursor); + mediaObjects.addAll(response.getStoredMediaObjects()); + cursor = response.getCursor(); + } while (cursor != null); + + return mediaObjects; + } + + /** + * Retrieves a page of media items in the user's archive. + * @param cursor A token that can be read from your previous response, telling the server where to start the next page. + */ + public ArchiveGetMediaItemsResponse getArchiveMediaItemsPage(ArchiveCredentialPresentation credentialPresentation, int limit, String cursor) throws IOException { + Map headers = new HashMap<>(); + headers.put("X-Signal-ZK-Auth", Base64.encodeWithPadding(credentialPresentation.getPresentation())); + headers.put("X-Signal-ZK-Auth-Signature", Base64.encodeWithPadding(credentialPresentation.getSignedPresentation())); + + String url = String.format(Locale.US, ARCHIVE_MEDIA_LIST, limit); + + if (cursor != null) { + url += "&cursor=" + cursor; + } + + String response = makeServiceRequestWithoutAuthentication(url, "GET", null, headers, NO_HANDLER); + + return JsonUtil.fromJson(response, ArchiveGetMediaItemsResponse.class); + } + public ArchiveMessageBackupUploadFormResponse getArchiveMessageBackupUploadForm(ArchiveCredentialPresentation credentialPresentation) throws IOException { Map headers = new HashMap<>(); headers.put("X-Signal-ZK-Auth", Base64.encodeWithPadding(credentialPresentation.getPresentation()));