content: /*
   * Copyright (C) 2017 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 com.android.server.power.batterysaver;
  
  import android.content.Context;
  import android.os.Environment;
  import android.os.Handler;
  import android.os.Looper;
  import android.os.SystemProperties;
  import android.util.ArrayMap;
  import android.util.AtomicFile;
  import android.util.Slog;
  import android.util.Xml;
  
  import com.android.internal.annotations.GuardedBy;
  import com.android.internal.annotations.VisibleForTesting;
  import com.android.internal.util.FastXmlSerializer;
  import com.android.internal.util.XmlUtils;
  import com.android.server.IoThread;
  
  import libcore.io.IoUtils;
  
  import org.xmlpull.v1.XmlPullParser;
  import org.xmlpull.v1.XmlPullParserException;
  import org.xmlpull.v1.XmlSerializer;
  
  import java.io.File;
  import java.io.FileInputStream;
  import java.io.FileNotFoundException;
  import java.io.FileOutputStream;
  import java.io.FileWriter;
  import java.io.IOException;
  import java.nio.charset.StandardCharsets;
  import java.util.ArrayList;
  import java.util.Map;
  
  /**
   * Used by {@link BatterySaverController} to write values to /sys/ (and possibly /proc/ too) files
   * with retries. It also support restoring to the file original values.
   *
   * Retries are needed because writing to "/sys/.../scaling_max_freq" returns EIO when the current
   * frequency happens to be above the new max frequency.
   *
   * Test:
   atest $ANDROID_BUILD_TOP/frameworks/base/services/tests/servicestests/src/com/android/server/power/batterysaver/FileUpdaterTest.java
   */
  public class FileUpdater {
      private static final String TAG = BatterySaverController.TAG;
  
      private static final boolean DEBUG = BatterySaverController.DEBUG;
  
      /**
       * If this system property is set to 1, it'll skip all file writes. This can be used when
       * one needs to change max CPU frequency for benchmarking, for example.
       */
      private static final String PROP_SKIP_WRITE = "debug.batterysaver.no_write_files";
  
      private static final String TAG_DEFAULT_ROOT = "defaults";
  
      // Don't do disk access with this lock held.
      private final Object mLock = new Object();
  
      private final Context mContext;
  
      private final Handler mHandler;
  
      /**
       * Filename -> value map that holds pending writes.
       */
      @GuardedBy("mLock")
      private final ArrayMap<String, String> mPendingWrites = new ArrayMap<>();
  
      /**
       * Filename -> value that holds the original value of each file.
       */
      @GuardedBy("mLock")
      private final ArrayMap<String, String> mDefaultValues = new ArrayMap<>();
  
      /** Number of retries. We give up on writing after {@link #MAX_RETRIES} retries. */
      @GuardedBy("mLock")
      private int mRetries = 0;
  
      private final int MAX_RETRIES;
  
      private final long RETRY_INTERVAL_MS;
  
      /**
       * "Official" constructor. Don't use the other constructor in the production code.
       */
      public FileUpdater(Context context) {
          this(context, IoThread.get().getLooper(), 10, 5000);
      }
  
      /**
       * Constructor for test.
       */
      @VisibleForTesting
      FileUpdater(Context context, Looper looper, int maxRetries, int retryIntervalMs) {
          mContext = context;
          mHandler = new Handler(looper);
  
          MAX_RETRIES = maxRetries;
          RETRY_INTERVAL_MS = retryIntervalMs;
      }
  
      public void systemReady(boolean runtimeRestarted) {
          synchronized (mLock) {
              if (runtimeRestarted) {
                  // If it runtime restarted, read the original values from the disk and apply.
                  if (loadDefaultValuesLocked()) {
                      Slog.d(TAG, "Default values loaded after runtime restart; writing them...");
                      restoreDefault();
                  }
              } else {
                  // Delete it, without checking the result. (file-not-exist is not an exception.)
                  injectDefaultValuesFilename().delete();
              }
          }
      }
  
      /**
       * Write values to files. (Note the actual writes happen ASAP but asynchronously.)
       */
      public void writeFiles(ArrayMap<String, String> fileValues) {
          synchronized (mLock) {
              for (int i = fileValues.size() - 1; i >= 0; i--) {
                  final String file = fileValues.keyAt(i);
                  final String value = fileValues.valueAt(i);
  
                  if (DEBUG) {
                      Slog.d(TAG, "Scheduling write: '" + value + "' to '" + file + "'");
                  }
  
                  mPendingWrites.put(file, value);
  
              }
              mRetries = 0;
  
              mHandler.removeCallbacks(mHandleWriteOnHandlerRunnable);
              mHandler.post(mHandleWriteOnHandlerRunnable);
          }
      }
  
      /**
       * Restore the default values.
       */
      public void restoreDefault() {
          synchronized (mLock) {
              if (DEBUG) {
                  Slog.d(TAG, "Resetting file default values.");
              }
              mPendingWrites.clear();
  
              writeFiles(mDefaultValues);
          }
      }
  
      private Runnable mHandleWriteOnHandlerRunnable = () -> handleWriteOnHandler();
  
      /** Convert map keys into a single string for debug messages. */
      private String getKeysString(Map<String, String> source) {
          return new ArrayList<>(source.keySet()).toString();
      }
  
      /** Clone an ArrayMap. */
      private ArrayMap<String, String> cloneMap(ArrayMap<String, String> source) {
          return new ArrayMap<>(source);
      }
  
      /**
       * Called on the handler and writes {@link #mPendingWrites} to the disk.
       *
       * When it about to write to each file for the first time, it'll read the file and store
       * the original value in {@link #mDefaultValues}.
       */
      private void handleWriteOnHandler() {
          // We don't want to access the disk with the lock held, so copy the pending writes to
          // a local map.
          final ArrayMap<String, String> writes;
          synchronized (mLock) {
              if (mPendingWrites.size() == 0) {
                  return;
              }
  
              if (DEBUG) {
                  Slog.d(TAG, "Writing files: (# retries=" + mRetries + ") " +
                          getKeysString(mPendingWrites));
              }
  
              writes = cloneMap(mPendingWrites);
          }
  
          // Then write.
  
          boolean needRetry = false;
  
          final int size = writes.size();
          for (int i = 0; i < size; i++) {
              final String file = writes.keyAt(i);
              final String value = writes.valueAt(i);
  
              // Make sure the default value is loaded.
              if (!ensureDefaultLoaded(file)) {
                  continue;
              }
  
              // Write to the file. When succeeded, remove it from the pending list.
              // Otherwise, schedule a retry.
              try {
                  injectWriteToFile(file, value);
  
                  removePendingWrite(file);
              } catch (IOException e) {
                  needRetry = true;
              }
          }
          if (needRetry) {
              scheduleRetry();
          }
      }
  
      private void removePendingWrite(String file) {
          synchronized (mLock) {
              mPendingWrites.remove(file);
          }
      }
  
      private void scheduleRetry() {
          synchronized (mLock) {
              if (mPendingWrites.size() == 0) {
                  return; // Shouldn't happen but just in case.
              }
  
              mRetries++;
              if (mRetries > MAX_RETRIES) {
                  doWtf("Gave up writing files: " + getKeysString(mPendingWrites));
                  return;
              }
  
              mHandler.removeCallbacks(mHandleWriteOnHandlerRunnable);
              mHandler.postDelayed(mHandleWriteOnHandlerRunnable, RETRY_INTERVAL_MS);
          }
      }
  
      /**
       * Make sure {@link #mDefaultValues} has the default value loaded for {@code file}.
       *
       * @return true if the default value is loaded. false if the file cannot be read.
       */
      private boolean ensureDefaultLoaded(String file) {
          // Has the default already?
          synchronized (mLock) {
              if (mDefaultValues.containsKey(file)) {
                  return true;
              }
          }
          final String originalValue;
          try {
              originalValue = injectReadFromFileTrimmed(file);
          } catch (IOException e) {
              // If the file is not readable, assume can't write too.
              injectWtf("Unable to read from file", e);
  
              removePendingWrite(file);
              return false;
          }
          synchronized (mLock) {
              mDefaultValues.put(file, originalValue);
              saveDefaultValuesLocked();
          }
          return true;
      }
  
      @VisibleForTesting
      String injectReadFromFileTrimmed(String file) throws IOException {
          return IoUtils.readFileAsString(file).trim();
      }
  
      @VisibleForTesting
      void injectWriteToFile(String file, String value) throws IOException {
          if (injectShouldSkipWrite()) {
              Slog.i(TAG, "Skipped writing to '" + file + "'");
              return;
          }
          if (DEBUG) {
              Slog.d(TAG, "Writing: '" + value + "' to '" + file + "'");
          }
          try (FileWriter out = new FileWriter(file)) {
              out.write(value);
          } catch (IOException | RuntimeException e) {
              Slog.w(TAG, "Failed writing '" + value + "' to '" + file + "': " + e.getMessage());
              throw e;
          }
      }
  
      @GuardedBy("mLock")
      private void saveDefaultValuesLocked() {
          final AtomicFile file = new AtomicFile(injectDefaultValuesFilename());
  
          FileOutputStream outs = null;
          try {
              file.getBaseFile().getParentFile().mkdirs();
              outs = file.startWrite();
  
              // Write to XML
              XmlSerializer out = new FastXmlSerializer();
              out.setOutput(outs, StandardCharsets.UTF_8.name());
              out.startDocument(null, true);
              out.startTag(null, TAG_DEFAULT_ROOT);
  
              XmlUtils.writeMapXml(mDefaultValues, out, null);
  
              // Epilogue.
              out.endTag(null, TAG_DEFAULT_ROOT);
              out.endDocument();
  
              // Close.
              file.finishWrite(outs);
          } catch (IOException | XmlPullParserException | RuntimeException e) {
              Slog.e(TAG, "Failed to write to file " + file.getBaseFile(), e);
              file.failWrite(outs);
          }
      }
  
      @GuardedBy("mLock")
      @VisibleForTesting
      boolean loadDefaultValuesLocked() {
          final AtomicFile file = new AtomicFile(injectDefaultValuesFilename());
          if (DEBUG) {
              Slog.d(TAG, "Loading from " + file.getBaseFile());
          }
          Map<String, String> read = null;
          try (FileInputStream in = file.openRead()) {
              XmlPullParser parser = Xml.newPullParser();
              parser.setInput(in, StandardCharsets.UTF_8.name());
  
              int type;
              while ((type = parser.next()) != XmlPullParser.END_DOCUMENT) {
                  if (type != XmlPullParser.START_TAG) {
                      continue;
                  }
                  final int depth = parser.getDepth();
                  // Check the root tag
                  final String tag = parser.getName();
                  if (depth == 1) {
                      if (!TAG_DEFAULT_ROOT.equals(tag)) {
                          Slog.e(TAG, "Invalid root tag: " + tag);
                          return false;
                      }
                      continue;
                  }
                  final String[] tagName = new String[1];
                  read = (ArrayMap<String, String>) XmlUtils.readThisArrayMapXml(parser,
                          TAG_DEFAULT_ROOT, tagName, null);
              }
          } catch (FileNotFoundException e) {
              read = null;
          } catch (IOException | XmlPullParserException | RuntimeException e) {
              Slog.e(TAG, "Failed to read file " + file.getBaseFile(), e);
          }
          if (read != null) {
              mDefaultValues.clear();
              mDefaultValues.putAll(read);
              return true;
          }
          return false;
      }
  
      private void doWtf(String message) {
          injectWtf(message, null);
      }
  
      @VisibleForTesting
      void injectWtf(String message, Throwable e) {
          Slog.wtf(TAG, message, e);
      }
  
      File injectDefaultValuesFilename() {
          final File dir = new File(Environment.getDataSystemDirectory(), "battery-saver");
          dir.mkdirs();
          return new File(dir, "default-values.xml");
      }
  
      @VisibleForTesting
      boolean injectShouldSkipWrite() {
          return SystemProperties.getBoolean(PROP_SKIP_WRITE, false);
      }
  
      @VisibleForTesting
      ArrayMap<String, String> getDefaultValuesForTest() {
          return mDefaultValues;
      }
  }
  