Tomasz Mikolajewski | dc235d2 | 2017-01-25 15:07:31 +0900 | [diff] [blame] | 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; |
felkachang | 1556c36 | 2018-08-30 16:21:59 +0800 | [diff] [blame^] | 22 | import android.os.FileUtils; |
Tomasz Mikolajewski | dc235d2 | 2017-01-25 15:07:31 +0900 | [diff] [blame] | 23 | import android.os.OperationCanceledException; |
| 24 | import android.os.ParcelFileDescriptor; |
felkachang | 1556c36 | 2018-08-30 16:21:59 +0800 | [diff] [blame^] | 25 | import android.os.ParcelFileDescriptor.AutoCloseOutputStream; |
Tomasz Mikolajewski | dc235d2 | 2017-01-25 15:07:31 +0900 | [diff] [blame] | 26 | import android.provider.DocumentsContract.Document; |
Tomasz Mikolajewski | dc235d2 | 2017-01-25 15:07:31 +0900 | [diff] [blame] | 27 | import android.util.Log; |
| 28 | |
Jeff Sharkey | a4ff00f | 2018-07-09 14:57:51 -0600 | [diff] [blame] | 29 | import androidx.annotation.GuardedBy; |
felkachang | 1556c36 | 2018-08-30 16:21:59 +0800 | [diff] [blame^] | 30 | import androidx.annotation.Nullable; |
KOUSHIK PANUGANTI | 6ca7acc | 2018-04-17 16:00:10 -0700 | [diff] [blame] | 31 | import androidx.annotation.VisibleForTesting; |
Tomasz Mikolajewski | dc235d2 | 2017-01-25 15:07:31 +0900 | [diff] [blame] | 32 | |
Tomasz Mikolajewski | dc235d2 | 2017-01-25 15:07:31 +0900 | [diff] [blame] | 33 | import java.io.FileNotFoundException; |
Tomasz Mikolajewski | dc235d2 | 2017-01-25 15:07:31 +0900 | [diff] [blame] | 34 | import java.io.IOException; |
felkachang | 1556c36 | 2018-08-30 16:21:59 +0800 | [diff] [blame^] | 35 | |
Tomasz Mikolajewski | dc235d2 | 2017-01-25 15:07:31 +0900 | [diff] [blame] | 36 | import java.util.ArrayList; |
| 37 | import java.util.HashSet; |
Tomasz Mikolajewski | dc235d2 | 2017-01-25 15:07:31 +0900 | [diff] [blame] | 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; |
Tomasz Mikolajewski | dc235d2 | 2017-01-25 15:07:31 +0900 | [diff] [blame] | 43 | import java.util.zip.ZipOutputStream; |
| 44 | |
felkachang | 1556c36 | 2018-08-30 16:21:59 +0800 | [diff] [blame^] | 45 | import org.apache.commons.compress.archivers.zip.ZipArchiveEntry; |
| 46 | |
Tomasz Mikolajewski | dc235d2 | 2017-01-25 15:07:31 +0900 | [diff] [blame] | 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 ZipOutputStream mZipOutputStream; |
Tomasz Mikolajewski | a18ea7e | 2017-02-14 16:05:38 +0900 | [diff] [blame] | 60 | private final AutoCloseOutputStream mOutputStream; |
Tomasz Mikolajewski | dc235d2 | 2017-01-25 15:07:31 +0900 | [diff] [blame] | 61 | |
Tomasz Mikolajewski | a18ea7e | 2017-02-14 16:05:38 +0900 | [diff] [blame] | 62 | /** |
| 63 | * Takes ownership of the passed file descriptor. |
| 64 | */ |
Tomasz Mikolajewski | dc235d2 | 2017-01-25 15:07:31 +0900 | [diff] [blame] | 65 | private WriteableArchive( |
| 66 | Context context, |
Tomasz Mikolajewski | a18ea7e | 2017-02-14 16:05:38 +0900 | [diff] [blame] | 67 | ParcelFileDescriptor fd, |
Tomasz Mikolajewski | dc235d2 | 2017-01-25 15:07:31 +0900 | [diff] [blame] | 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 | |
felkachang | 1556c36 | 2018-08-30 16:21:59 +0800 | [diff] [blame^] | 77 | addEntry(null /* no parent */, new ZipArchiveEntry("/")); // Root entry. |
Tomasz Mikolajewski | a18ea7e | 2017-02-14 16:05:38 +0900 | [diff] [blame] | 78 | mOutputStream = new AutoCloseOutputStream(fd); |
| 79 | mZipOutputStream = new ZipOutputStream(mOutputStream); |
Tomasz Mikolajewski | dc235d2 | 2017-01-25 15:07:31 +0900 | [diff] [blame] | 80 | } |
| 81 | |
felkachang | 1556c36 | 2018-08-30 16:21:59 +0800 | [diff] [blame^] | 82 | private void addEntry(@Nullable ZipArchiveEntry parentEntry, ZipArchiveEntry entry) { |
Tomasz Mikolajewski | dc235d2 | 2017-01-25 15:07:31 +0900 | [diff] [blame] | 83 | final String entryPath = getEntryPath(entry); |
| 84 | synchronized (mEntries) { |
| 85 | if (entry.isDirectory()) { |
| 86 | if (!mTree.containsKey(entryPath)) { |
felkachang | 1556c36 | 2018-08-30 16:21:59 +0800 | [diff] [blame^] | 87 | mTree.put(entryPath, new ArrayList<ZipArchiveEntry>()); |
Tomasz Mikolajewski | dc235d2 | 2017-01-25 15:07:31 +0900 | [diff] [blame] | 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 | */ |
| 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}. |
felkachang | 1556c36 | 2018-08-30 16:21:59 +0800 | [diff] [blame^] | 115 | * @param notificationUri notificationUri Uri for notifying that the archive file has changed. |
Tomasz Mikolajewski | dc235d2 | 2017-01-25 15:07:31 +0900 | [diff] [blame] | 116 | */ |
| 117 | @VisibleForTesting |
| 118 | public static WriteableArchive createForParcelFileDescriptor( |
| 119 | Context context, ParcelFileDescriptor descriptor, Uri archiveUri, int accessMode, |
| 120 | @Nullable Uri notificationUri) |
| 121 | throws IOException { |
Tomasz Mikolajewski | dc235d2 | 2017-01-25 15:07:31 +0900 | [diff] [blame] | 122 | try { |
Tomasz Mikolajewski | a18ea7e | 2017-02-14 16:05:38 +0900 | [diff] [blame] | 123 | return new WriteableArchive(context, descriptor, archiveUri, accessMode, |
| 124 | notificationUri); |
Tomasz Mikolajewski | dc235d2 | 2017-01-25 15:07:31 +0900 | [diff] [blame] | 125 | } catch (Exception e) { |
| 126 | // Since the method takes ownership of the passed descriptor, close it |
| 127 | // on exception. |
Jeff Sharkey | 94785ef | 2018-07-09 16:37:41 -0600 | [diff] [blame] | 128 | FileUtils.closeQuietly(descriptor); |
Tomasz Mikolajewski | dc235d2 | 2017-01-25 15:07:31 +0900 | [diff] [blame] | 129 | throw e; |
| 130 | } |
| 131 | } |
| 132 | |
| 133 | @Override |
| 134 | @VisibleForTesting |
| 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); |
felkachang | 1556c36 | 2018-08-30 16:21:59 +0800 | [diff] [blame^] | 142 | ZipArchiveEntry entry; |
Tomasz Mikolajewski | dc235d2 | 2017-01-25 15:07:31 +0900 | [diff] [blame] | 143 | String entryPath; |
| 144 | |
| 145 | synchronized (mEntries) { |
felkachang | 1556c36 | 2018-08-30 16:21:59 +0800 | [diff] [blame^] | 146 | final ZipArchiveEntry parentEntry = mEntries.get(parsedParentId.mPath); |
Tomasz Mikolajewski | dc235d2 | 2017-01-25 15:07:31 +0900 | [diff] [blame] | 147 | |
| 148 | if (parentEntry == null) { |
| 149 | throw new FileNotFoundException(); |
| 150 | } |
| 151 | |
felkachang | 1556c36 | 2018-08-30 16:21:59 +0800 | [diff] [blame^] | 152 | if (displayName.indexOf("/") != -1 || ".".equals(displayName) |
| 153 | || "..".equals(displayName)) { |
Tomasz Mikolajewski | dc235d2 | 2017-01-25 15:07:31 +0900 | [diff] [blame] | 154 | throw new IllegalStateException("Display name contains invalid characters."); |
| 155 | } |
| 156 | |
| 157 | if ("".equals(displayName)) { |
| 158 | throw new IllegalStateException("Display name cannot be empty."); |
| 159 | } |
| 160 | |
| 161 | |
| 162 | assert(parentEntry.getName().endsWith("/")); |
felkachang | 1556c36 | 2018-08-30 16:21:59 +0800 | [diff] [blame^] | 163 | final String parentName = "/".equals(parentEntry.getName()) |
| 164 | ? "" : parentEntry.getName(); |
Tomasz Mikolajewski | dc235d2 | 2017-01-25 15:07:31 +0900 | [diff] [blame] | 165 | final String entryName = parentName + displayName + (isDirectory ? "/" : ""); |
felkachang | 1556c36 | 2018-08-30 16:21:59 +0800 | [diff] [blame^] | 166 | entry = new ZipArchiveEntry(entryName); |
Tomasz Mikolajewski | dc235d2 | 2017-01-25 15:07:31 +0900 | [diff] [blame] | 167 | entryPath = getEntryPath(entry); |
| 168 | entry.setSize(0); |
| 169 | |
| 170 | if (mEntries.get(entryPath) != null) { |
| 171 | throw new IllegalStateException("The document already exist: " + entryPath); |
| 172 | } |
| 173 | addEntry(parentEntry, entry); |
| 174 | } |
| 175 | |
| 176 | if (!isDirectory) { |
| 177 | // For files, the contents will be written via openDocument. Since the contents |
| 178 | // must be immediately followed by the contents, defer adding the header until |
| 179 | // openDocument. All pending entires which haven't been written will be added |
| 180 | // to the ZIP file in close(). |
| 181 | synchronized (mEntries) { |
| 182 | mPendingEntries.add(entryPath); |
| 183 | } |
| 184 | } else { |
| 185 | try { |
| 186 | synchronized (mEntries) { |
| 187 | mZipOutputStream.putNextEntry(entry); |
| 188 | } |
| 189 | } catch (IOException e) { |
| 190 | throw new IllegalStateException( |
| 191 | "Failed to create a file in the archive: " + entryPath, e); |
| 192 | } |
| 193 | } |
| 194 | |
| 195 | return createArchiveId(entryPath).toDocumentId(); |
| 196 | } |
| 197 | |
| 198 | @Override |
| 199 | public ParcelFileDescriptor openDocument( |
| 200 | String documentId, String mode, @Nullable final CancellationSignal signal) |
| 201 | throws FileNotFoundException { |
| 202 | MorePreconditions.checkArgumentEquals("w", mode, |
| 203 | "Invalid mode. Only writing \"w\" supported, but got: \"%s\"."); |
| 204 | final ArchiveId parsedId = ArchiveId.fromDocumentId(documentId); |
| 205 | MorePreconditions.checkArgumentEquals(mArchiveUri, parsedId.mArchiveUri, |
| 206 | "Mismatching archive Uri. Expected: %s, actual: %s."); |
| 207 | |
felkachang | 1556c36 | 2018-08-30 16:21:59 +0800 | [diff] [blame^] | 208 | final ZipArchiveEntry entry; |
Tomasz Mikolajewski | dc235d2 | 2017-01-25 15:07:31 +0900 | [diff] [blame] | 209 | synchronized (mEntries) { |
| 210 | entry = mEntries.get(parsedId.mPath); |
| 211 | if (entry == null) { |
| 212 | throw new FileNotFoundException(); |
| 213 | } |
| 214 | |
| 215 | if (!mPendingEntries.contains(parsedId.mPath)) { |
| 216 | throw new IllegalStateException("Files can be written only once."); |
| 217 | } |
| 218 | mPendingEntries.remove(parsedId.mPath); |
| 219 | } |
| 220 | |
| 221 | ParcelFileDescriptor[] pipe; |
| 222 | try { |
| 223 | pipe = ParcelFileDescriptor.createReliablePipe(); |
| 224 | } catch (IOException e) { |
| 225 | // Ideally we'd simply throw IOException to the caller, but for consistency |
| 226 | // with DocumentsProvider::openDocument, converting it to IllegalStateException. |
| 227 | throw new IllegalStateException("Failed to open the document.", e); |
| 228 | } |
| 229 | final ParcelFileDescriptor inputPipe = pipe[0]; |
| 230 | |
| 231 | try { |
| 232 | mExecutor.execute( |
| 233 | new Runnable() { |
| 234 | @Override |
| 235 | public void run() { |
| 236 | try (final ParcelFileDescriptor.AutoCloseInputStream inputStream = |
| 237 | new ParcelFileDescriptor.AutoCloseInputStream(inputPipe)) { |
| 238 | try { |
| 239 | synchronized (mEntries) { |
| 240 | mZipOutputStream.putNextEntry(entry); |
| 241 | final byte buffer[] = new byte[32 * 1024]; |
| 242 | int bytes; |
| 243 | long size = 0; |
| 244 | while ((bytes = inputStream.read(buffer)) != -1) { |
| 245 | if (signal != null) { |
| 246 | signal.throwIfCanceled(); |
| 247 | } |
| 248 | mZipOutputStream.write(buffer, 0, bytes); |
| 249 | size += bytes; |
| 250 | } |
| 251 | entry.setSize(size); |
| 252 | mZipOutputStream.closeEntry(); |
| 253 | } |
| 254 | } catch (IOException e) { |
| 255 | // Catch the exception before the outer try-with-resource closes |
| 256 | // the pipe with close() instead of closeWithError(). |
| 257 | try { |
| 258 | Log.e(TAG, "Failed while writing to a file.", e); |
| 259 | inputPipe.closeWithError("Writing failure."); |
| 260 | } catch (IOException e2) { |
| 261 | Log.e(TAG, "Failed to close the pipe after an error.", e2); |
| 262 | } |
| 263 | } |
| 264 | } catch (OperationCanceledException e) { |
| 265 | // Cancelled gracefully. |
| 266 | } catch (IOException e) { |
| 267 | // Input stream auto-close error. Close quietly. |
| 268 | } |
| 269 | } |
| 270 | }); |
| 271 | } catch (RejectedExecutionException e) { |
Jeff Sharkey | 94785ef | 2018-07-09 16:37:41 -0600 | [diff] [blame] | 272 | FileUtils.closeQuietly(pipe[0]); |
| 273 | FileUtils.closeQuietly(pipe[1]); |
Tomasz Mikolajewski | dc235d2 | 2017-01-25 15:07:31 +0900 | [diff] [blame] | 274 | throw new IllegalStateException("Failed to initialize pipe."); |
| 275 | } |
| 276 | |
| 277 | return pipe[1]; |
| 278 | } |
| 279 | |
| 280 | /** |
| 281 | * Closes the archive. Blocks until all enqueued pipes are completed. |
| 282 | */ |
| 283 | @Override |
| 284 | public void close() { |
| 285 | // Waits until all enqueued pipe requests are completed. |
| 286 | mExecutor.shutdown(); |
| 287 | try { |
| 288 | final boolean result = mExecutor.awaitTermination( |
| 289 | Long.MAX_VALUE, TimeUnit.MILLISECONDS); |
| 290 | assert(result); |
| 291 | } catch (InterruptedException e) { |
| 292 | Log.e(TAG, "Opened files failed to be fullly written.", e); |
| 293 | } |
| 294 | |
| 295 | // Flush all pending entries. They will all have empty size. |
| 296 | synchronized (mEntries) { |
| 297 | for (final String path : mPendingEntries) { |
| 298 | try { |
| 299 | mZipOutputStream.putNextEntry(mEntries.get(path)); |
| 300 | mZipOutputStream.closeEntry(); |
| 301 | } catch (IOException e) { |
| 302 | Log.e(TAG, "Failed to flush empty entries.", e); |
| 303 | } |
| 304 | } |
| 305 | |
| 306 | try { |
| 307 | mZipOutputStream.close(); |
| 308 | } catch (IOException e) { |
| 309 | Log.e(TAG, "Failed while closing the ZIP file.", e); |
| 310 | } |
| 311 | } |
Tomasz Mikolajewski | a18ea7e | 2017-02-14 16:05:38 +0900 | [diff] [blame] | 312 | |
Jeff Sharkey | 94785ef | 2018-07-09 16:37:41 -0600 | [diff] [blame] | 313 | FileUtils.closeQuietly(mOutputStream); |
Tomasz Mikolajewski | dc235d2 | 2017-01-25 15:07:31 +0900 | [diff] [blame] | 314 | } |
felkachang | 1556c36 | 2018-08-30 16:21:59 +0800 | [diff] [blame^] | 315 | } |