content: /*
   * Copyright (C) 2015 The Android Open Source Project
   *
   * Licensed under the Apache License, Version 2.0 (the "License");
   * you may not use this file except in compliance with the License.
   * You may obtain a copy of the License at
   *
   *      http://www.apache.org/licenses/LICENSE-2.0
   *
   * Unless required by applicable law or agreed to in writing, software
   * distributed under the License is distributed on an "AS IS" BASIS,
   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
   * See the License for the specific language governing permissions and
   * limitations under the License.
   */
  
  package dev.dworks.apps.anexplorer.archive;
  
  import android.content.res.AssetFileDescriptor;
  import android.database.AbstractCursor;
  import android.database.ContentObserver;
  import android.database.Cursor;
  import android.graphics.Point;
  import android.net.Uri;
  import android.os.CancellationSignal;
  import android.os.ParcelFileDescriptor;
  import androidx.annotation.Nullable;
  import android.util.Log;
  import android.util.LruCache;
  
  import java.io.Closeable;
  import java.io.File;
  import java.io.FileNotFoundException;
  import java.io.IOException;
  import java.util.concurrent.locks.Lock;
  import java.util.concurrent.locks.ReentrantReadWriteLock;
  
  import dev.dworks.apps.anexplorer.misc.Preconditions;
  import dev.dworks.apps.anexplorer.model.DocumentsContract.Document;
  import dev.dworks.apps.anexplorer.provider.DocumentsProvider;
  
  /**
  /**
   * Provides basic implementation for creating, extracting and accessing
   * files within archives exposed by a document provider.
   *
   * <p>This class is thread safe. All methods can be called on any thread without
   * synchronization.
   *
   * TODO: Update the documentation. b/26047732
   */
  public class DocumentArchiveHelper implements Closeable {
      /**
       * Cursor column to be used for passing the local file path for documents.
       * If it's not specified, then a snapshot will be created, which is slower
       * and consumes more resources.
       *
       * <p>Type: STRING
       */
      public static final String COLUMN_LOCAL_FILE_PATH = "local_file_path";
  
      private static final String TAG = "DocumentArchiveHelper";
      private static final int OPENED_ARCHIVES_CACHE_SIZE = 4;
      private static final String[] ZIP_MIME_TYPES = {
              "application/zip", "application/x-zip", "application/x-zip-compressed"
      };
  
      private final DocumentsProvider mProvider;
      private final char mIdDelimiter;
  
      // @GuardedBy("mArchives")
      private final LruCache<String, Loader> mArchives =
              new LruCache<String, Loader>(OPENED_ARCHIVES_CACHE_SIZE) {
                  @Override
                  public void entryRemoved(boolean evicted, String key,
                          Loader oldValue, Loader newValue) {
                      oldValue.getWriteLock().lock();
                      try {
                          oldValue.get().close();
                      } catch (FileNotFoundException e) {
                          Log.e(TAG, "Failed to close an archive as it no longer exists.");
                      } finally {
                          oldValue.getWriteLock().unlock();
                      }
                  }
              };
  
      /**
       * Creates a helper for handling archived documents.
       *
       * @param provider Instance of a documents provider which provides archived documents.
       * @param idDelimiter A character used to create document IDs within archives. Can be any
       *            character which is not used in any other document ID. If your provider uses
       *            numbers as document IDs, the delimiter can be eg. a colon. However if your
       *            provider uses paths, then a delimiter can be any character not allowed in the
       *            path, which is often \0.
       */
      public DocumentArchiveHelper(DocumentsProvider provider, char idDelimiter) {
          mProvider = provider;
          mIdDelimiter = idDelimiter;
      }
  
      /**
       * Lists child documents of an archive or a directory within an
       * archive. Must be called only for archives with supported mime type,
       * or for documents within archives.
       *
       */
      public Cursor queryChildDocuments(String documentId, @Nullable String[] projection,
              @Nullable String sortOrder)
              throws FileNotFoundException {
          Loader loader = null;
          try {
              loader = obtainInstance(documentId);
              return loader.get().queryChildDocuments(documentId, projection, sortOrder);
          } finally {
              releaseInstance(loader);
          }
      }
  
      /**
       * Returns a MIME type of a document within an archive.
       *
       */
      public String getDocumentType(String documentId) throws FileNotFoundException {
          Loader loader = null;
          try {
              loader = obtainInstance(documentId);
              return loader.get().getDocumentType(documentId);
          } finally {
              releaseInstance(loader);
          }
      }
  
      /**
       * Returns true if a document within an archive is a child or any descendant of the archive
       * document or another document within the archive.
       *
       */
      public boolean isChildDocument(String parentDocumentId, String documentId) {
          Loader loader = null;
          try {
              loader = obtainInstance(documentId);
              return loader.get().isChildDocument(parentDocumentId, documentId);
          } catch (FileNotFoundException e) {
              throw new IllegalStateException(e);
          } finally {
              releaseInstance(loader);
          }
      }
  
      /**
       * Returns metadata of a document within an archive.
       *
       */
      public Cursor queryDocument(String documentId, @Nullable String[] projection)
              throws FileNotFoundException {
          Loader loader = null;
          try {
              loader = obtainInstance(documentId);
              return loader.get().queryDocument(documentId, projection);
          } finally {
              releaseInstance(loader);
          }
      }
  
      /**
       * Opens a file within an archive.
       *
       */
      public ParcelFileDescriptor openDocument(
              String documentId, String mode, final CancellationSignal signal)
              throws FileNotFoundException {
          Loader loader = null;
          try {
              loader = obtainInstance(documentId);
              return loader.get().openDocument(documentId, mode, signal);
          } finally {
              releaseInstance(loader);
          }
      }
  
      /**
       * Opens a thumbnail of a file within an archive.
       *
       */
      public AssetFileDescriptor openDocumentThumbnail(
              String documentId, Point sizeHint, final CancellationSignal signal)
              throws FileNotFoundException {
          Loader loader = null;
          try {
              loader = obtainInstance(documentId);
              return loader.get().openDocumentThumbnail(documentId, sizeHint, signal);
          } finally {
              releaseInstance(loader);
          }
      }
  
      /**
       * Returns true if the passed document ID is for a document within an archive.
       */
      public boolean isArchivedDocument(String documentId) {
          return ParsedDocumentId.hasPath(documentId, mIdDelimiter);
      }
  
      /**
       * Returns true if the passed mime type is supported by the helper.
       */
      public static boolean isSupportedArchiveType(String mimeType) {
          for (final String zipMimeType : ZIP_MIME_TYPES) {
              if (zipMimeType.equals(mimeType)) {
                  return true;
              }
          }
          return false;
      }
  
      /**
       * Closes the helper and disposes all existing archives. It will block until all ongoing
       * operations on each opened archive are finished.
       */
      @Override
      public void close() {
          synchronized (mArchives) {
              mArchives.evictAll();
          }
      }
  
      /**
       * Releases resources for an archive with the specified document ID. It will block until all
       * operations on the archive are finished. If not opened, the method does nothing.
       *
       * <p>Calling this method is optional. The helper automatically closes the least recently used
       * archives if too many archives are opened.
       *
       * @param documentId ID of the archive file.
       */
      public void closeArchive(String documentId) {
          synchronized (mArchives) {
              mArchives.remove(documentId);
          }
      }
  
      private Loader obtainInstance(String documentId) throws FileNotFoundException {
          Loader loader;
          synchronized (mArchives) {
              loader = getInstanceUncheckedLocked(documentId);
              loader.getReadLock().lock();
          }
          return loader;
      }
  
      private void releaseInstance(@Nullable Loader loader) {
          if (loader != null) {
              loader.getReadLock().unlock();
          }
      }
  
      private Loader getInstanceUncheckedLocked(String documentId)
              throws FileNotFoundException {
          try {
              final ParsedDocumentId id = ParsedDocumentId.fromDocumentId(documentId, mIdDelimiter);
              if (mArchives.get(id.mArchiveId) != null) {
                  return mArchives.get(id.mArchiveId);
              }
  
              final Cursor cursor = mProvider.queryDocument(id.mArchiveId, new String[]
                      { Document.COLUMN_MIME_TYPE, COLUMN_LOCAL_FILE_PATH });
              cursor.moveToFirst();
              final String mimeType = cursor.getString(cursor.getColumnIndex(
                      Document.COLUMN_MIME_TYPE));
              Preconditions.checkArgument(isSupportedArchiveType(mimeType),
                      "Unsupported archive type.");
              final int columnIndex = cursor.getColumnIndex(COLUMN_LOCAL_FILE_PATH);
              final String localFilePath = columnIndex != -1 ? cursor.getString(columnIndex) : null;
              final File localFile = localFilePath != null ? new File(localFilePath) : null;
  
              final Uri notificationUri = ((AbstractCursor)cursor).getNotificationUri();
              final Loader loader = new Loader(mProvider, localFile, id, mIdDelimiter,
                      notificationUri);
  
              // Remove the instance from mArchives collection once the archive file changes.
              if (notificationUri != null) {
                  final LruCache<String, Loader> finalArchives = mArchives;
                  mProvider.getContext().getContentResolver().registerContentObserver(notificationUri,
                          false,
                          new ContentObserver(null) {
                              @Override
                              public void onChange(boolean selfChange, Uri uri) {
                                  synchronized (mArchives) {
                                      final Loader currentLoader = mArchives.get(id.mArchiveId);
                                      if (currentLoader == loader) {
                                          mArchives.remove(id.mArchiveId);
                                      }
                                  }
                              }
                          });
              }
  
              mArchives.put(id.mArchiveId, loader);
              return loader;
          } catch (IOException e) {
              // DocumentsProvider doesn't use IOException. For consistency convert it to
              // IllegalStateException.
              throw new IllegalStateException(e);
          }
      }
  
      /**
       * Loads an instance of DocumentArchive lazily.
       */
      private static final class Loader {
          private final DocumentsProvider mProvider;
          private final File mLocalFile;
          private final ParsedDocumentId mId;
          private final char mIdDelimiter;
          private final Uri mNotificationUri;
          private final ReentrantReadWriteLock mLock = new ReentrantReadWriteLock();
          private DocumentArchive mArchive = null;
  
          Loader(DocumentsProvider provider, @Nullable File localFile, ParsedDocumentId id,
                  char idDelimiter, Uri notificationUri) {
              this.mProvider = provider;
              this.mLocalFile = localFile;
              this.mId = id;
              this.mIdDelimiter = idDelimiter;
              this.mNotificationUri = notificationUri;
          }
  
          synchronized DocumentArchive get() throws FileNotFoundException {
              if (mArchive != null) {
                  return mArchive;
              }
  
              try {
                  if (mLocalFile != null) {
                      mArchive = DocumentArchive.createForLocalFile(
                              mProvider.getContext(), mLocalFile, mId.mArchiveId, mIdDelimiter,
                              mNotificationUri);
                  } else {
                      mArchive = DocumentArchive.createForParcelFileDescriptor(
                              mProvider.getContext(),
                              mProvider.openDocument(mId.mArchiveId, "r", null /* signal */),
                              mId.mArchiveId, mIdDelimiter, mNotificationUri);
                  }
              } catch (IOException e) {
                  throw new IllegalStateException(e);
              }
  
              return mArchive;
          }
  
          Lock getReadLock() {
              return mLock.readLock();
          }
  
          Lock getWriteLock() {
              return mLock.writeLock();
          }
      }
  }
  