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 package com.android.wallpaper.util;
17 
18 import static java.nio.charset.StandardCharsets.UTF_8;
19 
20 import android.content.Context;
21 import android.os.Build;
22 import android.os.Handler;
23 import android.os.HandlerThread;
24 import android.os.Process;
25 import android.util.Log;
26 
27 import com.android.wallpaper.compat.BuildCompat;
28 
29 import java.io.BufferedReader;
30 import java.io.Closeable;
31 import java.io.File;
32 import java.io.FileInputStream;
33 import java.io.FileOutputStream;
34 import java.io.IOException;
35 import java.io.InputStreamReader;
36 import java.io.OutputStream;
37 import java.text.ParseException;
38 import java.text.SimpleDateFormat;
39 import java.util.Calendar;
40 import java.util.Date;
41 import java.util.Locale;
42 import java.util.concurrent.TimeUnit;
43 
44 import androidx.annotation.Nullable;
45 import androidx.annotation.VisibleForTesting;
46 
47 /**
48  * Logs messages to logcat and for debuggable build types ("eng" or "userdebug") also mirrors logs
49  * to a disk-based log buffer.
50  */
51 public class DiskBasedLogger {
52 
53     static final String LOGS_FILE_PATH = "logs.txt";
54     static final SimpleDateFormat DATE_FORMAT =
55             new SimpleDateFormat("EEE MMM dd HH:mm:ss.SSS z yyyy", Locale.US);
56 
57     private static final String TEMP_LOGS_FILE_PATH = "temp_logs.txt";
58     private static final String TAG = "DiskBasedLogger";
59 
60     /**
61      * POJO used to lock thread creation and file read/write operations.
62      */
63     private static final Object S_LOCK = new Object();
64 
65     private static final long THREAD_TIMEOUT_MILLIS =
66             TimeUnit.MILLISECONDS.convert(2, TimeUnit.MINUTES);
67     private static Handler sHandler;
68     private static HandlerThread sLoggerThread;
69     private static final Runnable THREAD_CLEANUP_RUNNABLE = new Runnable() {
70         @Override
71         public void run() {
72             if (sLoggerThread != null && sLoggerThread.isAlive()) {
73 
74                 // HandlerThread#quitSafely was added in JB-MR2, so prefer to use that instead of #quit.
75                 boolean isQuitSuccessful = BuildCompat.isAtLeastJBMR2()
76                         ? sLoggerThread.quitSafely()
77                         : sLoggerThread.quit();
78 
79                 if (!isQuitSuccessful) {
80                     Log.e(TAG, "Unable to quit disk-based logger HandlerThread");
81                 }
82 
83                 sLoggerThread = null;
84                 sHandler = null;
85             }
86         }
87     };
88 
89     /**
90      * Initializes and returns a new dedicated HandlerThread for reading and writing to the disk-based
91      * logs file.
92      */
initializeLoggerThread()93     private static void initializeLoggerThread() {
94         sLoggerThread = new HandlerThread("DiskBasedLoggerThread", Process.THREAD_PRIORITY_BACKGROUND);
95         sLoggerThread.start();
96     }
97 
98     /**
99      * Returns a Handler that can post messages to the dedicated HandlerThread for reading and writing
100      * to the logs file on disk. Lazy-loads the HandlerThread if it doesn't already exist and delays
101      * its death by a timeout if the thread already exists.
102      */
getLoggerThreadHandler()103     private static Handler getLoggerThreadHandler() {
104         synchronized (S_LOCK) {
105             if (sLoggerThread == null) {
106                 initializeLoggerThread();
107 
108                 // Create a new Handler tied to the new HandlerThread's Looper for processing disk I/O off
109                 // the main thread. Starts with a default timeout to quit and remove references to the
110                 // thread after a period of inactivity.
111                 sHandler = new Handler(sLoggerThread.getLooper());
112             } else {
113                 sHandler.removeCallbacks(THREAD_CLEANUP_RUNNABLE);
114             }
115 
116             // Delay the logger thread's eventual death.
117             sHandler.postDelayed(THREAD_CLEANUP_RUNNABLE, THREAD_TIMEOUT_MILLIS);
118 
119             return sHandler;
120         }
121     }
122 
123     /**
124      * Logs an "error" level log to logcat based on the provided tag and message and also duplicates
125      * the log to a file-based log buffer if running on a "userdebug" or "eng" build.
126      */
e(String tag, String msg, Context context)127     public static void e(String tag, String msg, Context context) {
128         // Pass log tag and message through to logcat regardless of build type.
129         Log.e(tag, msg);
130 
131         // Only mirror logs to disk-based log buffer if the build is debuggable.
132         if (!Build.TYPE.equals("eng") && !Build.TYPE.equals("userdebug")) {
133             return;
134         }
135 
136         Handler handler = getLoggerThreadHandler();
137         if (handler == null) {
138             Log.e(TAG, "Something went wrong creating the logger thread handler, quitting this logging "
139                     + "operation");
140             return;
141         }
142 
143         handler.post(() -> {
144             File logs = new File(context.getFilesDir(), LOGS_FILE_PATH);
145 
146             // Construct a log message that we can parse later in order to clean up old logs.
147             String datetime = DATE_FORMAT.format(Calendar.getInstance().getTime());
148             String log = datetime + "/E " + tag + ": " + msg + "\n";
149 
150             synchronized (S_LOCK) {
151                 FileOutputStream outputStream;
152 
153                 try {
154                     outputStream = context.openFileOutput(logs.getName(), Context.MODE_APPEND);
155                     outputStream.write(log.getBytes(UTF_8));
156                     outputStream.close();
157                 } catch (IOException e) {
158                     Log.e(TAG, "Unable to close output stream for disk-based log buffer", e);
159                 }
160             }
161         });
162     }
163 
164     /**
165      * Deletes logs in the disk-based log buffer older than 7 days.
166      */
clearOldLogs(Context context)167     public static void clearOldLogs(Context context) {
168         if (!Build.TYPE.equals("eng") && !Build.TYPE.equals("userdebug")) {
169             return;
170         }
171 
172         Handler handler = getLoggerThreadHandler();
173         if (handler == null) {
174             Log.e(TAG, "Something went wrong creating the logger thread handler, quitting this logging "
175                     + "operation");
176             return;
177         }
178 
179         handler.post(() -> {
180             // Check if the logs file exists first before trying to read from it.
181             File logsFile = new File(context.getFilesDir(), LOGS_FILE_PATH);
182             if (!logsFile.exists()) {
183                 Log.w(TAG, "Disk-based log buffer doesn't exist, so there's nothing to clean up.");
184                 return;
185             }
186 
187             synchronized (S_LOCK) {
188                 FileInputStream inputStream;
189                 BufferedReader bufferedReader;
190 
191                 try {
192                     inputStream = context.openFileInput(LOGS_FILE_PATH);
193                     bufferedReader = new BufferedReader(new InputStreamReader(inputStream, UTF_8));
194                 } catch (IOException e) {
195                     Log.e(TAG, "IO exception opening a buffered reader for the existing logs file", e);
196                     return;
197                 }
198 
199                 Date sevenDaysAgo = getSevenDaysAgo();
200 
201                 File tempLogsFile = new File(context.getFilesDir(), TEMP_LOGS_FILE_PATH);
202                 FileOutputStream outputStream;
203 
204                 try {
205                     outputStream = context.openFileOutput(TEMP_LOGS_FILE_PATH, Context.MODE_APPEND);
206                 } catch (IOException e) {
207                     Log.e(TAG, "Unable to close output stream for disk-based log buffer", e);
208                     return;
209                 }
210 
211                 copyLogsNewerThanDate(bufferedReader, outputStream, sevenDaysAgo);
212 
213                 // Close streams to prevent resource leaks.
214                 closeStream(inputStream, "couldn't close input stream for log file");
215                 closeStream(outputStream, "couldn't close output stream for temp log file");
216 
217                 // Rename temp log file (if it exists--which is only when the logs file has logs newer than
218                 // 7 days to begin with) to the final logs file.
219                 if (tempLogsFile.exists() && !tempLogsFile.renameTo(logsFile)) {
220                     Log.e(TAG, "couldn't rename temp logs file to final logs file");
221                 }
222             }
223         });
224     }
225 
226     @Nullable
227     @VisibleForTesting
getHandler()228   /* package */ static Handler getHandler() {
229         return sHandler;
230     }
231 
232     /**
233      * Constructs and returns a {@link Date} object representing the time 7 days ago.
234      */
getSevenDaysAgo()235     private static Date getSevenDaysAgo() {
236         Calendar sevenDaysAgoCalendar = Calendar.getInstance();
237         sevenDaysAgoCalendar.add(Calendar.DAY_OF_MONTH, -7);
238         return sevenDaysAgoCalendar.getTime();
239     }
240 
241     /**
242      * Tries to close the provided Closeable stream and logs the error message if the stream couldn't
243      * be closed.
244      */
closeStream(Closeable stream, String errorMessage)245     private static void closeStream(Closeable stream, String errorMessage) {
246         try {
247             stream.close();
248         } catch (IOException e) {
249             Log.e(TAG, errorMessage);
250         }
251     }
252 
253     /**
254      * Copies all log lines newer than the supplied date from the provided {@link BufferedReader} to
255      * the provided {@OutputStream}.
256      * <p>
257      * The caller of this method is responsible for closing the output stream and input stream
258      * underlying the BufferedReader when all operations have finished.
259      */
copyLogsNewerThanDate(BufferedReader reader, OutputStream outputStream, Date date)260     private static void copyLogsNewerThanDate(BufferedReader reader, OutputStream outputStream,
261                                               Date date) {
262         try {
263             String line = reader.readLine();
264             while (line != null) {
265                 // Get the date from the line string.
266                 String datetime = line.split("/")[0];
267                 Date logDate;
268                 try {
269                     logDate = DATE_FORMAT.parse(datetime);
270                 } catch (ParseException e) {
271                     Log.e(TAG, "Error parsing date from previous logs", e);
272                     return;
273                 }
274 
275                 // Copy logs newer than the provided date into a temp log file.
276                 if (logDate.after(date)) {
277                     outputStream.write(line.getBytes(UTF_8));
278                     outputStream.write("\n".getBytes(UTF_8));
279                 }
280 
281                 line = reader.readLine();
282             }
283         } catch (IOException e) {
284             Log.e(TAG, "IO exception while reading line from buffered reader", e);
285         }
286     }
287 }
288