• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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