AtomicFile.java

/*
 * Copyright (C) 2016 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 androidx.media3.common.util;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;

/**
 * A helper class for performing atomic operations on a file by creating a backup file until a write
 * has successfully completed.
 *
 * <p>Atomic file guarantees file integrity by ensuring that a file has been completely written and
 * synced to disk before removing its backup. As long as the backup file exists, the original file
 * is considered to be invalid (left over from a previous attempt to write the file).
 *
 * <p>Atomic file does not confer any file locking semantics. Do not use this class when the file
 * may be accessed or modified concurrently by multiple threads or processes. The caller is
 * responsible for ensuring appropriate mutual exclusion invariants whenever it accesses the file.
 */
@UnstableApi
public final class AtomicFile {

  private static final String TAG = "AtomicFile";

  private final File baseName;
  private final File backupName;

  /**
   * Create a new AtomicFile for a file located at the given File path. The secondary backup file
   * will be the same file path with ".bak" appended.
   */
  public AtomicFile(File baseName) {
    this.baseName = baseName;
    backupName = new File(baseName.getPath() + ".bak");
  }

  /** Returns whether the file or its backup exists. */
  public boolean exists() {
    return baseName.exists() || backupName.exists();
  }

  /** Delete the atomic file. This deletes both the base and backup files. */
  public void delete() {
    baseName.delete();
    backupName.delete();
  }

  /**
   * Start a new write operation on the file. This returns an {@link OutputStream} to which you can
   * write the new file data. If the whole data is written successfully you <em>must</em> call
   * {@link #endWrite(OutputStream)}. On failure you should call {@link OutputStream#close()} only
   * to free up resources used by it.
   *
   * <p>Example usage:
   *
   * <pre>
   *   DataOutputStream dataOutput = null;
   *   try {
   *     OutputStream outputStream = atomicFile.startWrite();
   *     dataOutput = new DataOutputStream(outputStream); // Wrapper stream
   *     dataOutput.write(data1);
   *     dataOutput.write(data2);
   *     atomicFile.endWrite(dataOutput); // Pass wrapper stream
   *   } finally{
   *     if (dataOutput != null) {
   *       dataOutput.close();
   *     }
   *   }
   * </pre>
   *
   * <p>Note that if another thread is currently performing a write, this will simply replace
   * whatever that thread is writing with the new file being written by this thread, and when the
   * other thread finishes the write the new write operation will no longer be safe (or will be
   * lost). You must do your own threading protection for access to AtomicFile.
   */
  public OutputStream startWrite() throws IOException {
    // Rename the current file so it may be used as a backup during the next read
    if (baseName.exists()) {
      if (!backupName.exists()) {
        if (!baseName.renameTo(backupName)) {
          Log.w(TAG, "Couldn't rename file " + baseName + " to backup file " + backupName);
        }
      } else {
        baseName.delete();
      }
    }
    OutputStream str;
    try {
      str = new AtomicFileOutputStream(baseName);
    } catch (FileNotFoundException e) {
      File parent = baseName.getParentFile();
      if (parent == null || !parent.mkdirs()) {
        throw new IOException("Couldn't create " + baseName, e);
      }
      // Try again now that we've created the parent directory.
      try {
        str = new AtomicFileOutputStream(baseName);
      } catch (FileNotFoundException e2) {
        throw new IOException("Couldn't create " + baseName, e2);
      }
    }
    return str;
  }

  /**
   * Call when you have successfully finished writing to the stream returned by {@link
   * #startWrite()}. This will close, sync, and commit the new data. The next attempt to read the
   * atomic file will return the new file stream.
   *
   * @param str Outer-most wrapper OutputStream used to write to the stream returned by {@link
   *     #startWrite()}.
   * @see #startWrite()
   */
  public void endWrite(OutputStream str) throws IOException {
    str.close();
    // If close() throws exception, the next line is skipped.
    backupName.delete();
  }

  /**
   * Open the atomic file for reading. If there previously was an incomplete write, this will roll
   * back to the last good data before opening for read.
   *
   * <p>Note that if another thread is currently performing a write, this will incorrectly consider
   * it to be in the state of a bad write and roll back, causing the new data currently being
   * written to be dropped. You must do your own threading protection for access to AtomicFile.
   */
  public InputStream openRead() throws FileNotFoundException {
    restoreBackup();
    return new FileInputStream(baseName);
  }

  private void restoreBackup() {
    if (backupName.exists()) {
      baseName.delete();
      backupName.renameTo(baseName);
    }
  }

  private static final class AtomicFileOutputStream extends OutputStream {

    private final FileOutputStream fileOutputStream;
    private boolean closed = false;

    public AtomicFileOutputStream(File file) throws FileNotFoundException {
      fileOutputStream = new FileOutputStream(file);
    }

    @Override
    public void close() throws IOException {
      if (closed) {
        return;
      }
      closed = true;
      flush();
      try {
        fileOutputStream.getFD().sync();
      } catch (IOException e) {
        Log.w(TAG, "Failed to sync file descriptor:", e);
      }
      fileOutputStream.close();
    }

    @Override
    public void flush() throws IOException {
      fileOutputStream.flush();
    }

    @Override
    public void write(int b) throws IOException {
      fileOutputStream.write(b);
    }

    @Override
    public void write(byte[] b) throws IOException {
      fileOutputStream.write(b);
    }

    @Override
    public void write(byte[] b, int off, int len) throws IOException {
      fileOutputStream.write(b, off, len);
    }
  }
}