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