/* _____ _ * |_ _| |_ _ _ ___ ___ _ __ __ _ * | | | ' \| '_/ -_) -_) ' \/ _` |_ * |_| |_||_|_| \___\___|_|_|_\__,_(_) * * Threema for Android * Copyright (c) 2018-2022 Threema GmbH * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License, version 3, * as published by the Free Software Foundation. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ package ch.threema.logging.backend; import android.os.Handler; import android.os.HandlerThread; import android.os.Looper; import android.util.Log; import net.lingala.zip4j.io.outputstream.ZipOutputStream; import net.lingala.zip4j.model.ZipParameters; import net.lingala.zip4j.model.enums.CompressionLevel; import net.lingala.zip4j.model.enums.CompressionMethod; import org.slf4j.helpers.MessageFormatter; import java.io.File; import java.io.FileInputStream; import java.io.FileWriter; import java.io.PrintWriter; import java.util.Date; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import ch.threema.app.ThreemaApplication; import ch.threema.app.services.FileService; import ch.threema.app.utils.ZipUtil; import ch.threema.app.utils.executor.HandlerExecutor; import ch.threema.logging.LogLevel; import ch.threema.base.utils.LoggingUtil; import java8.util.concurrent.CompletableFuture; /** * A logging backend that logs to the debug log file. * * This backend is only enabled if the user enabled the debug log. * * The log file is deleted when calling `setEnabled(false)`. * * A zipped log file can be requested with `getZipFile()`. */ public class DebugLogFileBackend implements LogBackend { // Constants private static final String TAG = "3ma"; private static final String LOGFILE_NAME = "debug_log.txt"; // Static variables private static boolean enabled = false; private static File logFile = null; private final @LogLevel int minLogLevel; // For tags starting with these prefixes, the package path is stripped private final static String[] STRIP_PREFIXES = { "ch.threema.app.", "ch.threema.domain.", "ch.threema.storage.", "ch.threema.", }; // Worker thread private static @Nullable HandlerExecutor handler; /** * Create and start worker thread. */ private static synchronized HandlerExecutor createHandler() { final HandlerThread handlerThread = new HandlerThread("DebugLogWorker"); handlerThread.start(); final Looper looper = handlerThread.getLooper(); final Handler parent; if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.P) { parent = Handler.createAsync(looper); } else { parent = new Handler(looper); } return new HandlerExecutor(parent); } /** * Return the handler for the worker thread. Start it first if necessary. */ private static synchronized @NonNull HandlerExecutor getHandler() { if (handler == null) { handler = createHandler(); } return handler; } public DebugLogFileBackend(@LogLevel int minLogLevel) { this.minLogLevel = minLogLevel; } @Override public boolean isEnabled(int level) { return enabled && level >= this.minLogLevel; } /** * Enable or disable logging to the debug log file. * * By default, it is disabled. * * When disabling the logging, then the file is deleted if it already exists. */ public synchronized static void setEnabled(boolean enabled) { DebugLogFileBackend.enabled = enabled; if (!enabled) { final File file = getLogFile(); if (file == null) { Log.e(TAG,"DebugLogFileBackend: Could not get debug log file path"); return; } if (file.exists() && !file.delete()) { Log.e(TAG,"DebugLogFileBackend: Could not delete debug log file"); } } } /** * Return whether debug log file logging is enabled. */ public static boolean isEnabled() { return enabled; } /** * @hidden Used only for tests. */ static File getLogFilePath() { final File threemaDir = new File(ThreemaApplication.getAppContext().getExternalFilesDir(null), "log"); return new File(threemaDir, LOGFILE_NAME); } /** * Return a `File` instance pointing to the debug log file. * * Returns `null` if the log file directory could not be created. */ @Nullable private static File getLogFile() { if (logFile == null || !logFile.exists()) { final File threemaDir = new File(ThreemaApplication.getAppContext().getExternalFilesDir(null), "log"); if (!threemaDir.exists()) { if (!threemaDir.mkdirs()) { Log.e(TAG, "DebugLogFileBackend: Could not create threema directory"); return null; } } logFile = new File(threemaDir, LOGFILE_NAME); } return logFile; } /** * If the logger is enabled, write the log asynchronously to the log file. * * I/O is dispatched to the handler thread. * * A CompletableFuture is returned, which resolves once processing is finished. * The returned value is TRUE if the log was written successfully, * FALSE if writing the log failed, and null if the logger was not enabled. * * @see #print(int, String, Throwable, String) */ synchronized CompletableFuture printAsync( @LogLevel int level, @NonNull String tag, @Nullable Throwable throwable, @Nullable String message ) { final CompletableFuture future = new CompletableFuture<>(); if (!this.isEnabled(level)) { future.complete(null); return future; } // Dispatch I/O to worker thread. getHandler().post(() -> { // Get log file final File logFile = getLogFile(); if (logFile == null) { Log.w(TAG, "DebugLogFileBackend: Could not get log file path"); future.complete(false); return; } // Get log level string String levelString; switch (level) { case Log.VERBOSE: levelString = "TRACE"; break; case Log.DEBUG: levelString = "DEBUG"; break; case Log.INFO: levelString = "INFO "; break; case Log.WARN: levelString = "WARN "; break; case Log.ERROR: levelString = "ERROR"; break; default: levelString = "? "; } // Prepare log text final Date now = new Date(); String logLine = now.toString() + '\t' + levelString + " " + LoggingUtil.cleanTag(tag, STRIP_PREFIXES) + ": "; if (message == null) { if (throwable != null) { logLine += Log.getStackTraceString(throwable); } } else { if (throwable == null) { logLine += message; } else { logLine += message + '\n' + Log.getStackTraceString(throwable); } } // Write to logfile try ( final FileWriter fw = new FileWriter(logFile, true); final PrintWriter pw = new PrintWriter(fw) ) { pw.println(logLine); future.complete(true); } catch (Exception e) { // Write failed... future.complete(false); } }); return future; } /** * If the logger is enabled, write the log to the log file. * * Note: I/O is done asynchronously, so the log may not yet be fully written * to storage when this method returns! * * @param level The log level * @param tag The log tag * @param throwable A throwable (may be null) * @param message A message (may be null) */ @Override public synchronized void print( @LogLevel int level, @NonNull String tag, @Nullable Throwable throwable, @Nullable String message ) { this.printAsync(level, tag, throwable, message); } @Override public synchronized void print(@LogLevel int level, @NonNull String tag, @Nullable Throwable throwable, @NonNull String messageFormat, Object... args) { if (!this.isEnabled(level)) { return; } try { this.print(level, tag, throwable, MessageFormatter.arrayFormat(messageFormat, args).getMessage()); } catch (Exception e) { // Never crash this.print(level, tag, throwable, messageFormat); } } @Nullable public static File getZipFile(FileService fileService) { // Open log file final File logFile = getLogFile(); if (logFile == null) { Log.w(TAG, "DebugLogFileBackend: getLogFile returned null"); return null; } // Delete old debug log archive final File tempDebugLogArchive = new File(fileService.getExtTmpPath(), "debug_log.zip"); if (tempDebugLogArchive.exists() && !tempDebugLogArchive.delete()) { Log.w(TAG, "DebugLogFileBackend: Could not delete tempDebugLogArchive"); } // Create and return ZIP try ( final FileInputStream inputStream = new FileInputStream(logFile); final ZipOutputStream zipOutputStream = ZipUtil.initializeZipOutputStream(tempDebugLogArchive, null) ) { final ZipParameters parameters = createZipParameters(logFile.getName()); zipOutputStream.putNextEntry(parameters); final byte[] buf = new byte[16384]; int nread; while ((nread = inputStream.read(buf)) > 0) { zipOutputStream.write(buf, 0, nread); } zipOutputStream.closeEntry(); return tempDebugLogArchive; } catch (Exception e) { return null; } } private static ZipParameters createZipParameters(String filenameInZip) { ZipParameters parameters = new ZipParameters(); parameters.setCompressionMethod(CompressionMethod.DEFLATE); parameters.setCompressionLevel(CompressionLevel.NORMAL); parameters.setFileNameInZip(filenameInZip); return parameters; } }