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
17package com.android.dialer.persistentlog;
18
19import android.annotation.TargetApi;
20import android.content.Context;
21import android.content.SharedPreferences;
22import android.os.Build.VERSION_CODES;
23import android.preference.PreferenceManager;
24import android.support.annotation.AnyThread;
25import android.support.annotation.MainThread;
26import android.support.annotation.NonNull;
27import android.support.annotation.Nullable;
28import android.support.annotation.WorkerThread;
29import android.support.v4.os.UserManagerCompat;
30import java.io.ByteArrayInputStream;
31import java.io.DataInputStream;
32import java.io.DataOutputStream;
33import java.io.EOFException;
34import java.io.File;
35import java.io.FileOutputStream;
36import java.io.IOException;
37import java.io.RandomAccessFile;
38import java.nio.ByteBuffer;
39import java.util.ArrayList;
40import java.util.Arrays;
41import java.util.List;
42
43/**
44 * Handles serialization of byte arrays and read/write them to multiple rotating files. If a logText
45 * file exceeds {@code fileSizeLimit} after a write, a new file will be used. if the total number of
46 * files exceeds {@code fileCountLimit} the oldest ones will be deleted. The logs are stored in the
47 * cache but the file index is stored in the data (clearing data will also clear the cache). The
48 * logs will be stored under /cache_dir/persistent_log/{@code subfolder}, so multiple independent
49 * logs can be created.
50 *
51 * <p>This class is NOT thread safe. All methods expect the constructor must be called on the same
52 * worker thread.
53 */
54@SuppressWarnings("AndroidApiChecker") // lambdas
55@TargetApi(VERSION_CODES.M)
56final class PersistentLogFileHandler {
57
58  private static final String LOG_DIRECTORY = "persistent_log";
59  private static final String NEXT_FILE_INDEX_PREFIX = "persistent_long_next_file_index_";
60
61  private File logDirectory;
62  private final String subfolder;
63  private final int fileSizeLimit;
64  private final int fileCountLimit;
65
66  private SharedPreferences sharedPreferences;
67
68  private File outputFile;
69  private Context context;
70
71  @MainThread
72  PersistentLogFileHandler(String subfolder, int fileSizeLimit, int fileCountLimit) {
73    this.subfolder = subfolder;
74    this.fileSizeLimit = fileSizeLimit;
75    this.fileCountLimit = fileCountLimit;
76  }
77
78  /** Must be called right after the logger thread is created. */
79  @WorkerThread
80  void initialize(Context context) {
81    this.context = context;
82    logDirectory = new File(new File(context.getCacheDir(), LOG_DIRECTORY), subfolder);
83    initializeSharedPreference(context);
84  }
85
86  @WorkerThread
87  private boolean initializeSharedPreference(Context context) {
88    if (sharedPreferences == null && UserManagerCompat.isUserUnlocked(context)) {
89      sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context);
90      return true;
91    }
92    return sharedPreferences != null;
93  }
94
95  /**
96   * Write the list of byte arrays to the current log file, prefixing each entry with its' length. A
97   * new file will only be selected when the batch is completed, so the resulting file might be
98   * larger then {@code fileSizeLimit}
99   */
100  @WorkerThread
101  void writeLogs(List<byte[]> logs) throws IOException {
102    if (outputFile == null) {
103      selectNextFileToWrite();
104    }
105    outputFile.createNewFile();
106    try (DataOutputStream outputStream =
107        new DataOutputStream(new FileOutputStream(outputFile, true))) {
108      for (byte[] log : logs) {
109        outputStream.writeInt(log.length);
110        outputStream.write(log);
111      }
112      outputStream.close();
113      if (outputFile.length() > fileSizeLimit) {
114        selectNextFileToWrite();
115      }
116    }
117  }
118
119  /** Concatenate all log files in chronicle order and return a byte array. */
120  @WorkerThread
121  @NonNull
122  private byte[] readBlob() throws IOException {
123    File[] files = getLogFiles();
124
125    ByteBuffer byteBuffer = ByteBuffer.allocate(getTotalSize(files));
126    for (File file : files) {
127      byteBuffer.put(readAllBytes(file));
128    }
129    return byteBuffer.array();
130  }
131
132  private static int getTotalSize(File[] files) {
133    int sum = 0;
134    for (File file : files) {
135      sum += (int) file.length();
136    }
137    return sum;
138  }
139
140  /** Parses the content of all files back to individual byte arrays. */
141  @WorkerThread
142  @NonNull
143  List<byte[]> getLogs() throws IOException {
144    byte[] blob = readBlob();
145    List<byte[]> logs = new ArrayList<>();
146    try (DataInputStream input = new DataInputStream(new ByteArrayInputStream(blob))) {
147      byte[] log = readLog(input);
148      while (log != null) {
149        logs.add(log);
150        log = readLog(input);
151      }
152    }
153    return logs;
154  }
155
156  @WorkerThread
157  private void selectNextFileToWrite() throws IOException {
158    File[] files = getLogFiles();
159
160    if (files.length == 0 || files[files.length - 1].length() > fileSizeLimit) {
161      if (files.length >= fileCountLimit) {
162        for (int i = 0; i <= files.length - fileCountLimit; i++) {
163          files[i].delete();
164        }
165      }
166      outputFile = new File(logDirectory, String.valueOf(getAndIncrementNextFileIndex()));
167    } else {
168      outputFile = files[files.length - 1];
169    }
170  }
171
172  @NonNull
173  @WorkerThread
174  private File[] getLogFiles() {
175    logDirectory.mkdirs();
176    File[] files = logDirectory.listFiles();
177    if (files == null) {
178      files = new File[0];
179    }
180    Arrays.sort(
181        files,
182        (File lhs, File rhs) ->
183            Long.compare(Long.valueOf(lhs.getName()), Long.valueOf(rhs.getName())));
184    return files;
185  }
186
187  @Nullable
188  @WorkerThread
189  private static byte[] readLog(DataInputStream inputStream) throws IOException {
190    try {
191      byte[] data = new byte[inputStream.readInt()];
192      inputStream.read(data);
193      return data;
194    } catch (EOFException e) {
195      return null;
196    }
197  }
198
199  @NonNull
200  @WorkerThread
201  private static byte[] readAllBytes(File file) throws IOException {
202    byte[] result = new byte[(int) file.length()];
203    try (RandomAccessFile randomAccessFile = new RandomAccessFile(file, "r")) {
204      randomAccessFile.readFully(result);
205    }
206    return result;
207  }
208
209  @WorkerThread
210  private int getAndIncrementNextFileIndex() throws IOException {
211    if (!initializeSharedPreference(context)) {
212      throw new IOException("Shared preference is not available");
213    }
214
215    int index = sharedPreferences.getInt(getNextFileKey(), 0);
216    sharedPreferences.edit().putInt(getNextFileKey(), index + 1).commit();
217    return index;
218  }
219
220  @AnyThread
221  private String getNextFileKey() {
222    return NEXT_FILE_INDEX_PREFIX + subfolder;
223  }
224}
225