kopia lustrzana https://github.com/ryukoposting/Signal-Android
Move logging into a database.
rodzic
0b85852621
commit
7419da7247
|
@ -27,14 +27,11 @@ import androidx.multidex.MultiDexApplication;
|
|||
|
||||
import com.google.android.gms.security.ProviderInstaller;
|
||||
|
||||
import net.sqlcipher.database.SQLiteDatabase;
|
||||
|
||||
import org.conscrypt.Conscrypt;
|
||||
import org.signal.aesgcmprovider.AesGcmProvider;
|
||||
import org.signal.core.util.concurrent.SignalExecutors;
|
||||
import org.signal.core.util.logging.AndroidLogger;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.signal.core.util.logging.PersistentLogger;
|
||||
import org.signal.core.util.tracing.Tracer;
|
||||
import org.signal.glide.SignalGlideCodecs;
|
||||
import org.signal.ringrtc.CallManager;
|
||||
|
@ -56,7 +53,7 @@ import org.thoughtcrime.securesms.jobs.RefreshPreKeysJob;
|
|||
import org.thoughtcrime.securesms.jobs.RetrieveProfileJob;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.logging.CustomSignalProtocolLogger;
|
||||
import org.thoughtcrime.securesms.logging.LogSecretProvider;
|
||||
import org.thoughtcrime.securesms.logging.PersistentLogger;
|
||||
import org.thoughtcrime.securesms.messageprocessingalarm.MessageProcessReceiver;
|
||||
import org.thoughtcrime.securesms.migrations.ApplicationMigrations;
|
||||
import org.thoughtcrime.securesms.notifications.NotificationChannels;
|
||||
|
@ -74,7 +71,6 @@ import org.thoughtcrime.securesms.service.UpdateApkRefreshListener;
|
|||
import org.thoughtcrime.securesms.storage.StorageSyncHelper;
|
||||
import org.thoughtcrime.securesms.util.AppForegroundObserver;
|
||||
import org.thoughtcrime.securesms.util.AppStartup;
|
||||
import org.thoughtcrime.securesms.util.ByteUnit;
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.SignalUncaughtExceptionHandler;
|
||||
|
@ -248,7 +244,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
|||
}
|
||||
|
||||
private void initializeLogging() {
|
||||
persistentLogger = new PersistentLogger(this, LogSecretProvider.getOrCreateAttachmentSecret(this), BuildConfig.VERSION_NAME, FeatureFlags.internalUser() ? 15 : 7, ByteUnit.KILOBYTES.toBytes(300));
|
||||
persistentLogger = new PersistentLogger(this, TimeUnit.DAYS.toMillis(2));
|
||||
org.signal.core.util.logging.Log.initialize(FeatureFlags::internalUser, new AndroidLogger(), persistentLogger);
|
||||
|
||||
SignalProtocolLoggerProvider.setProvider(new CustomSignalProtocolLogger());
|
||||
|
|
|
@ -61,7 +61,7 @@ public class KeyValueDatabase extends SQLiteOpenHelper implements SignalDatabase
|
|||
return instance;
|
||||
}
|
||||
|
||||
public KeyValueDatabase(@NonNull Application application, @NonNull DatabaseSecret databaseSecret) {
|
||||
private KeyValueDatabase(@NonNull Application application, @NonNull DatabaseSecret databaseSecret) {
|
||||
super(application, DATABASE_NAME, null, DATABASE_VERSION, new SqlCipherDatabaseHook(), new SqlCipherErrorHandler(DATABASE_NAME));
|
||||
|
||||
this.application = application;
|
||||
|
|
|
@ -0,0 +1,166 @@
|
|||
package org.thoughtcrime.securesms.database
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Application
|
||||
import android.content.ContentValues
|
||||
import android.database.Cursor
|
||||
import net.sqlcipher.database.SQLiteDatabase
|
||||
import net.sqlcipher.database.SQLiteOpenHelper
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.crypto.DatabaseSecret
|
||||
import org.thoughtcrime.securesms.crypto.DatabaseSecretProvider
|
||||
import org.thoughtcrime.securesms.database.model.LogEntry
|
||||
import org.thoughtcrime.securesms.util.CursorUtil
|
||||
import org.thoughtcrime.securesms.util.SqlUtil
|
||||
import java.io.Closeable
|
||||
|
||||
/**
|
||||
* Stores logs.
|
||||
*
|
||||
* Logs are very performance-critical, particularly inserts and deleting old entries.
|
||||
*
|
||||
* This is it's own separate physical database, so it cannot do joins or queries with any other
|
||||
* tables.
|
||||
*/
|
||||
class LogDatabase private constructor(
|
||||
application: Application,
|
||||
private val databaseSecret: DatabaseSecret
|
||||
) : SQLiteOpenHelper(
|
||||
application,
|
||||
DATABASE_NAME,
|
||||
null,
|
||||
DATABASE_VERSION,
|
||||
SqlCipherDatabaseHook(),
|
||||
SqlCipherErrorHandler(DATABASE_NAME)
|
||||
),
|
||||
SignalDatabase {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(LogDatabase::class.java)
|
||||
|
||||
private const val DATABASE_VERSION = 1
|
||||
private const val DATABASE_NAME = "signal-logs.db"
|
||||
|
||||
private const val TABLE_NAME = "log"
|
||||
private const val ID = "_id"
|
||||
private const val CREATED_AT = "created_at"
|
||||
private const val EXPIRES_AT = "expires_at"
|
||||
private const val BODY = "body"
|
||||
private const val SIZE = "size"
|
||||
|
||||
private val CREATE_TABLE = """
|
||||
CREATE TABLE $TABLE_NAME (
|
||||
$ID INTEGER PRIMARY KEY,
|
||||
$CREATED_AT INTEGER,
|
||||
$EXPIRES_AT INTEGER,
|
||||
$BODY TEXT,
|
||||
$SIZE INTEGER
|
||||
)
|
||||
""".trimIndent()
|
||||
|
||||
private val CREATE_INDEXES = arrayOf(
|
||||
"CREATE INDEX log_expires_at_index ON $TABLE_NAME ($EXPIRES_AT)"
|
||||
)
|
||||
|
||||
@SuppressLint("StaticFieldLeak") // We hold an Application context, not a view context
|
||||
@Volatile
|
||||
private var instance: LogDatabase? = null
|
||||
|
||||
@JvmStatic
|
||||
fun getInstance(context: Application): LogDatabase {
|
||||
if (instance == null) {
|
||||
synchronized(LogDatabase::class.java) {
|
||||
if (instance == null) {
|
||||
instance = LogDatabase(context, DatabaseSecretProvider.getOrCreateDatabaseSecret(context))
|
||||
}
|
||||
}
|
||||
}
|
||||
return instance!!
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(db: SQLiteDatabase) {
|
||||
Log.i(TAG, "onCreate()")
|
||||
db.execSQL(CREATE_TABLE)
|
||||
CREATE_INDEXES.forEach { db.execSQL(it) }
|
||||
}
|
||||
|
||||
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
||||
Log.i(TAG, "onUpgrade($oldVersion, $newVersion)")
|
||||
}
|
||||
|
||||
override fun getSqlCipherDatabase(): SQLiteDatabase {
|
||||
return writableDatabase
|
||||
}
|
||||
|
||||
fun insert(logs: List<LogEntry>, currentTime: Long) {
|
||||
val db = writableDatabase
|
||||
|
||||
db.beginTransaction()
|
||||
try {
|
||||
logs.forEach { log ->
|
||||
db.insert(TABLE_NAME, null, buildValues(log))
|
||||
}
|
||||
db.delete(TABLE_NAME, "$EXPIRES_AT < ?", SqlUtil.buildArgs(currentTime))
|
||||
db.setTransactionSuccessful()
|
||||
} finally {
|
||||
db.endTransaction()
|
||||
}
|
||||
}
|
||||
|
||||
fun getAllBeforeTime(time: Long): Reader {
|
||||
return Reader(readableDatabase.query(TABLE_NAME, arrayOf(BODY), "$CREATED_AT < ?", SqlUtil.buildArgs(time), null, null, null))
|
||||
}
|
||||
|
||||
fun getRangeBeforeTime(start: Int, length: Int, time: Long): List<String> {
|
||||
val lines = mutableListOf<String>()
|
||||
|
||||
readableDatabase.query(TABLE_NAME, arrayOf(BODY), "$CREATED_AT < ?", SqlUtil.buildArgs(time), null, null, null, "$start,$length").use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
lines.add(CursorUtil.requireString(cursor, BODY))
|
||||
}
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
fun getLogCountBeforeTime(time: Long): Int {
|
||||
readableDatabase.query(TABLE_NAME, arrayOf("COUNT(*)"), "$CREATED_AT < ?", SqlUtil.buildArgs(time), null, null, null).use { cursor ->
|
||||
return if (cursor.moveToFirst()) {
|
||||
cursor.getInt(0)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildValues(log: LogEntry): ContentValues {
|
||||
return ContentValues().apply {
|
||||
put(CREATED_AT, log.createdAt)
|
||||
put(EXPIRES_AT, log.createdAt + log.lifespan)
|
||||
put(BODY, log.body)
|
||||
put(SIZE, log.body.length)
|
||||
}
|
||||
}
|
||||
|
||||
private val readableDatabase: SQLiteDatabase
|
||||
get() = getReadableDatabase(databaseSecret.asString())
|
||||
|
||||
private val writableDatabase: SQLiteDatabase
|
||||
get() = getWritableDatabase(databaseSecret.asString())
|
||||
|
||||
class Reader(private val cursor: Cursor) : Iterator<String>, Closeable {
|
||||
override fun hasNext(): Boolean {
|
||||
return !cursor.isLast
|
||||
}
|
||||
|
||||
override fun next(): String {
|
||||
cursor.moveToNext()
|
||||
return CursorUtil.requireString(cursor, BODY)
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
cursor.close()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
package org.thoughtcrime.securesms.database.model
|
||||
|
||||
data class LogEntry(
|
||||
val createdAt: Long,
|
||||
val lifespan: Long,
|
||||
val body: String
|
||||
)
|
|
@ -19,29 +19,19 @@ public class HelpViewModel extends ViewModel {
|
|||
|
||||
private static final int MINIMUM_PROBLEM_CHARS = 10;
|
||||
|
||||
private MutableLiveData<Boolean> problemMeetsLengthRequirements = new MutableLiveData<>();
|
||||
private MutableLiveData<Boolean> hasLines = new MutableLiveData<>(false);
|
||||
private MutableLiveData<Integer> categoryIndex = new MutableLiveData<>(0);
|
||||
private LiveData<Boolean> isFormValid = Transformations.map(new LiveDataPair<>(problemMeetsLengthRequirements, hasLines), this::transformValidationData);
|
||||
private final MutableLiveData<Boolean> problemMeetsLengthRequirements;
|
||||
private final MutableLiveData<Integer> categoryIndex;
|
||||
private final LiveData<Boolean> isFormValid;
|
||||
|
||||
private final SubmitDebugLogRepository submitDebugLogRepository;
|
||||
|
||||
private List<LogLine> logLines;
|
||||
|
||||
public HelpViewModel() {
|
||||
submitDebugLogRepository = new SubmitDebugLogRepository();
|
||||
submitDebugLogRepository = new SubmitDebugLogRepository();
|
||||
problemMeetsLengthRequirements = new MutableLiveData<>();
|
||||
categoryIndex = new MutableLiveData<>(0);
|
||||
|
||||
submitDebugLogRepository.getLogLines(lines -> {
|
||||
logLines = lines;
|
||||
hasLines.postValue(true);
|
||||
});
|
||||
|
||||
LiveData<Boolean> firstValid = LiveDataUtil.combineLatest(problemMeetsLengthRequirements, hasLines, (validLength, validLines) -> {
|
||||
return validLength == Boolean.TRUE && validLines == Boolean.TRUE;
|
||||
});
|
||||
|
||||
isFormValid = LiveDataUtil.combineLatest(firstValid, categoryIndex, (valid, index) -> {
|
||||
return valid == Boolean.TRUE && index > 0;
|
||||
isFormValid = LiveDataUtil.combineLatest(problemMeetsLengthRequirements, categoryIndex, (meetsLengthRequirements, index) -> {
|
||||
return meetsLengthRequirements == Boolean.TRUE && index > 0;
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -65,7 +55,7 @@ public class HelpViewModel extends ViewModel {
|
|||
MutableLiveData<SubmitResult> resultLiveData = new MutableLiveData<>();
|
||||
|
||||
if (includeDebugLogs) {
|
||||
submitDebugLogRepository.submitLog(logLines, result -> resultLiveData.postValue(new SubmitResult(result, result.isPresent())));
|
||||
submitDebugLogRepository.buildAndSubmitLog(result -> resultLiveData.postValue(new SubmitResult(result, result.isPresent())));
|
||||
} else {
|
||||
resultLiveData.postValue(new SubmitResult(Optional.absent(), false));
|
||||
}
|
||||
|
@ -73,10 +63,6 @@ public class HelpViewModel extends ViewModel {
|
|||
return resultLiveData;
|
||||
}
|
||||
|
||||
private boolean transformValidationData(Pair<Boolean, Boolean> validationData) {
|
||||
return validationData.first() == Boolean.TRUE && validationData.second() == Boolean.TRUE;
|
||||
}
|
||||
|
||||
static class SubmitResult {
|
||||
private final Optional<String> debugLogUrl;
|
||||
private final boolean isError;
|
||||
|
|
|
@ -39,6 +39,7 @@ import org.thoughtcrime.securesms.migrations.BackupNotificationMigrationJob;
|
|||
import org.thoughtcrime.securesms.migrations.BlobStorageLocationMigrationJob;
|
||||
import org.thoughtcrime.securesms.migrations.CachedAttachmentsMigrationJob;
|
||||
import org.thoughtcrime.securesms.migrations.DatabaseMigrationJob;
|
||||
import org.thoughtcrime.securesms.migrations.DeleteDeprecatedLogsMigrationJob;
|
||||
import org.thoughtcrime.securesms.migrations.DirectoryRefreshMigrationJob;
|
||||
import org.thoughtcrime.securesms.migrations.KbsEnclaveMigrationJob;
|
||||
import org.thoughtcrime.securesms.migrations.LegacyMigrationJob;
|
||||
|
@ -175,6 +176,7 @@ public final class JobManagerFactories {
|
|||
put(BlobStorageLocationMigrationJob.KEY, new BlobStorageLocationMigrationJob.Factory());
|
||||
put(CachedAttachmentsMigrationJob.KEY, new CachedAttachmentsMigrationJob.Factory());
|
||||
put(DatabaseMigrationJob.KEY, new DatabaseMigrationJob.Factory());
|
||||
put(DeleteDeprecatedLogsMigrationJob.KEY, new DeleteDeprecatedLogsMigrationJob.Factory());
|
||||
put(DirectoryRefreshMigrationJob.KEY, new DirectoryRefreshMigrationJob.Factory());
|
||||
put(KbsEnclaveMigrationJob.KEY, new KbsEnclaveMigrationJob.Factory());
|
||||
put(LegacyMigrationJob.KEY, new LegacyMigrationJob.Factory());
|
||||
|
|
|
@ -0,0 +1,206 @@
|
|||
package org.thoughtcrime.securesms.logging
|
||||
|
||||
import android.app.Application
|
||||
import android.os.Looper
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.BuildConfig
|
||||
import org.thoughtcrime.securesms.database.LogDatabase
|
||||
import org.thoughtcrime.securesms.database.model.LogEntry
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.PrintStream
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* A logger that will persist log entries in [LogDatabase].
|
||||
*
|
||||
* We log everywhere, and we never want it to slow down the app, so performance is critical here.
|
||||
* This class takes special care to do as little as possible on the main thread, instead letting the background thread do the work.
|
||||
*
|
||||
* The process looks something like:
|
||||
* - Main thread creates a [LogRequest] object and puts it in a queue
|
||||
* - The [WriteThread] constantly pulls from that queue, formats the logs, and writes them to the database.
|
||||
*/
|
||||
class PersistentLogger(
|
||||
application: Application,
|
||||
defaultLifespan: Long
|
||||
) : Log.Logger() {
|
||||
|
||||
companion object {
|
||||
private const val LOG_V = "V"
|
||||
private const val LOG_D = "D"
|
||||
private const val LOG_I = "I"
|
||||
private const val LOG_W = "W"
|
||||
private const val LOG_E = "E"
|
||||
private const val LOG_WTF = "A"
|
||||
}
|
||||
|
||||
private val logEntries = LogRequests()
|
||||
private val logDatabase = LogDatabase.getInstance(application)
|
||||
private val cachedThreadString: ThreadLocal<String> = ThreadLocal()
|
||||
|
||||
init {
|
||||
WriteThread(logEntries, logDatabase, defaultLifespan).apply {
|
||||
priority = Thread.MIN_PRIORITY
|
||||
}.start()
|
||||
}
|
||||
|
||||
override fun v(tag: String?, message: String?, t: Throwable?) {
|
||||
write(LOG_V, tag, message, t)
|
||||
}
|
||||
|
||||
override fun d(tag: String?, message: String?, t: Throwable?) {
|
||||
write(LOG_D, tag, message, t)
|
||||
}
|
||||
|
||||
override fun i(tag: String?, message: String?, t: Throwable?) {
|
||||
write(LOG_I, tag, message, t)
|
||||
}
|
||||
|
||||
override fun w(tag: String?, message: String?, t: Throwable?) {
|
||||
write(LOG_W, tag, message, t)
|
||||
}
|
||||
|
||||
override fun e(tag: String?, message: String?, t: Throwable?) {
|
||||
write(LOG_E, tag, message, t)
|
||||
}
|
||||
|
||||
override fun wtf(tag: String?, message: String?, t: Throwable?) {
|
||||
write(LOG_WTF, tag, message, t)
|
||||
}
|
||||
|
||||
override fun flush() {
|
||||
logEntries.blockForFlushed()
|
||||
}
|
||||
|
||||
private fun write(level: String, tag: String?, message: String?, t: Throwable?) {
|
||||
logEntries.add(LogRequest(level, tag ?: "null", message, Date(), getThreadString(), t))
|
||||
}
|
||||
|
||||
private fun getThreadString(): String {
|
||||
var threadString = cachedThreadString.get()
|
||||
|
||||
if (cachedThreadString.get() == null) {
|
||||
threadString = if (Looper.myLooper() == Looper.getMainLooper()) {
|
||||
"main "
|
||||
} else {
|
||||
String.format("%-5s", Thread.currentThread().id)
|
||||
}
|
||||
|
||||
cachedThreadString.set(threadString)
|
||||
}
|
||||
|
||||
return threadString!!
|
||||
}
|
||||
|
||||
private data class LogRequest(
|
||||
val level: String,
|
||||
val tag: String,
|
||||
val message: String?,
|
||||
val date: Date,
|
||||
val threadString: String,
|
||||
val throwable: Throwable?
|
||||
)
|
||||
|
||||
private class WriteThread(
|
||||
private val requests: LogRequests,
|
||||
private val db: LogDatabase,
|
||||
private val defaultLifespan: Long
|
||||
) : Thread("signal-logger") {
|
||||
|
||||
private val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS zzz", Locale.US)
|
||||
private val buffer = mutableListOf<LogRequest>()
|
||||
|
||||
override fun run() {
|
||||
while (true) {
|
||||
requests.blockForRequests(buffer)
|
||||
db.insert(buffer.flatMap { requestToEntries(it) }, System.currentTimeMillis())
|
||||
buffer.clear()
|
||||
requests.notifyFlushed()
|
||||
}
|
||||
}
|
||||
|
||||
fun requestToEntries(request: LogRequest): List<LogEntry> {
|
||||
val out = mutableListOf<LogEntry>()
|
||||
|
||||
out.add(
|
||||
LogEntry(
|
||||
createdAt = request.date.time,
|
||||
lifespan = defaultLifespan,
|
||||
body = formatBody(request.threadString, request.date, request.level, request.tag, request.message)
|
||||
)
|
||||
)
|
||||
|
||||
if (request.throwable != null) {
|
||||
val outputStream = ByteArrayOutputStream()
|
||||
request.throwable.printStackTrace(PrintStream(outputStream))
|
||||
|
||||
val trace = String(outputStream.toByteArray())
|
||||
val lines = trace.split("\\n".toRegex()).toTypedArray()
|
||||
|
||||
val entries = lines.map { line ->
|
||||
LogEntry(
|
||||
createdAt = request.date.time,
|
||||
lifespan = defaultLifespan,
|
||||
body = formatBody(request.threadString, request.date, request.level, request.tag, line)
|
||||
)
|
||||
}
|
||||
|
||||
out.addAll(entries)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
fun formatBody(threadString: String, date: Date, level: String, tag: String, message: String?): String {
|
||||
return "[${BuildConfig.VERSION_NAME}] [$threadString] ${dateFormat.format(date)} $level $tag: $message"
|
||||
}
|
||||
}
|
||||
|
||||
private class LogRequests {
|
||||
val logs = mutableListOf<LogRequest>()
|
||||
val logLock = Object()
|
||||
|
||||
var flushed = false
|
||||
val flushedLock = Object()
|
||||
|
||||
fun add(entry: LogRequest) {
|
||||
synchronized(logLock) {
|
||||
logs.add(entry)
|
||||
logLock.notify()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Blocks until requests are available. When they are, the [buffer] will be populated with all pending requests.
|
||||
* Note: This method gets hit a *lot*, which is why we're using a buffer instead of spamming out new lists every time.
|
||||
*/
|
||||
fun blockForRequests(buffer: MutableList<LogRequest>) {
|
||||
synchronized(logLock) {
|
||||
while (logs.isEmpty()) {
|
||||
logLock.wait()
|
||||
}
|
||||
|
||||
buffer.addAll(logs)
|
||||
logs.clear()
|
||||
flushed = false
|
||||
}
|
||||
}
|
||||
|
||||
fun blockForFlushed() {
|
||||
synchronized(flushedLock) {
|
||||
while (!flushed) {
|
||||
flushedLock.wait()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun notifyFlushed() {
|
||||
synchronized(flushedLock) {
|
||||
flushed = true
|
||||
flushedLock.notify()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,42 @@
|
|||
package org.thoughtcrime.securesms.logsubmit
|
||||
|
||||
import android.app.Application
|
||||
import org.signal.paging.PagedDataSource
|
||||
import org.thoughtcrime.securesms.database.LogDatabase
|
||||
import org.thoughtcrime.securesms.logsubmit.util.Scrubber
|
||||
|
||||
/**
|
||||
* Retrieves logs to show in the [SubmitDebugLogActivity].
|
||||
*
|
||||
* @param prefixLines A static list of lines to show before all of the lines retrieved from [LogDatabase]
|
||||
* @param untilTime Only show logs before this time. This is our way of making sure the set of logs we show on this screen doesn't grow.
|
||||
*/
|
||||
class LogDataSource(
|
||||
application: Application,
|
||||
private val prefixLines: List<LogLine>,
|
||||
private val untilTime: Long
|
||||
) :
|
||||
PagedDataSource<LogLine> {
|
||||
|
||||
val logDatabase = LogDatabase.getInstance(application)
|
||||
|
||||
override fun size(): Int {
|
||||
return prefixLines.size + logDatabase.getLogCountBeforeTime(untilTime)
|
||||
}
|
||||
|
||||
override fun load(start: Int, length: Int, cancellationSignal: PagedDataSource.CancellationSignal): List<LogLine> {
|
||||
if (start + length < prefixLines.size) {
|
||||
return prefixLines.subList(start, start + length)
|
||||
} else if (start < prefixLines.size) {
|
||||
return prefixLines.subList(start, prefixLines.size) +
|
||||
logDatabase.getRangeBeforeTime(0, length - (prefixLines.size - start), untilTime).map { convertToLogLine(it) }
|
||||
} else {
|
||||
return logDatabase.getRangeBeforeTime(start - prefixLines.size, length, untilTime).map { convertToLogLine(it) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun convertToLogLine(raw: String): LogLine {
|
||||
val scrubbed: String = Scrubber.scrub(raw).toString()
|
||||
return SimpleLogLine(scrubbed, LogStyleParser.parseStyle(scrubbed), LogLine.Placeholder.NONE)
|
||||
}
|
||||
}
|
|
@ -4,9 +4,10 @@ import android.content.Context;
|
|||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.thoughtcrime.securesms.ApplicationContext;
|
||||
|
||||
public class LogSectionLogger implements LogSection {
|
||||
/**
|
||||
* Because the actual contents of this section are paged from the database, this class just has a header and no content.
|
||||
*/
|
||||
public class LogSectionLoggerHeader implements LogSection {
|
||||
|
||||
@Override
|
||||
public @NonNull String getTitle() {
|
||||
|
@ -15,7 +16,6 @@ public class LogSectionLogger implements LogSection {
|
|||
|
||||
@Override
|
||||
public @NonNull CharSequence getContent(@NonNull Context context) {
|
||||
CharSequence logs = ApplicationContext.getInstance(context).getPersistentLogger().getLogs();
|
||||
return logs != null ? logs : "Unable to retrieve logs.";
|
||||
return "";
|
||||
}
|
||||
}
|
|
@ -60,6 +60,8 @@ public class SubmitDebugLogActivity extends BaseActivity implements SubmitDebugL
|
|||
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
|
||||
getSupportActionBar().setTitle(R.string.HelpSettingsFragment__debug_log);
|
||||
|
||||
this.viewModel = ViewModelProviders.of(this, new SubmitDebugLogViewModel.Factory()).get(SubmitDebugLogViewModel.class);
|
||||
|
||||
initView();
|
||||
initViewModel();
|
||||
}
|
||||
|
@ -115,16 +117,13 @@ public class SubmitDebugLogActivity extends BaseActivity implements SubmitDebugL
|
|||
public boolean onOptionsItemSelected(MenuItem item) {
|
||||
super.onOptionsItemSelected(item);
|
||||
|
||||
switch (item.getItemId()) {
|
||||
case android.R.id.home:
|
||||
finish();
|
||||
return true;
|
||||
case R.id.menu_edit_log:
|
||||
viewModel.onEditButtonPressed();
|
||||
break;
|
||||
case R.id.menu_done_editing_log:
|
||||
viewModel.onDoneEditingButtonPressed();
|
||||
break;
|
||||
if (item.getItemId() == android.R.id.home) {
|
||||
finish();
|
||||
return true;
|
||||
} else if (item.getItemId() == R.id.menu_edit_log) {
|
||||
viewModel.onEditButtonPressed();
|
||||
} else if (item.getItemId() == R.id.menu_done_editing_log) {
|
||||
viewModel.onDoneEditingButtonPressed();
|
||||
}
|
||||
|
||||
return false;
|
||||
|
@ -150,10 +149,11 @@ public class SubmitDebugLogActivity extends BaseActivity implements SubmitDebugL
|
|||
this.scrollToBottomButton = findViewById(R.id.debug_log_scroll_to_bottom);
|
||||
this.scrollToTopButton = findViewById(R.id.debug_log_scroll_to_top);
|
||||
|
||||
this.adapter = new SubmitDebugLogAdapter(this);
|
||||
this.adapter = new SubmitDebugLogAdapter(this, viewModel.getPagingController());
|
||||
|
||||
this.lineList.setLayoutManager(new LinearLayoutManager(this));
|
||||
this.lineList.setAdapter(adapter);
|
||||
this.lineList.setItemAnimator(null);
|
||||
|
||||
submitButton.setOnClickListener(v -> onSubmitClicked());
|
||||
|
||||
|
@ -181,8 +181,6 @@ public class SubmitDebugLogActivity extends BaseActivity implements SubmitDebugL
|
|||
}
|
||||
|
||||
private void initViewModel() {
|
||||
this.viewModel = ViewModelProviders.of(this, new SubmitDebugLogViewModel.Factory()).get(SubmitDebugLogViewModel.class);
|
||||
|
||||
viewModel.getLines().observe(this, this::presentLines);
|
||||
viewModel.getMode().observe(this, this::presentMode);
|
||||
}
|
||||
|
@ -196,7 +194,7 @@ public class SubmitDebugLogActivity extends BaseActivity implements SubmitDebugL
|
|||
submitButton.setVisibility(View.VISIBLE);
|
||||
}
|
||||
|
||||
adapter.setLines(lines);
|
||||
adapter.submitList(lines);
|
||||
}
|
||||
|
||||
private void presentMode(@NonNull SubmitDebugLogViewModel.Mode mode) {
|
||||
|
@ -204,9 +202,10 @@ public class SubmitDebugLogActivity extends BaseActivity implements SubmitDebugL
|
|||
case NORMAL:
|
||||
editBanner.setVisibility(View.GONE);
|
||||
adapter.setEditing(false);
|
||||
editMenuItem.setVisible(true);
|
||||
doneMenuItem.setVisible(false);
|
||||
searchMenuItem.setVisible(true);
|
||||
// TODO [greyson][log] Not yet implemented
|
||||
// editMenuItem.setVisible(true);
|
||||
// doneMenuItem.setVisible(false);
|
||||
// searchMenuItem.setVisible(true);
|
||||
break;
|
||||
case SUBMITTING:
|
||||
editBanner.setVisibility(View.GONE);
|
||||
|
|
|
@ -10,8 +10,7 @@ import androidx.annotation.NonNull;
|
|||
import androidx.core.content.ContextCompat;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import org.signal.paging.PagingController;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.ListenableHorizontalScrollView;
|
||||
|
||||
|
@ -21,26 +20,52 @@ import java.util.concurrent.CopyOnWriteArrayList;
|
|||
|
||||
public class SubmitDebugLogAdapter extends RecyclerView.Adapter<SubmitDebugLogAdapter.LineViewHolder> {
|
||||
|
||||
private static final int MAX_LINE_LENGTH = 1000;
|
||||
private static final int LINE_LENGTH = 500;
|
||||
|
||||
private final List<LogLine> lines;
|
||||
private final ScrollManager scrollManager;
|
||||
private final Listener listener;
|
||||
private static final int TYPE_LOG = 1;
|
||||
private static final int TYPE_PLACEHOLDER = 2;
|
||||
|
||||
private final ScrollManager scrollManager;
|
||||
private final Listener listener;
|
||||
private final PagingController pagingController;
|
||||
private final List<LogLine> lines;
|
||||
|
||||
private boolean editing;
|
||||
private int longestLine;
|
||||
|
||||
public SubmitDebugLogAdapter(@NonNull Listener listener) {
|
||||
this.listener = listener;
|
||||
this.lines = new ArrayList<>();
|
||||
this.scrollManager = new ScrollManager();
|
||||
public SubmitDebugLogAdapter(@NonNull Listener listener, @NonNull PagingController pagingController) {
|
||||
this.listener = listener;
|
||||
this.pagingController = pagingController;
|
||||
this.scrollManager = new ScrollManager();
|
||||
this.lines = new ArrayList<>();
|
||||
|
||||
setHasStableIds(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getItemId(int position) {
|
||||
return lines.get(position).getId();
|
||||
LogLine item = getItem(position);
|
||||
return item != null ? getItem(position).getId() : -1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemViewType(int position) {
|
||||
return getItem(position) == null ? TYPE_PLACEHOLDER : TYPE_LOG;
|
||||
}
|
||||
|
||||
protected LogLine getItem(int position) {
|
||||
pagingController.onDataNeededAroundIndex(position);
|
||||
return lines.get(position);
|
||||
}
|
||||
|
||||
public void submitList(@NonNull List<LogLine> list) {
|
||||
this.lines.clear();
|
||||
this.lines.addAll(list);
|
||||
notifyDataSetChanged();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemCount() {
|
||||
return lines.size();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -50,7 +75,13 @@ public class SubmitDebugLogAdapter extends RecyclerView.Adapter<SubmitDebugLogAd
|
|||
|
||||
@Override
|
||||
public void onBindViewHolder(@NonNull LineViewHolder holder, int position) {
|
||||
holder.bind(lines.get(position), longestLine, editing, scrollManager, listener);
|
||||
LogLine item = getItem(position);
|
||||
|
||||
if (item == null) {
|
||||
item = SimpleLogLine.EMPTY;
|
||||
}
|
||||
|
||||
holder.bind(item, LINE_LENGTH, editing, scrollManager, listener);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -58,21 +89,6 @@ public class SubmitDebugLogAdapter extends RecyclerView.Adapter<SubmitDebugLogAd
|
|||
holder.unbind(scrollManager);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemCount() {
|
||||
return lines.size();
|
||||
}
|
||||
|
||||
public void setLines(@NonNull List<LogLine> lines) {
|
||||
this.lines.clear();
|
||||
this.lines.addAll(lines);
|
||||
|
||||
this.longestLine = Stream.of(lines).reduce(0, (currentMax, line) -> Math.max(currentMax, line.getText().length()));
|
||||
this.longestLine = Math.min(longestLine, MAX_LINE_LENGTH);
|
||||
|
||||
notifyDataSetChanged();
|
||||
}
|
||||
|
||||
public void setEditing(boolean editing) {
|
||||
this.editing = editing;
|
||||
notifyDataSetChanged();
|
||||
|
|
|
@ -1,7 +1,10 @@
|
|||
package org.thoughtcrime.securesms.logsubmit;
|
||||
|
||||
import android.app.Application;
|
||||
import android.content.Context;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.ParcelFileDescriptor;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
@ -11,20 +14,26 @@ import com.annimon.stream.Stream;
|
|||
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
import org.signal.core.util.StreamUtil;
|
||||
import org.signal.core.util.concurrent.SignalExecutors;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.signal.core.util.tracing.Tracer;
|
||||
import org.thoughtcrime.securesms.database.LogDatabase;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.logsubmit.util.Scrubber;
|
||||
import org.thoughtcrime.securesms.net.StandardUserAgentInterceptor;
|
||||
import org.thoughtcrime.securesms.providers.BlobProvider;
|
||||
import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.zip.GZIPOutputStream;
|
||||
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.MultipartBody;
|
||||
|
@ -33,6 +42,9 @@ import okhttp3.Request;
|
|||
import okhttp3.RequestBody;
|
||||
import okhttp3.Response;
|
||||
import okhttp3.ResponseBody;
|
||||
import okio.BufferedSink;
|
||||
import okio.Okio;
|
||||
import okio.Source;
|
||||
|
||||
/**
|
||||
* Handles retrieving, scrubbing, and uploading of all debug logs.
|
||||
|
@ -68,10 +80,10 @@ public class SubmitDebugLogRepository {
|
|||
add(new LogSectionThreads());
|
||||
add(new LogSectionBlockedThreads());
|
||||
add(new LogSectionLogcat());
|
||||
add(new LogSectionLogger());
|
||||
add(new LogSectionLoggerHeader());
|
||||
}};
|
||||
|
||||
private final Context context;
|
||||
private final Application context;
|
||||
private final ExecutorService executor;
|
||||
|
||||
public SubmitDebugLogRepository() {
|
||||
|
@ -79,44 +91,88 @@ public class SubmitDebugLogRepository {
|
|||
this.executor = SignalExecutors.SERIAL;
|
||||
}
|
||||
|
||||
public void getLogLines(@NonNull Callback<List<LogLine>> callback) {
|
||||
executor.execute(() -> callback.onResult(getLogLinesInternal()));
|
||||
public void getPrefixLogLines(@NonNull Callback<List<LogLine>> callback) {
|
||||
executor.execute(() -> callback.onResult(getPrefixLogLinesInternal()));
|
||||
}
|
||||
|
||||
public void submitLog(@NonNull List<LogLine> lines, Callback<Optional<String>> callback) {
|
||||
SignalExecutors.UNBOUNDED.execute(() -> callback.onResult(submitLogInternal(lines, null)));
|
||||
public void buildAndSubmitLog(@NonNull Callback<Optional<String>> callback) {
|
||||
SignalExecutors.UNBOUNDED.execute(() -> callback.onResult(submitLogInternal(System.currentTimeMillis(), getPrefixLogLinesInternal(), Tracer.getInstance().serialize())));
|
||||
}
|
||||
|
||||
public void submitLog(@NonNull List<LogLine> lines, @Nullable byte[] trace, Callback<Optional<String>> callback) {
|
||||
SignalExecutors.UNBOUNDED.execute(() -> callback.onResult(submitLogInternal(lines, trace)));
|
||||
/**
|
||||
* Submits a log with the provided prefix lines.
|
||||
*
|
||||
* @param untilTime Only submit logs from {@link LogDatabase} if they were created before this time. This is our way of making sure that the logs we submit
|
||||
* only include the logs that we've already shown the user. It's possible some old logs may have been trimmed off in the meantime, but no
|
||||
* new ones could pop up.
|
||||
*/
|
||||
public void submitLogWithPrefixLines(long untilTime, @NonNull List<LogLine> prefixLines, @Nullable byte[] trace, Callback<Optional<String>> callback) {
|
||||
SignalExecutors.UNBOUNDED.execute(() -> callback.onResult(submitLogInternal(untilTime, prefixLines, trace)));
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private @NonNull Optional<String> submitLogInternal(@NonNull List<LogLine> lines, @Nullable byte[] trace) {
|
||||
private @NonNull Optional<String> submitLogInternal(long untilTime, @NonNull List<LogLine> prefixLines, @Nullable byte[] trace) {
|
||||
String traceUrl = null;
|
||||
if (trace != null) {
|
||||
try {
|
||||
traceUrl = uploadContent("application/octet-stream", trace);
|
||||
traceUrl = uploadContent("application/octet-stream", RequestBody.create(MediaType.get("application/octet-stream"), trace));
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, "Error during trace upload.", e);
|
||||
return Optional.absent();
|
||||
}
|
||||
}
|
||||
|
||||
StringBuilder bodyBuilder = new StringBuilder();
|
||||
for (LogLine line : lines) {
|
||||
StringBuilder prefixStringBuilder = new StringBuilder();
|
||||
for (LogLine line : prefixLines) {
|
||||
switch (line.getPlaceholderType()) {
|
||||
case NONE:
|
||||
bodyBuilder.append(line.getText()).append('\n');
|
||||
prefixStringBuilder.append(line.getText()).append('\n');
|
||||
break;
|
||||
case TRACE:
|
||||
bodyBuilder.append(traceUrl).append('\n');
|
||||
prefixStringBuilder.append(traceUrl).append('\n');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
String logUrl = uploadContent("text/plain", bodyBuilder.toString().getBytes());
|
||||
ParcelFileDescriptor[] fds = ParcelFileDescriptor.createPipe();
|
||||
Uri gzipUri = BlobProvider.getInstance()
|
||||
.forData(new ParcelFileDescriptor.AutoCloseInputStream(fds[0]), 0)
|
||||
.withMimeType("application/gzip")
|
||||
.createForSingleSessionOnDiskAsync(context, null, null);
|
||||
|
||||
OutputStream gzipOutput = new GZIPOutputStream(new ParcelFileDescriptor.AutoCloseOutputStream(fds[1]));
|
||||
|
||||
gzipOutput.write(prefixStringBuilder.toString().getBytes());
|
||||
|
||||
try (LogDatabase.Reader reader = LogDatabase.getInstance(context).getAllBeforeTime(untilTime)) {
|
||||
while (reader.hasNext()) {
|
||||
gzipOutput.write(Scrubber.scrub(reader.next()).toString().getBytes());
|
||||
gzipOutput.write("\n".getBytes());
|
||||
}
|
||||
}
|
||||
|
||||
StreamUtil.close(gzipOutput);
|
||||
|
||||
String logUrl = uploadContent("application/gzip", new RequestBody() {
|
||||
@Override
|
||||
public @NonNull MediaType contentType() {
|
||||
return MediaType.get("application/gzip");
|
||||
}
|
||||
|
||||
@Override public long contentLength() {
|
||||
return BlobProvider.getInstance().calculateFileSize(context, gzipUri);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeTo(@NonNull BufferedSink sink) throws IOException {
|
||||
Source source = Okio.source(BlobProvider.getInstance().getStream(context, gzipUri));
|
||||
sink.writeAll(source);
|
||||
}
|
||||
});
|
||||
|
||||
BlobProvider.getInstance().delete(context, gzipUri);
|
||||
|
||||
return Optional.of(logUrl);
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, "Error during log upload.", e);
|
||||
|
@ -125,7 +181,7 @@ public class SubmitDebugLogRepository {
|
|||
}
|
||||
|
||||
@WorkerThread
|
||||
private @NonNull String uploadContent(@NonNull String contentType, @NonNull byte[] content) throws IOException {
|
||||
private @NonNull String uploadContent(@NonNull String contentType, @NonNull RequestBody requestBody) throws IOException {
|
||||
try {
|
||||
OkHttpClient client = new OkHttpClient.Builder().addInterceptor(new StandardUserAgentInterceptor()).dns(SignalServiceNetworkAccess.DNS).build();
|
||||
Response response = client.newCall(new Request.Builder().url(API_ENDPOINT).get().build()).execute();
|
||||
|
@ -149,7 +205,7 @@ public class SubmitDebugLogRepository {
|
|||
post.addFormDataPart(key, fields.getString(key));
|
||||
}
|
||||
|
||||
post.addFormDataPart("file", "file", RequestBody.create(MediaType.parse(contentType), content));
|
||||
post.addFormDataPart("file", "file", requestBody);
|
||||
|
||||
Response postResponse = client.newCall(new Request.Builder().url(url).post(post.build()).build()).execute();
|
||||
|
||||
|
@ -165,7 +221,7 @@ public class SubmitDebugLogRepository {
|
|||
}
|
||||
|
||||
@WorkerThread
|
||||
private @NonNull List<LogLine> getLogLinesInternal() {
|
||||
private @NonNull List<LogLine> getPrefixLogLinesInternal() {
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
int maxTitleLength = Stream.of(SECTIONS).reduce(0, (max, section) -> Math.max(max, section.getTitle().length()));
|
||||
|
|
|
@ -1,42 +1,60 @@
|
|||
package org.thoughtcrime.securesms.logsubmit;
|
||||
|
||||
import android.text.TextUtils;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.MediatorLiveData;
|
||||
import androidx.lifecycle.MutableLiveData;
|
||||
import androidx.lifecycle.ViewModel;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import org.signal.core.util.ThreadUtil;
|
||||
import org.signal.core.util.tracing.Tracer;
|
||||
import org.signal.paging.PagedData;
|
||||
import org.signal.paging.PagingConfig;
|
||||
import org.signal.paging.PagingController;
|
||||
import org.signal.paging.ProxyPagingController;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.util.DefaultValueLiveData;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class SubmitDebugLogViewModel extends ViewModel {
|
||||
|
||||
private final SubmitDebugLogRepository repo;
|
||||
private final DefaultValueLiveData<List<LogLine>> lines;
|
||||
private final MutableLiveData<Mode> mode;
|
||||
private final SubmitDebugLogRepository repo;
|
||||
private final MutableLiveData<Mode> mode;
|
||||
private final ProxyPagingController pagingController;
|
||||
private final List<LogLine> staticLines;
|
||||
private final MediatorLiveData<List<LogLine>> lines;
|
||||
private final long firstViewTime;
|
||||
private final byte[] trace;
|
||||
|
||||
private List<LogLine> sourceLines;
|
||||
private byte[] trace;
|
||||
|
||||
private SubmitDebugLogViewModel() {
|
||||
this.repo = new SubmitDebugLogRepository();
|
||||
this.lines = new DefaultValueLiveData<>(Collections.emptyList());
|
||||
this.mode = new MutableLiveData<>();
|
||||
this.trace = Tracer.getInstance().serialize();
|
||||
this.repo = new SubmitDebugLogRepository();
|
||||
this.mode = new MutableLiveData<>();
|
||||
this.trace = Tracer.getInstance().serialize();
|
||||
this.pagingController = new ProxyPagingController();
|
||||
this.firstViewTime = System.currentTimeMillis();
|
||||
this.staticLines = new ArrayList<>();
|
||||
this.lines = new MediatorLiveData<>();
|
||||
|
||||
repo.getLogLines(result -> {
|
||||
sourceLines = result;
|
||||
mode.postValue(Mode.NORMAL);
|
||||
lines.postValue(sourceLines);
|
||||
repo.getPrefixLogLines(staticLines -> {
|
||||
this.staticLines.addAll(staticLines);
|
||||
|
||||
LogDataSource dataSource = new LogDataSource(ApplicationDependencies.getApplication(), staticLines, firstViewTime);
|
||||
PagingConfig config = new PagingConfig.Builder().setPageSize(100)
|
||||
.setBufferPages(3)
|
||||
.setStartIndex(0)
|
||||
.build();
|
||||
|
||||
PagedData<LogLine> pagedData = PagedData.create(dataSource, config);
|
||||
|
||||
ThreadUtil.runOnMain(() -> {
|
||||
pagingController.set(pagedData.getController());
|
||||
lines.addSource(pagedData.getData(), lines::setValue);
|
||||
mode.setValue(Mode.NORMAL);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -44,6 +62,10 @@ public class SubmitDebugLogViewModel extends ViewModel {
|
|||
return lines;
|
||||
}
|
||||
|
||||
@NonNull PagingController getPagingController() {
|
||||
return pagingController;
|
||||
}
|
||||
|
||||
@NonNull LiveData<Mode> getMode() {
|
||||
return mode;
|
||||
}
|
||||
|
@ -53,7 +75,7 @@ public class SubmitDebugLogViewModel extends ViewModel {
|
|||
|
||||
MutableLiveData<Optional<String>> result = new MutableLiveData<>();
|
||||
|
||||
repo.submitLog(lines.getValue(), trace, value -> {
|
||||
repo.submitLogWithPrefixLines(firstViewTime, staticLines, trace, value -> {
|
||||
mode.postValue(Mode.NORMAL);
|
||||
result.postValue(value);
|
||||
});
|
||||
|
@ -62,35 +84,23 @@ public class SubmitDebugLogViewModel extends ViewModel {
|
|||
}
|
||||
|
||||
void onQueryUpdated(@NonNull String query) {
|
||||
if (TextUtils.isEmpty(query)) {
|
||||
lines.postValue(sourceLines);
|
||||
} else {
|
||||
List<LogLine> filtered = Stream.of(sourceLines)
|
||||
.filter(l -> l.getText().toLowerCase().contains(query.toLowerCase()))
|
||||
.toList();
|
||||
lines.postValue(filtered);
|
||||
}
|
||||
throw new UnsupportedOperationException("Not yet implemented.");
|
||||
}
|
||||
|
||||
void onSearchClosed() {
|
||||
lines.postValue(sourceLines);
|
||||
throw new UnsupportedOperationException("Not yet implemented.");
|
||||
}
|
||||
|
||||
void onEditButtonPressed() {
|
||||
mode.setValue(Mode.EDIT);
|
||||
throw new UnsupportedOperationException("Not yet implemented.");
|
||||
}
|
||||
|
||||
void onDoneEditingButtonPressed() {
|
||||
mode.setValue(Mode.NORMAL);
|
||||
throw new UnsupportedOperationException("Not yet implemented.");
|
||||
}
|
||||
|
||||
void onLogDeleted(@NonNull LogLine line) {
|
||||
sourceLines.remove(line);
|
||||
|
||||
List<LogLine> logs = lines.getValue();
|
||||
logs.remove(line);
|
||||
|
||||
lines.postValue(logs);
|
||||
throw new UnsupportedOperationException("Not yet implemented.");
|
||||
}
|
||||
|
||||
boolean onBackPressed() {
|
||||
|
|
|
@ -78,9 +78,10 @@ public class ApplicationMigrations {
|
|||
static final int SENDER_KEY_2 = 36;
|
||||
static final int DB_AUTOINCREMENT = 37;
|
||||
static final int ATTACHMENT_CLEANUP = 38;
|
||||
static final int LOG_CLEANUP = 39;
|
||||
}
|
||||
|
||||
public static final int CURRENT_VERSION = 38;
|
||||
public static final int CURRENT_VERSION = 39;
|
||||
|
||||
/**
|
||||
* This *must* be called after the {@link JobManager} has been instantiated, but *before* the call
|
||||
|
@ -342,6 +343,10 @@ public class ApplicationMigrations {
|
|||
jobs.put(Version.ATTACHMENT_CLEANUP, new AttachmentCleanupMigrationJob());
|
||||
}
|
||||
|
||||
if (lastSeenVersion < Version.LOG_CLEANUP) {
|
||||
jobs.put(Version.LOG_CLEANUP, new DeleteDeprecatedLogsMigrationJob());
|
||||
}
|
||||
|
||||
return jobs;
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,72 @@
|
|||
package org.thoughtcrime.securesms.migrations;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.jobmanager.Data;
|
||||
import org.thoughtcrime.securesms.jobmanager.Job;
|
||||
|
||||
import java.io.File;
|
||||
|
||||
/**
|
||||
* We moved from storing logs in encrypted files to just storing them in an encrypted database. So we need to delete the leftover files.
|
||||
*/
|
||||
public class DeleteDeprecatedLogsMigrationJob extends MigrationJob {
|
||||
|
||||
private static final String TAG = Log.tag(DeleteDeprecatedLogsMigrationJob.class);
|
||||
|
||||
public static final String KEY = "DeleteDeprecatedLogsMigrationJob";
|
||||
|
||||
public DeleteDeprecatedLogsMigrationJob() {
|
||||
this(new Parameters.Builder().build());
|
||||
}
|
||||
|
||||
private DeleteDeprecatedLogsMigrationJob(@NonNull Parameters parameters) {
|
||||
super(parameters);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull String getFactoryKey() {
|
||||
return KEY;
|
||||
}
|
||||
|
||||
@Override
|
||||
boolean isUiBlocking() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
void performMigration() {
|
||||
File logDir = new File(context.getCacheDir(), "log");
|
||||
if (logDir.exists()) {
|
||||
File[] files = logDir.listFiles();
|
||||
|
||||
int count = 0;
|
||||
if (files != null) {
|
||||
for (File f : files) {
|
||||
count += f.delete() ? 1 : 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (!logDir.delete()) {
|
||||
Log.w(TAG, "Failed to delete log directory.");
|
||||
}
|
||||
|
||||
Log.i(TAG, "Deleted " + count + " log files.");
|
||||
} else {
|
||||
Log.w(TAG, "Log directory does not exist.");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
boolean shouldRetry(@NonNull Exception e) {
|
||||
return false;
|
||||
}
|
||||
|
||||
public static final class Factory implements Job.Factory<DeleteDeprecatedLogsMigrationJob> {
|
||||
@Override
|
||||
public @NonNull DeleteDeprecatedLogsMigrationJob create(@NonNull Parameters parameters, @NonNull Data data) {
|
||||
return new DeleteDeprecatedLogsMigrationJob(parameters);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -89,22 +89,18 @@ public final class ShakeToReport implements ShakeDetector.Listener {
|
|||
|
||||
Log.i(TAG, "Submitting log...");
|
||||
|
||||
repo.getLogLines(lines -> {
|
||||
Log.i(TAG, "Retrieved log lines...");
|
||||
repo.buildAndSubmitLog(url -> {
|
||||
Log.i(TAG, "Logs uploaded!");
|
||||
|
||||
repo.submitLog(lines, Tracer.getInstance().serialize(), url -> {
|
||||
Log.i(TAG, "Logs uploaded!");
|
||||
ThreadUtil.runOnMain(() -> {
|
||||
spinner.dismiss();
|
||||
|
||||
ThreadUtil.runOnMain(() -> {
|
||||
spinner.dismiss();
|
||||
|
||||
if (url.isPresent()) {
|
||||
showPostSubmitDialog(activity, url.get());
|
||||
} else {
|
||||
Toast.makeText(activity, R.string.ShakeToReport_failed_to_submit, Toast.LENGTH_SHORT).show();
|
||||
enableIfVisible();
|
||||
}
|
||||
});
|
||||
if (url.isPresent()) {
|
||||
showPostSubmitDialog(activity, url.get());
|
||||
} else {
|
||||
Toast.makeText(activity, R.string.ShakeToReport_failed_to_submit, Toast.LENGTH_SHORT).show();
|
||||
enableIfVisible();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
@ -11,7 +11,7 @@
|
|||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="8dp"
|
||||
android:text="@string/SubmitDebugLogActivity_this_log_will_be_posted_publicly_online"
|
||||
android:text="@string/SubmitDebugLogActivity_this_log_will_be_posted_publicly_online_for_contributors"
|
||||
android:textColor="@color/core_black"
|
||||
android:background="@color/core_yellow"
|
||||
android:visibility="gone"
|
||||
|
@ -47,9 +47,8 @@
|
|||
android:id="@+id/debug_log_lines"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:scrollbars="vertical"
|
||||
app:layout_constraintTop_toBottomOf="@id/debug_log_header_barrier"
|
||||
app:layout_constraintBottom_toTopOf="@id/debug_log_submit_button"/>
|
||||
|
||||
|
|
|
@ -3,6 +3,8 @@
|
|||
android:id="@+id/log_item_scroll"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_width="match_parent"
|
||||
android:paddingStart="8dp"
|
||||
android:paddingEnd="8dp"
|
||||
android:scrollbars="none">
|
||||
|
||||
<TextView
|
||||
|
|
|
@ -1573,7 +1573,7 @@
|
|||
<string name="SubmitDebugLogActivity_success">Success!</string>
|
||||
<string name="SubmitDebugLogActivity_copy_this_url_and_add_it_to_your_issue">Copy this URL and add it to your issue report or support email:\n\n<b>%1$s</b></string>
|
||||
<string name="SubmitDebugLogActivity_share">Share</string>
|
||||
<string name="SubmitDebugLogActivity_this_log_will_be_posted_publicly_online">This log will be posted publicly online for contributors to view. You may examine and edit it before uploading.</string>
|
||||
<string name="SubmitDebugLogActivity_this_log_will_be_posted_publicly_online_for_contributors">This log will be posted publicly online for contributors to view. You may examine it before uploading.</string>
|
||||
|
||||
<!-- SupportEmailUtil -->
|
||||
<string name="SupportEmailUtil_support_email" translatable="false">support@signal.org</string>
|
||||
|
@ -2020,9 +2020,6 @@
|
|||
<!-- giphy_fragment -->
|
||||
<string name="giphy_fragment__nothing_found">Nothing found</string>
|
||||
|
||||
<!-- log_submit_activity -->
|
||||
<string name="log_submit_activity__this_log_will_be_posted_online">This log will be posted publicly online for contributors to view, you may examine and edit it before submitting.</string>
|
||||
|
||||
<!-- database_migration_activity -->
|
||||
<string name="database_migration_activity__would_you_like_to_import_your_existing_text_messages">Would you like to import your existing text messages into Signal\'s encrypted database?</string>
|
||||
<string name="database_migration_activity__the_default_system_database_will_not_be_modified">The default system database will not be modified or altered in any way.</string>
|
||||
|
|
|
@ -22,5 +22,5 @@ public class EmptyLogger extends Log.Logger {
|
|||
public void wtf(String tag, String message, Throwable t) { }
|
||||
|
||||
@Override
|
||||
public void blockUntilAllWritesFinished() { }
|
||||
public void flush() { }
|
||||
}
|
||||
|
|
|
@ -48,7 +48,7 @@ public final class LogRecorder extends Log.Logger {
|
|||
}
|
||||
|
||||
@Override
|
||||
public void blockUntilAllWritesFinished() {
|
||||
public void flush() {
|
||||
}
|
||||
|
||||
public List<Entry> getVerbose() {
|
||||
|
|
|
@ -34,7 +34,7 @@ public final class SystemOutLogger extends Log.Logger {
|
|||
}
|
||||
|
||||
@Override
|
||||
public void blockUntilAllWritesFinished() { }
|
||||
public void flush() { }
|
||||
|
||||
private void printlnFormatted(char level, String tag, String message, Throwable t) {
|
||||
System.out.println(format(level, tag, message, t));
|
||||
|
|
|
@ -36,6 +36,6 @@ public final class AndroidLogger extends Log.Logger {
|
|||
}
|
||||
|
||||
@Override
|
||||
public void blockUntilAllWritesFinished() {
|
||||
public void flush() {
|
||||
}
|
||||
}
|
||||
|
|
|
@ -57,9 +57,9 @@ class CompoundLogger extends Log.Logger {
|
|||
}
|
||||
|
||||
@Override
|
||||
public void blockUntilAllWritesFinished() {
|
||||
public void flush() {
|
||||
for (Log.Logger logger : loggers) {
|
||||
logger.blockUntilAllWritesFinished();
|
||||
logger.flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,8 +5,6 @@ import android.annotation.SuppressLint;
|
|||
import androidx.annotation.MainThread;
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import java.util.logging.Logger;
|
||||
|
||||
@SuppressLint("LogNotSignal")
|
||||
public final class Log {
|
||||
|
||||
|
@ -124,7 +122,7 @@ public final class Log {
|
|||
}
|
||||
|
||||
public static void blockUntilAllWritesFinished() {
|
||||
logger.blockUntilAllWritesFinished();
|
||||
logger.flush();
|
||||
}
|
||||
|
||||
public static abstract class Logger {
|
||||
|
@ -134,7 +132,7 @@ public final class Log {
|
|||
public abstract void w(String tag, String message, Throwable t);
|
||||
public abstract void e(String tag, String message, Throwable t);
|
||||
public abstract void wtf(String tag, String message, Throwable t);
|
||||
public abstract void blockUntilAllWritesFinished();
|
||||
public abstract void flush();
|
||||
|
||||
public void v(String tag, String message) {
|
||||
v(tag, message, null);
|
||||
|
|
|
@ -23,5 +23,5 @@ class NoopLogger extends Log.Logger {
|
|||
public void wtf(String tag, String message, Throwable t) { }
|
||||
|
||||
@Override
|
||||
public void blockUntilAllWritesFinished() { }
|
||||
public void flush() { }
|
||||
}
|
||||
|
|
|
@ -1,274 +0,0 @@
|
|||
package org.signal.core.util.logging;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.Context;
|
||||
import android.os.Looper;
|
||||
|
||||
import androidx.annotation.AnyThread;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.WorkerThread;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.PrintStream;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Arrays;
|
||||
import java.util.Date;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
@SuppressLint("LogNotSignal")
|
||||
public final class PersistentLogger extends Log.Logger {
|
||||
|
||||
private static final String TAG = PersistentLogger.class.getSimpleName();
|
||||
|
||||
private static final String LOG_V = "V";
|
||||
private static final String LOG_D = "D";
|
||||
private static final String LOG_I = "I";
|
||||
private static final String LOG_W = "W";
|
||||
private static final String LOG_E = "E";
|
||||
private static final String LOG_WTF = "A";
|
||||
|
||||
private static final String LOG_DIRECTORY = "log";
|
||||
private static final String FILENAME_PREFIX = "log-";
|
||||
private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS zzz", Locale.US);
|
||||
|
||||
private final Context context;
|
||||
private final Executor executor;
|
||||
private final byte[] secret;
|
||||
private final String logTag;
|
||||
private final int maxLogFiles;
|
||||
private final long maxLogFileSize;
|
||||
|
||||
private LogFile.Writer writer;
|
||||
|
||||
private final ThreadLocal<String> cachedThreadString;
|
||||
|
||||
public PersistentLogger(@NonNull Context context, @NonNull byte[] secret, @NonNull String logTag, int maxLogFiles, long maxLogFileSize) {
|
||||
this.context = context.getApplicationContext();
|
||||
this.secret = secret;
|
||||
this.logTag = logTag;
|
||||
this.maxLogFiles = maxLogFiles;
|
||||
this.maxLogFileSize = maxLogFileSize;
|
||||
this.cachedThreadString = new ThreadLocal<>();
|
||||
this.executor = Executors.newSingleThreadExecutor(r -> {
|
||||
Thread thread = new Thread(r, "signal-PersistentLogger");
|
||||
thread.setPriority(Thread.MIN_PRIORITY);
|
||||
return thread;
|
||||
});
|
||||
|
||||
executor.execute(this::initializeWriter);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void v(String tag, String message, Throwable t) {
|
||||
write(LOG_V, tag, message, t);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void d(String tag, String message, Throwable t) {
|
||||
write(LOG_D, tag, message, t);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void i(String tag, String message, Throwable t) {
|
||||
write(LOG_I, tag, message, t);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void w(String tag, String message, Throwable t) {
|
||||
write(LOG_W, tag, message, t);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void e(String tag, String message, Throwable t) {
|
||||
write(LOG_E, tag, message, t);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void wtf(String tag, String message, Throwable t) {
|
||||
write(LOG_WTF, tag, message, t);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void blockUntilAllWritesFinished() {
|
||||
CountDownLatch latch = new CountDownLatch(1);
|
||||
|
||||
executor.execute(latch::countDown);
|
||||
|
||||
try {
|
||||
latch.await();
|
||||
} catch (InterruptedException e) {
|
||||
android.util.Log.w(TAG, "Failed to wait for all writes.");
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
public @Nullable CharSequence getLogs() {
|
||||
CountDownLatch latch = new CountDownLatch(1);
|
||||
AtomicReference<CharSequence> logs = new AtomicReference<>();
|
||||
|
||||
executor.execute(() -> {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
|
||||
try {
|
||||
File[] logFiles = getSortedLogFiles();
|
||||
for (int i = logFiles.length - 1; i >= 0; i--) {
|
||||
try {
|
||||
LogFile.Reader reader = new LogFile.Reader(secret, logFiles[i]);
|
||||
builder.append(reader.readAll());
|
||||
} catch (IOException e) {
|
||||
android.util.Log.w(TAG, "Failed to read log at index " + i + ". Removing reference.");
|
||||
logFiles[i].delete();
|
||||
}
|
||||
}
|
||||
|
||||
logs.set(builder);
|
||||
} catch (IOException e) {
|
||||
logs.set(null);
|
||||
}
|
||||
|
||||
latch.countDown();
|
||||
});
|
||||
|
||||
try {
|
||||
latch.await();
|
||||
return logs.get();
|
||||
} catch (InterruptedException e) {
|
||||
android.util.Log.w(TAG, "Failed to wait for logs to be retrieved.");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private void initializeWriter() {
|
||||
try {
|
||||
writer = new LogFile.Writer(secret, getOrCreateActiveLogFile());
|
||||
} catch (IOException e) {
|
||||
android.util.Log.e(TAG, "Failed to initialize writer.", e);
|
||||
}
|
||||
}
|
||||
|
||||
@AnyThread
|
||||
private void write(String level, String tag, String message, Throwable t) {
|
||||
String threadString = cachedThreadString.get();
|
||||
|
||||
if (cachedThreadString.get() == null) {
|
||||
if (Looper.myLooper() == Looper.getMainLooper()) {
|
||||
threadString = "main ";
|
||||
} else {
|
||||
threadString = String.format("%-5s", Thread.currentThread().getId());
|
||||
}
|
||||
|
||||
cachedThreadString.set(threadString);
|
||||
}
|
||||
|
||||
final String finalThreadString = threadString;
|
||||
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
if (writer == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (writer.getLogSize() >= maxLogFileSize) {
|
||||
writer.close();
|
||||
writer = new LogFile.Writer(secret, createNewLogFile());
|
||||
trimLogFilesOverMax();
|
||||
}
|
||||
|
||||
for (String entry : buildLogEntries(level, tag, message, t, finalThreadString)) {
|
||||
writer.writeEntry(entry);
|
||||
}
|
||||
|
||||
} catch (IOException e) {
|
||||
android.util.Log.w(TAG, "Failed to write line. Deleting all logs and starting over.");
|
||||
deleteAllLogs();
|
||||
initializeWriter();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void trimLogFilesOverMax() throws IOException {
|
||||
File[] logs = getSortedLogFiles();
|
||||
if (logs.length > maxLogFiles) {
|
||||
for (int i = maxLogFiles; i < logs.length; i++) {
|
||||
logs[i].delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void deleteAllLogs() {
|
||||
try {
|
||||
File[] logs = getSortedLogFiles();
|
||||
for (File log : logs) {
|
||||
log.delete();
|
||||
}
|
||||
} catch (IOException e) {
|
||||
android.util.Log.w(TAG, "Was unable to delete logs.", e);
|
||||
}
|
||||
}
|
||||
|
||||
private File getOrCreateActiveLogFile() throws IOException {
|
||||
File[] logs = getSortedLogFiles();
|
||||
if (logs.length > 0) {
|
||||
return logs[0];
|
||||
}
|
||||
|
||||
return createNewLogFile();
|
||||
}
|
||||
|
||||
private File createNewLogFile() throws IOException {
|
||||
return new File(getOrCreateLogDirectory(), FILENAME_PREFIX + System.currentTimeMillis());
|
||||
}
|
||||
|
||||
private File[] getSortedLogFiles() throws IOException {
|
||||
File[] logs = getOrCreateLogDirectory().listFiles();
|
||||
if (logs != null) {
|
||||
Arrays.sort(logs, (o1, o2) -> o2.getName().compareTo(o1.getName()));
|
||||
return logs;
|
||||
}
|
||||
return new File[0];
|
||||
}
|
||||
|
||||
private File getOrCreateLogDirectory() throws IOException {
|
||||
File logDir = new File(context.getCacheDir(), LOG_DIRECTORY);
|
||||
if (!logDir.exists() && !logDir.mkdir()) {
|
||||
throw new IOException("Unable to create log directory.");
|
||||
}
|
||||
|
||||
return logDir;
|
||||
}
|
||||
|
||||
private List<String> buildLogEntries(String level, String tag, String message, Throwable t, String threadString) {
|
||||
List<String> entries = new LinkedList<>();
|
||||
Date date = new Date();
|
||||
|
||||
entries.add(buildEntry(level, tag, message, date, threadString));
|
||||
|
||||
if (t != null) {
|
||||
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
|
||||
t.printStackTrace(new PrintStream(outputStream));
|
||||
|
||||
String trace = new String(outputStream.toByteArray());
|
||||
String[] lines = trace.split("\\n");
|
||||
|
||||
for (String line : lines) {
|
||||
entries.add(buildEntry(level, tag, line, date, threadString));
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
private String buildEntry(String level, String tag, String message, Date date, String threadString) {
|
||||
return '[' + logTag + "] [" + threadString + "] " + DATE_FORMAT.format(date) + ' ' + level + ' ' + tag + ": " + message;
|
||||
}
|
||||
}
|
Ładowanie…
Reference in New Issue