1 /* 2 * Copyright 2022 Google LLC 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 package com.google.android.libraries.mobiledatadownload.file.backends; 17 18 import static java.util.concurrent.TimeUnit.SECONDS; 19 20 import android.annotation.SuppressLint; 21 import android.app.blob.BlobHandle; 22 import android.app.blob.BlobStoreManager; 23 import android.content.Context; 24 import android.net.Uri; 25 import android.os.ParcelFileDescriptor; 26 import android.util.Pair; 27 import com.google.android.libraries.mobiledatadownload.file.common.LimitExceededException; 28 import com.google.android.libraries.mobiledatadownload.file.spi.Backend; 29 import com.google.common.util.concurrent.MoreExecutors; 30 import java.io.Closeable; 31 import java.io.IOException; 32 import java.io.InputStream; 33 import java.io.OutputStream; 34 import java.util.List; 35 import java.util.concurrent.CompletableFuture; 36 import java.util.concurrent.ExecutionException; 37 import javax.annotation.Nullable; 38 39 /** 40 * Backend for accessing the Android blob Sharing Service. 41 * 42 * <p>For more details see {@link <internal>}. 43 * 44 * <p>Supports reading, writing, deleting and exists; every other operation provided by the {@link 45 * Backend} interface will throw {@link UnsupportedFileStorageOperation}. 46 * 47 * <p>Only available to Android SDK >= R. 48 */ 49 @SuppressLint({"NewApi", "WrongConstant"}) 50 @SuppressWarnings("AndroidJdkLibsChecker") 51 public final class BlobStoreBackend implements Backend { 52 private static final String SCHEME = "blobstore"; 53 // TODO(b/149260496): accept a custom label once available in the file config. 54 private static final String LABEL = "The file is shared to provide a better user experience"; 55 // TODO(b/149260496): accept a custom tag once available in the file config. 56 private static final String TAG = "File downloaded through MDDLib"; 57 // ExpiryDate set to 0 will be treated as expiryDate non-existent. 58 private static final long EXPIRY_DATE = 0; 59 60 private final BlobStoreManager blobStoreManager; 61 BlobStoreBackend(Context context)62 public BlobStoreBackend(Context context) { 63 this.blobStoreManager = (BlobStoreManager) context.getSystemService(Context.BLOB_STORE_SERVICE); 64 } 65 66 @Override name()67 public String name() { 68 return SCHEME; 69 } 70 71 /** The uri should be: "blobstore://<package_name>/<non_empty_checksum>". */ 72 @Override exists(Uri uri)73 public boolean exists(Uri uri) throws IOException { 74 boolean exists = false; 75 try (ParcelFileDescriptor pfd = openForReadInternal(uri)) { 76 if (pfd != null && pfd.getFileDescriptor().valid()) { 77 exists = true; 78 } 79 } catch (SecurityException e) { 80 // A SecurityException is thrown when the blob does not exist or the caller does not have 81 // access to it. 82 } 83 return exists; 84 } 85 86 /** The uri should be: "blobstore://<package_name>/<non_empty_checksum>". */ 87 @Override openForRead(Uri uri)88 public InputStream openForRead(Uri uri) throws IOException { 89 return new ParcelFileDescriptor.AutoCloseInputStream(openForReadInternal(uri)); 90 } 91 92 /** The uri should be: "blobstore://<package_name>/<non_empty_checksum>". */ 93 @Override openForNativeRead(Uri uri)94 public Pair<Uri, Closeable> openForNativeRead(Uri uri) throws IOException { 95 return FileDescriptorUri.fromParcelFileDescriptor(openForReadInternal(uri)); 96 } 97 openForReadInternal(Uri uri)98 private ParcelFileDescriptor openForReadInternal(Uri uri) throws IOException { 99 BlobUri.validateUri(uri); 100 byte[] checksum = BlobUri.getChecksum(uri.getPath()); 101 // TODO(b/149260496): add option to set a custom expiryDate in the uri. 102 BlobHandle blobHandle = BlobHandle.createWithSha256(checksum, LABEL, EXPIRY_DATE, TAG); 103 return blobStoreManager.openBlob(blobHandle); 104 } 105 106 /** 107 * Two possible URIs are accepted: 108 * 109 * <ul> 110 * <li>"blobstore://<package_name>/<non_empty_checksum>". A new blob will be written in the blob 111 * storage. 112 * <li>"blobstore://<package_name>/<non_empty_checksum>.lease?expiryDateSecs=<expiryDateSecs>.". 113 * A lease will be acquired on the blob specified by the encoded checksum. 114 * </ul> 115 * 116 * @throws MalformedUriException when the {@code uri} is malformed. 117 * @throws LimitExceededException when the caller is trying to create too many sessions, acquire 118 * too many leases or acquire leases on too much data. 119 * @throws IOException when there is an I/O error while writing the blob/lease. 120 */ 121 @Override openForWrite(Uri uri)122 public @Nullable OutputStream openForWrite(Uri uri) throws IOException { 123 BlobUri.validateUri(uri); 124 byte[] checksum = BlobUri.getChecksum(uri.getPath()); 125 try { 126 if (BlobUri.isLeaseUri(uri.getPath())) { 127 // TODO(b/149260496): pass blob size from MDD to the backend so that the backend can check 128 // it against the remaining quota. 129 if (blobStoreManager.getRemainingLeaseQuotaBytes() <= 0) { 130 throw new LimitExceededException( 131 "The caller is trying to acquire a lease on too much data."); 132 } 133 long expiryDateMillis = SECONDS.toMillis(BlobUri.getExpiryDateSecs(uri)); 134 acquireLease(checksum, expiryDateMillis); 135 return null; 136 } 137 138 BlobHandle blobHandle = BlobHandle.createWithSha256(checksum, LABEL, EXPIRY_DATE, TAG); 139 long sessionId = blobStoreManager.createSession(blobHandle); 140 BlobStoreManager.Session session = blobStoreManager.openSession(sessionId); 141 session.allowPublicAccess(); 142 return new SuperFirstAutoCloseOutputStream(session.openWrite(0, -1), session); 143 } catch (android.os.LimitExceededException e) { 144 throw new LimitExceededException(e); 145 } catch (IllegalStateException e) { 146 throw new IOException("Failed to write into BlobStoreManager", e); 147 } 148 } 149 150 /** 151 * Releases the lease(s) on the blob(s) specified through the {@code uri}. 152 * 153 * <p>Two possible URIs are accepted: 154 * 155 * <ul> 156 * <li>"blobstore://<package_name>/<non_empty_checksum>". The lease on the blob with checksum 157 * <non_empty_checksum> will be released. 158 * <li>"blobstore://<package_name>/*.lease.". All leases owned by calling package in the blob 159 * shared storage will be released. 160 * </ul> 161 */ 162 @Override deleteFile(Uri uri)163 public void deleteFile(Uri uri) throws IOException { 164 BlobUri.validateUri(uri); 165 if (BlobUri.isAllLeasesUri(uri.getPath())) { 166 releaseAllLeases(); 167 return; 168 } 169 byte[] checksum = BlobUri.getChecksum(uri.getPath()); 170 releaseLease(checksum); 171 } 172 releaseAllLeases()173 private void releaseAllLeases() throws IOException { 174 List<BlobHandle> blobHandles = blobStoreManager.getLeasedBlobs(); 175 for (BlobHandle blobHandle : blobHandles) { 176 releaseLease(blobHandle.getSha256Digest()); 177 } 178 } 179 180 @Override isDirectory(Uri uri)181 public boolean isDirectory(Uri uri) { 182 return false; 183 } 184 acquireLease(byte[] checksum, long expiryDateMillis)185 private void acquireLease(byte[] checksum, long expiryDateMillis) throws IOException { 186 BlobHandle blobHandle = BlobHandle.createWithSha256(checksum, LABEL, EXPIRY_DATE, TAG); 187 // TODO(b/149260496): remove hardcoded description. 188 // NOTE: The lease description is meant for specifying why the app needs the data blob and 189 // should be geared towards end users. 190 blobStoreManager.acquireLease( 191 blobHandle, 192 "String description needed for providing a better user experience", 193 expiryDateMillis); 194 } 195 releaseLease(byte[] checksum)196 private void releaseLease(byte[] checksum) throws IOException { 197 BlobHandle blobHandle = BlobHandle.createWithSha256(checksum, LABEL, EXPIRY_DATE, TAG); 198 try { 199 blobStoreManager.releaseLease(blobHandle); 200 } catch (SecurityException | IllegalStateException | IllegalArgumentException e) { 201 throw new IOException("Failed to release the lease", e); 202 } 203 } 204 205 // NOTE: ParcelFileDescriptor.AutoCloseOutput|InputStream are affected by bug b/118316956. This 206 // was fixed in Android Q and this class requires Android R, so they are safe to use. 207 private static class SuperFirstAutoCloseOutputStream 208 extends ParcelFileDescriptor.AutoCloseOutputStream { 209 private final BlobStoreManager.Session session; 210 private boolean commitAttempted = false; 211 SuperFirstAutoCloseOutputStream( ParcelFileDescriptor pfd, BlobStoreManager.Session session)212 public SuperFirstAutoCloseOutputStream( 213 ParcelFileDescriptor pfd, BlobStoreManager.Session session) { 214 super(pfd); 215 this.session = session; 216 } 217 218 @Override close()219 public void close() throws IOException { 220 try { 221 super.close(); 222 } finally { 223 closeEverything(); 224 } 225 } 226 closeEverything()227 private void closeEverything() throws IOException { 228 int result = 0; 229 Exception cause = null; 230 if (!commitAttempted) { 231 // Commit throws IllegalStateException if the session was already finalized, so avoid 232 // calling it more than once. We can assume Closeable closes are idempotent. 233 commitAttempted = true; 234 try { 235 CompletableFuture<Integer> callback = new CompletableFuture<>(); 236 session.commit(MoreExecutors.directExecutor(), callback::complete); 237 result = callback.get(); 238 } catch (ExecutionException | InterruptedException | RuntimeException e) { 239 result = -1; 240 cause = e; 241 } 242 } 243 try (session) { 244 if (result != 0) { 245 throw new IOException("Commit operation failed", cause); 246 } 247 } 248 } 249 } 250 } 251