1 /* 2 * Copyright (C) 2017 The Android Open Source Project 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 17 package com.android.documentsui.archives; 18 19 import android.content.Context; 20 import android.net.Uri; 21 import android.os.CancellationSignal; 22 import android.os.FileUtils; 23 import android.os.OperationCanceledException; 24 import android.os.ParcelFileDescriptor; 25 import android.os.ParcelFileDescriptor.AutoCloseOutputStream; 26 import android.provider.DocumentsContract.Document; 27 import android.util.Log; 28 29 import androidx.annotation.GuardedBy; 30 import androidx.annotation.Nullable; 31 import androidx.annotation.VisibleForTesting; 32 33 import java.io.FileNotFoundException; 34 import java.io.IOException; 35 36 import java.util.ArrayList; 37 import java.util.HashSet; 38 import java.util.Set; 39 import java.util.concurrent.ExecutorService; 40 import java.util.concurrent.Executors; 41 import java.util.concurrent.RejectedExecutionException; 42 import java.util.concurrent.TimeUnit; 43 44 import org.apache.commons.compress.archivers.zip.ZipArchiveEntry; 45 import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream; 46 47 /** 48 * Provides basic implementation for creating archives. 49 * 50 * <p>This class is thread safe. 51 */ 52 public class WriteableArchive extends Archive { 53 private static final String TAG = "WriteableArchive"; 54 55 @GuardedBy("mEntries") 56 private final Set<String> mPendingEntries = new HashSet<>(); 57 private final ExecutorService mExecutor = Executors.newSingleThreadExecutor(); 58 @GuardedBy("mEntries") 59 private final ZipArchiveOutputStream mZipOutputStream; 60 private final AutoCloseOutputStream mOutputStream; 61 62 /** 63 * Takes ownership of the passed file descriptor. 64 */ WriteableArchive( Context context, ParcelFileDescriptor fd, Uri archiveUri, int accessMode, @Nullable Uri notificationUri)65 private WriteableArchive( 66 Context context, 67 ParcelFileDescriptor fd, 68 Uri archiveUri, 69 int accessMode, 70 @Nullable Uri notificationUri) 71 throws IOException { 72 super(context, archiveUri, accessMode, notificationUri); 73 if (!supportsAccessMode(accessMode)) { 74 throw new IllegalStateException("Unsupported access mode."); 75 } 76 77 addEntry(null /* no parent */, new ZipArchiveEntry("/")); // Root entry. 78 mOutputStream = new AutoCloseOutputStream(fd); 79 mZipOutputStream = new ZipArchiveOutputStream(mOutputStream); 80 } 81 addEntry(@ullable ZipArchiveEntry parentEntry, ZipArchiveEntry entry)82 private void addEntry(@Nullable ZipArchiveEntry parentEntry, ZipArchiveEntry entry) { 83 final String entryPath = getEntryPath(entry); 84 synchronized (mEntries) { 85 if (entry.isDirectory()) { 86 if (!mTree.containsKey(entryPath)) { 87 mTree.put(entryPath, new ArrayList<>()); 88 } 89 } 90 mEntries.put(entryPath, entry); 91 if (parentEntry != null) { 92 mTree.get(getEntryPath(parentEntry)).add(entry); 93 } 94 } 95 } 96 97 /** 98 * @see ParcelFileDescriptor 99 */ supportsAccessMode(int accessMode)100 public static boolean supportsAccessMode(int accessMode) { 101 return accessMode == ParcelFileDescriptor.MODE_WRITE_ONLY; 102 } 103 104 /** 105 * Creates a DocumentsArchive instance for writing into an archive file passed 106 * as a file descriptor. 107 * 108 * This method takes ownership for the passed descriptor. The caller must 109 * not use it after passing. 110 * 111 * @param context Context of the provider. 112 * @param descriptor File descriptor for the archive's contents. 113 * @param archiveUri Uri of the archive document. 114 * @param accessMode Access mode for the archive {@see ParcelFileDescriptor}. 115 * @param notificationUri notificationUri Uri for notifying that the archive file has changed. 116 */ 117 @VisibleForTesting createForParcelFileDescriptor( Context context, ParcelFileDescriptor descriptor, Uri archiveUri, int accessMode, @Nullable Uri notificationUri)118 public static WriteableArchive createForParcelFileDescriptor( 119 Context context, ParcelFileDescriptor descriptor, Uri archiveUri, int accessMode, 120 @Nullable Uri notificationUri) 121 throws IOException { 122 try { 123 return new WriteableArchive(context, descriptor, archiveUri, accessMode, 124 notificationUri); 125 } catch (Exception e) { 126 // Since the method takes ownership of the passed descriptor, close it 127 // on exception. 128 FileUtils.closeQuietly(descriptor); 129 throw e; 130 } 131 } 132 133 @Override 134 @VisibleForTesting createDocument(String parentDocumentId, String mimeType, String displayName)135 public String createDocument(String parentDocumentId, String mimeType, String displayName) 136 throws FileNotFoundException { 137 final ArchiveId parsedParentId = ArchiveId.fromDocumentId(parentDocumentId); 138 MorePreconditions.checkArgumentEquals(mArchiveUri, parsedParentId.mArchiveUri, 139 "Mismatching archive Uri. Expected: %s, actual: %s."); 140 141 final boolean isDirectory = Document.MIME_TYPE_DIR.equals(mimeType); 142 ZipArchiveEntry entry; 143 String entryPath; 144 145 synchronized (mEntries) { 146 final ZipArchiveEntry parentEntry = 147 (ZipArchiveEntry) mEntries.get(parsedParentId.mPath); 148 149 if (parentEntry == null) { 150 throw new FileNotFoundException(); 151 } 152 153 if (displayName.indexOf("/") != -1 || ".".equals(displayName) 154 || "..".equals(displayName)) { 155 throw new IllegalStateException("Display name contains invalid characters."); 156 } 157 158 if ("".equals(displayName)) { 159 throw new IllegalStateException("Display name cannot be empty."); 160 } 161 162 163 assert(parentEntry.getName().endsWith("/")); 164 final String parentName = "/".equals(parentEntry.getName()) 165 ? "" : parentEntry.getName(); 166 final String entryName = parentName + displayName + (isDirectory ? "/" : ""); 167 entry = new ZipArchiveEntry(entryName); 168 entryPath = getEntryPath(entry); 169 entry.setSize(0); 170 171 if (mEntries.get(entryPath) != null) { 172 throw new IllegalStateException("The document already exist: " + entryPath); 173 } 174 addEntry(parentEntry, entry); 175 } 176 177 if (!isDirectory) { 178 // For files, the contents will be written via openDocument. Since the contents 179 // must be immediately followed by the contents, defer adding the header until 180 // openDocument. All pending entires which haven't been written will be added 181 // to the ZIP file in close(). 182 synchronized (mEntries) { 183 mPendingEntries.add(entryPath); 184 } 185 } else { 186 try { 187 synchronized (mEntries) { 188 mZipOutputStream.putArchiveEntry(entry); 189 mZipOutputStream.closeArchiveEntry(); 190 } 191 } catch (IOException e) { 192 throw new IllegalStateException( 193 "Failed to create a file in the archive: " + entryPath, e); 194 } 195 } 196 197 return createArchiveId(entryPath).toDocumentId(); 198 } 199 200 @Override openDocument( String documentId, String mode, @Nullable final CancellationSignal signal)201 public ParcelFileDescriptor openDocument( 202 String documentId, String mode, @Nullable final CancellationSignal signal) 203 throws FileNotFoundException { 204 MorePreconditions.checkArgumentEquals("w", mode, 205 "Invalid mode. Only writing \"w\" supported, but got: \"%s\"."); 206 final ArchiveId parsedId = ArchiveId.fromDocumentId(documentId); 207 MorePreconditions.checkArgumentEquals(mArchiveUri, parsedId.mArchiveUri, 208 "Mismatching archive Uri. Expected: %s, actual: %s."); 209 210 final ZipArchiveEntry entry; 211 synchronized (mEntries) { 212 entry = (ZipArchiveEntry) mEntries.get(parsedId.mPath); 213 if (entry == null) { 214 throw new FileNotFoundException(); 215 } 216 217 if (!mPendingEntries.contains(parsedId.mPath)) { 218 throw new IllegalStateException("Files can be written only once."); 219 } 220 mPendingEntries.remove(parsedId.mPath); 221 } 222 223 ParcelFileDescriptor[] pipe; 224 try { 225 pipe = ParcelFileDescriptor.createReliablePipe(); 226 } catch (IOException e) { 227 // Ideally we'd simply throw IOException to the caller, but for consistency 228 // with DocumentsProvider::openDocument, converting it to IllegalStateException. 229 throw new IllegalStateException("Failed to open the document.", e); 230 } 231 final ParcelFileDescriptor inputPipe = pipe[0]; 232 233 try { 234 mExecutor.execute( 235 new Runnable() { 236 @Override 237 public void run() { 238 try (final ParcelFileDescriptor.AutoCloseInputStream inputStream = 239 new ParcelFileDescriptor.AutoCloseInputStream(inputPipe)) { 240 try { 241 synchronized (mEntries) { 242 mZipOutputStream.putArchiveEntry(entry); 243 final byte buffer[] = new byte[32 * 1024]; 244 int bytes; 245 long size = 0; 246 while ((bytes = inputStream.read(buffer)) != -1) { 247 if (signal != null) { 248 signal.throwIfCanceled(); 249 } 250 mZipOutputStream.write(buffer, 0, bytes); 251 size += bytes; 252 } 253 entry.setSize(size); 254 mZipOutputStream.closeArchiveEntry(); 255 } 256 } catch (IOException e) { 257 // Catch the exception before the outer try-with-resource closes 258 // the pipe with close() instead of closeWithError(). 259 try { 260 Log.e(TAG, "Failed while writing to a file.", e); 261 inputPipe.closeWithError("Writing failure."); 262 } catch (IOException e2) { 263 Log.e(TAG, "Failed to close the pipe after an error.", e2); 264 } 265 } 266 } catch (OperationCanceledException e) { 267 // Cancelled gracefully. 268 } catch (IOException e) { 269 // Input stream auto-close error. Close quietly. 270 } 271 } 272 }); 273 } catch (RejectedExecutionException e) { 274 FileUtils.closeQuietly(pipe[0]); 275 FileUtils.closeQuietly(pipe[1]); 276 throw new IllegalStateException("Failed to initialize pipe."); 277 } 278 279 return pipe[1]; 280 } 281 282 /** 283 * Closes the archive. Blocks until all enqueued pipes are completed. 284 */ 285 @Override close()286 public void close() { 287 // Waits until all enqueued pipe requests are completed. 288 mExecutor.shutdown(); 289 try { 290 final boolean result = mExecutor.awaitTermination( 291 Long.MAX_VALUE, TimeUnit.MILLISECONDS); 292 assert(result); 293 } catch (InterruptedException e) { 294 Log.e(TAG, "Opened files failed to be fullly written.", e); 295 } 296 297 // Flush all pending entries. They will all have empty size. 298 synchronized (mEntries) { 299 for (final String path : mPendingEntries) { 300 try { 301 mZipOutputStream.putArchiveEntry(mEntries.get(path)); 302 mZipOutputStream.closeArchiveEntry(); 303 } catch (IOException e) { 304 Log.e(TAG, "Failed to flush empty entries.", e); 305 } 306 } 307 308 try { 309 mZipOutputStream.close(); 310 } catch (IOException e) { 311 Log.e(TAG, "Failed while closing the ZIP file.", e); 312 } 313 } 314 315 FileUtils.closeQuietly(mOutputStream); 316 } 317 } 318