DebugLogFileBackend.java 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340
  1. /* _____ _
  2. * |_ _| |_ _ _ ___ ___ _ __ __ _
  3. * | | | ' \| '_/ -_) -_) ' \/ _` |_
  4. * |_| |_||_|_| \___\___|_|_|_\__,_(_)
  5. *
  6. * Threema for Android
  7. * Copyright (c) 2018-2022 Threema GmbH
  8. *
  9. * This program is free software: you can redistribute it and/or modify
  10. * it under the terms of the GNU Affero General Public License, version 3,
  11. * as published by the Free Software Foundation.
  12. *
  13. * This program is distributed in the hope that it will be useful,
  14. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  15. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  16. * GNU Affero General Public License for more details.
  17. *
  18. * You should have received a copy of the GNU Affero General Public License
  19. * along with this program. If not, see <https://www.gnu.org/licenses/>.
  20. */
  21. package ch.threema.logging.backend;
  22. import android.os.Handler;
  23. import android.os.HandlerThread;
  24. import android.os.Looper;
  25. import android.util.Log;
  26. import net.lingala.zip4j.io.outputstream.ZipOutputStream;
  27. import net.lingala.zip4j.model.ZipParameters;
  28. import net.lingala.zip4j.model.enums.CompressionLevel;
  29. import net.lingala.zip4j.model.enums.CompressionMethod;
  30. import org.slf4j.helpers.MessageFormatter;
  31. import java.io.File;
  32. import java.io.FileInputStream;
  33. import java.io.FileWriter;
  34. import java.io.PrintWriter;
  35. import java.util.Date;
  36. import androidx.annotation.NonNull;
  37. import androidx.annotation.Nullable;
  38. import ch.threema.app.ThreemaApplication;
  39. import ch.threema.app.services.FileService;
  40. import ch.threema.app.utils.ZipUtil;
  41. import ch.threema.app.utils.executor.HandlerExecutor;
  42. import ch.threema.logging.LogLevel;
  43. import ch.threema.base.utils.LoggingUtil;
  44. import java8.util.concurrent.CompletableFuture;
  45. /**
  46. * A logging backend that logs to the debug log file.
  47. *
  48. * This backend is only enabled if the user enabled the debug log.
  49. *
  50. * The log file is deleted when calling `setEnabled(false)`.
  51. *
  52. * A zipped log file can be requested with `getZipFile()`.
  53. */
  54. public class DebugLogFileBackend implements LogBackend {
  55. // Constants
  56. private static final String TAG = "3ma";
  57. private static final String LOGFILE_NAME = "debug_log.txt";
  58. // Static variables
  59. private static boolean enabled = false;
  60. private static File logFile = null;
  61. private final @LogLevel int minLogLevel;
  62. // For tags starting with these prefixes, the package path is stripped
  63. private final static String[] STRIP_PREFIXES = {
  64. "ch.threema.app.",
  65. "ch.threema.domain.",
  66. "ch.threema.storage.",
  67. "ch.threema.",
  68. };
  69. // Worker thread
  70. private static @Nullable HandlerExecutor handler;
  71. /**
  72. * Create and start worker thread.
  73. */
  74. private static synchronized HandlerExecutor createHandler() {
  75. final HandlerThread handlerThread = new HandlerThread("DebugLogWorker");
  76. handlerThread.start();
  77. final Looper looper = handlerThread.getLooper();
  78. final Handler parent;
  79. if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.P) {
  80. parent = Handler.createAsync(looper);
  81. } else {
  82. parent = new Handler(looper);
  83. }
  84. return new HandlerExecutor(parent);
  85. }
  86. /**
  87. * Return the handler for the worker thread. Start it first if necessary.
  88. */
  89. private static synchronized @NonNull HandlerExecutor getHandler() {
  90. if (handler == null) {
  91. handler = createHandler();
  92. }
  93. return handler;
  94. }
  95. public DebugLogFileBackend(@LogLevel int minLogLevel) {
  96. this.minLogLevel = minLogLevel;
  97. }
  98. @Override
  99. public boolean isEnabled(int level) {
  100. return enabled && level >= this.minLogLevel;
  101. }
  102. /**
  103. * Enable or disable logging to the debug log file.
  104. *
  105. * By default, it is disabled.
  106. *
  107. * When disabling the logging, then the file is deleted if it already exists.
  108. */
  109. public synchronized static void setEnabled(boolean enabled) {
  110. DebugLogFileBackend.enabled = enabled;
  111. if (!enabled) {
  112. final File file = getLogFile();
  113. if (file == null) {
  114. Log.e(TAG,"DebugLogFileBackend: Could not get debug log file path");
  115. return;
  116. }
  117. if (file.exists() && !file.delete()) {
  118. Log.e(TAG,"DebugLogFileBackend: Could not delete debug log file");
  119. }
  120. }
  121. }
  122. /**
  123. * Return whether debug log file logging is enabled.
  124. */
  125. public static boolean isEnabled() {
  126. return enabled;
  127. }
  128. /**
  129. * @hidden Used only for tests.
  130. */
  131. static File getLogFilePath() {
  132. final File threemaDir = new File(ThreemaApplication.getAppContext().getExternalFilesDir(null), "log");
  133. return new File(threemaDir, LOGFILE_NAME);
  134. }
  135. /**
  136. * Return a `File` instance pointing to the debug log file.
  137. *
  138. * Returns `null` if the log file directory could not be created.
  139. */
  140. @Nullable
  141. private static File getLogFile() {
  142. if (logFile == null || !logFile.exists()) {
  143. final File threemaDir = new File(ThreemaApplication.getAppContext().getExternalFilesDir(null), "log");
  144. if (!threemaDir.exists()) {
  145. if (!threemaDir.mkdirs()) {
  146. Log.e(TAG, "DebugLogFileBackend: Could not create threema directory");
  147. return null;
  148. }
  149. }
  150. logFile = new File(threemaDir, LOGFILE_NAME);
  151. }
  152. return logFile;
  153. }
  154. /**
  155. * If the logger is enabled, write the log asynchronously to the log file.
  156. *
  157. * I/O is dispatched to the handler thread.
  158. *
  159. * A CompletableFuture is returned, which resolves once processing is finished.
  160. * The returned value is TRUE if the log was written successfully,
  161. * FALSE if writing the log failed, and null if the logger was not enabled.
  162. *
  163. * @see #print(int, String, Throwable, String)
  164. */
  165. synchronized CompletableFuture<Boolean> printAsync(
  166. @LogLevel int level,
  167. @NonNull String tag,
  168. @Nullable Throwable throwable,
  169. @Nullable String message
  170. ) {
  171. final CompletableFuture<Boolean> future = new CompletableFuture<>();
  172. if (!this.isEnabled(level)) {
  173. future.complete(null);
  174. return future;
  175. }
  176. // Dispatch I/O to worker thread.
  177. getHandler().post(() -> {
  178. // Get log file
  179. final File logFile = getLogFile();
  180. if (logFile == null) {
  181. Log.w(TAG, "DebugLogFileBackend: Could not get log file path");
  182. future.complete(false);
  183. return;
  184. }
  185. // Get log level string
  186. String levelString;
  187. switch (level) {
  188. case Log.VERBOSE:
  189. levelString = "TRACE";
  190. break;
  191. case Log.DEBUG:
  192. levelString = "DEBUG";
  193. break;
  194. case Log.INFO:
  195. levelString = "INFO ";
  196. break;
  197. case Log.WARN:
  198. levelString = "WARN ";
  199. break;
  200. case Log.ERROR:
  201. levelString = "ERROR";
  202. break;
  203. default:
  204. levelString = "? ";
  205. }
  206. // Prepare log text
  207. final Date now = new Date();
  208. String logLine = now.toString()
  209. + '\t' + levelString
  210. + " " + LoggingUtil.cleanTag(tag, STRIP_PREFIXES) + ": ";
  211. if (message == null) {
  212. if (throwable != null) {
  213. logLine += Log.getStackTraceString(throwable);
  214. }
  215. } else {
  216. if (throwable == null) {
  217. logLine += message;
  218. } else {
  219. logLine += message + '\n' + Log.getStackTraceString(throwable);
  220. }
  221. }
  222. // Write to logfile
  223. try (
  224. final FileWriter fw = new FileWriter(logFile, true);
  225. final PrintWriter pw = new PrintWriter(fw)
  226. ) {
  227. pw.println(logLine);
  228. future.complete(true);
  229. } catch (Exception e) {
  230. // Write failed...
  231. future.complete(false);
  232. }
  233. });
  234. return future;
  235. }
  236. /**
  237. * If the logger is enabled, write the log to the log file.
  238. *
  239. * Note: I/O is done asynchronously, so the log may not yet be fully written
  240. * to storage when this method returns!
  241. *
  242. * @param level The log level
  243. * @param tag The log tag
  244. * @param throwable A throwable (may be null)
  245. * @param message A message (may be null)
  246. */
  247. @Override
  248. public synchronized void print(
  249. @LogLevel int level,
  250. @NonNull String tag,
  251. @Nullable Throwable throwable,
  252. @Nullable String message
  253. ) {
  254. this.printAsync(level, tag, throwable, message);
  255. }
  256. @Override
  257. public synchronized void print(@LogLevel int level, @NonNull String tag, @Nullable Throwable throwable, @NonNull String messageFormat, Object... args) {
  258. if (!this.isEnabled(level)) {
  259. return;
  260. }
  261. try {
  262. this.print(level, tag, throwable, MessageFormatter.arrayFormat(messageFormat, args).getMessage());
  263. } catch (Exception e) { // Never crash
  264. this.print(level, tag, throwable, messageFormat);
  265. }
  266. }
  267. @Nullable
  268. public static File getZipFile(FileService fileService) {
  269. // Open log file
  270. final File logFile = getLogFile();
  271. if (logFile == null) {
  272. Log.w(TAG, "DebugLogFileBackend: getLogFile returned null");
  273. return null;
  274. }
  275. // Delete old debug log archive
  276. final File tempDebugLogArchive = new File(fileService.getExtTmpPath(), "debug_log.zip");
  277. if (tempDebugLogArchive.exists() && !tempDebugLogArchive.delete()) {
  278. Log.w(TAG, "DebugLogFileBackend: Could not delete tempDebugLogArchive");
  279. }
  280. // Create and return ZIP
  281. try (
  282. final FileInputStream inputStream = new FileInputStream(logFile);
  283. final ZipOutputStream zipOutputStream = ZipUtil.initializeZipOutputStream(tempDebugLogArchive, null)
  284. ) {
  285. final ZipParameters parameters = createZipParameters(logFile.getName());
  286. zipOutputStream.putNextEntry(parameters);
  287. final byte[] buf = new byte[16384];
  288. int nread;
  289. while ((nread = inputStream.read(buf)) > 0) {
  290. zipOutputStream.write(buf, 0, nread);
  291. }
  292. zipOutputStream.closeEntry();
  293. return tempDebugLogArchive;
  294. } catch (Exception e) {
  295. return null;
  296. }
  297. }
  298. private static ZipParameters createZipParameters(String filenameInZip) {
  299. ZipParameters parameters = new ZipParameters();
  300. parameters.setCompressionMethod(CompressionMethod.DEFLATE);
  301. parameters.setCompressionLevel(CompressionLevel.NORMAL);
  302. parameters.setFileNameInZip(filenameInZip);
  303. return parameters;
  304. }
  305. }