New logger implementation

Fixes #4228

Signed-off-by: Chris Narkiewicz <hello@ezaquarii.com>
This commit is contained in:
Chris Narkiewicz 2019-07-31 23:19:12 +01:00
parent 0469db38ca
commit 79e8d59aa1
No known key found for this signature in database
GPG Key ID: 30D28CA4CCC665C6
35 changed files with 2060 additions and 313 deletions

3
.gitignore vendored
View File

@ -32,6 +32,7 @@ tests/proguard-project.txt
*.iml
build
/gradle.properties
.attach_pid*
fastlane/Fastfile
*.hprof

View File

@ -60,6 +60,11 @@
</option>
</JavaCodeStyleSettings>
<JetCodeStyleSettings>
<option name="PACKAGES_TO_USE_STAR_IMPORTS">
<value>
<package name="kotlinx.android.synthetic" withSubpackages="true" static="false" />
</value>
</option>
<option name="NAME_COUNT_TO_USE_STAR_IMPORT" value="2147483647" />
<option name="NAME_COUNT_TO_USE_STAR_IMPORT_FOR_MEMBERS" value="2147483647" />
</JetCodeStyleSettings>

View File

@ -57,7 +57,7 @@ configurations {
ext {
jacocoVersion = "0.8.2"
daggerVersion = "2.24"
androidLibraryVersion = "master-SNAPSHOT"
androidLibraryVersion = "plug-custom-impl-into-legacy-logger-SNAPSHOT"
travisBuild = System.getenv("TRAVIS") == "true"
@ -164,6 +164,10 @@ android {
versionName "1"
}
}
testOptions {
unitTests.returnDefaultValues = true
}
}
// adapt structure from Eclipse to Gradle/Android Studio expectations;

View File

@ -22,6 +22,7 @@ test-pattern: # Configure exclusions for test sources
- 'ForEachOnRange'
- 'FunctionMaxLength'
- 'TooGenericExceptionCaught'
- 'TooGenericExceptionThrown'
- 'InstanceOfCheckForException'
build:

View File

@ -318,7 +318,7 @@
<activity android:name=".ui.activity.ConflictsResolveActivity"/>
<activity android:name=".ui.activity.ErrorsWhileCopyingHandlerActivity"/>
<activity android:name=".ui.activity.LogHistoryActivity"/>
<activity android:name=".ui.activity.LogsActivity"/>
<activity android:name="com.nextcloud.client.errorhandling.ShowErrorActivity"
android:theme="@style/Theme.ownCloud.Toolbar"

View File

@ -0,0 +1,33 @@
/*
* Nextcloud Android client application
*
* @author Chris Narkiewicz
* Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* 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 <http://www.gnu.org/licenses/>.
*/
package com.nextcloud.client.core
typealias TaskBody<T> = () -> T
typealias OnResultCallback<T> = (T) -> Unit
typealias OnErrorCallback = (Throwable) -> Unit
/**
* This interface allows to post background tasks that report results via callbacks invoked on main thread.
*
* It is provided as an alternative for heavy, platform specific and virtually untestable [android.os.AsyncTask]
*/
interface AsyncRunner {
fun <T> post(block: () -> T, onResult: OnResultCallback<T>? = null, onError: OnErrorCallback? = null): Cancellable
}

View File

@ -0,0 +1,65 @@
/*
* Nextcloud Android client application
*
* @author Chris Narkiewicz
* Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* 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 <http://www.gnu.org/licenses/>.
*/
package com.nextcloud.client.core
import android.os.Handler
import java.util.concurrent.ScheduledThreadPoolExecutor
import java.util.concurrent.atomic.AtomicBoolean
internal class AsyncRunnerImpl(private val uiThreadHandler: Handler, corePoolSize: Int) : AsyncRunner {
private class Task<T>(
private val handler: Handler,
private val callable: () -> T,
private val onSuccess: OnResultCallback<T>?,
private val onError: OnErrorCallback?
) : Runnable, Cancellable {
private val cancelled = AtomicBoolean(false)
override fun run() {
@Suppress("TooGenericExceptionCaught") // this is exactly what we want here
try {
val result = callable.invoke()
if (!cancelled.get()) {
handler.post {
onSuccess?.invoke(result)
}
}
} catch (t: Throwable) {
if (!cancelled.get()) {
handler.post { onError?.invoke(t) }
}
}
}
override fun cancel() {
cancelled.set(true)
}
}
private val executor = ScheduledThreadPoolExecutor(corePoolSize)
override fun <T> post(block: () -> T, onResult: OnResultCallback<T>?, onError: OnErrorCallback?): Cancellable {
val task = Task(uiThreadHandler, block, onResult, onError)
executor.execute(task)
return task
}
}

View File

@ -0,0 +1,24 @@
/*
* Nextcloud Android client application
*
* @author Chris Narkiewicz
* Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* 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 <http://www.gnu.org/licenses/>.
*/
package com.nextcloud.client.core
interface Cancellable {
fun cancel()
}

View File

@ -0,0 +1,29 @@
/*
* Nextcloud Android client application
*
* @author Chris Narkiewicz
* Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* 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 <http://www.gnu.org/licenses/>.
*/
package com.nextcloud.client.core
import java.util.Date
import java.util.TimeZone
interface Clock {
val currentTime: Long
val currentDate: Date
val tz: TimeZone
}

View File

@ -0,0 +1,38 @@
/*
* Nextcloud Android client application
*
* @author Chris Narkiewicz
* Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* 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 <http://www.gnu.org/licenses/>.
*/
package com.nextcloud.client.core
import java.util.Date
import java.util.TimeZone
class ClockImpl : Clock {
override val currentTime: Long
get() {
return System.currentTimeMillis()
}
override val currentDate: Date
get() {
return Date(currentTime)
}
override val tz: TimeZone
get() = TimeZone.getDefault()
}

View File

@ -25,11 +25,20 @@ import android.app.Application;
import android.content.ContentResolver;
import android.content.Context;
import android.content.res.Resources;
import android.os.Handler;
import com.nextcloud.client.account.CurrentAccountProvider;
import com.nextcloud.client.account.UserAccountManager;
import com.nextcloud.client.account.UserAccountManagerImpl;
import com.nextcloud.client.core.AsyncRunner;
import com.nextcloud.client.core.AsyncRunnerImpl;
import com.nextcloud.client.core.Clock;
import com.nextcloud.client.core.ClockImpl;
import com.nextcloud.client.device.DeviceInfo;
import com.nextcloud.client.logger.FileLogHandler;
import com.nextcloud.client.logger.Logger;
import com.nextcloud.client.logger.LoggerImpl;
import com.nextcloud.client.logger.LogsRepository;
import com.owncloud.android.datamodel.ArbitraryDataProvider;
import com.owncloud.android.datamodel.UploadsStorageManager;
import com.owncloud.android.ui.activities.data.activities.ActivitiesRepository;
@ -40,6 +49,10 @@ import com.owncloud.android.ui.activities.data.files.FilesRepository;
import com.owncloud.android.ui.activities.data.files.FilesServiceApiImpl;
import com.owncloud.android.ui.activities.data.files.RemoteFilesRepository;
import java.io.File;
import javax.inject.Singleton;
import dagger.Module;
import dagger.Provides;
@ -104,4 +117,33 @@ class AppModule {
DeviceInfo deviceInfo() {
return new DeviceInfo();
}
@Provides
@Singleton
Clock clock() {
return new ClockImpl();
}
@Provides
@Singleton
Logger logger(Context context, Clock clock) {
File logDir = new File(context.getFilesDir(), "logs");
FileLogHandler handler = new FileLogHandler(logDir, "log.txt", 1024*1024);
LoggerImpl logger = new LoggerImpl(clock, handler, new Handler(), 1000);
logger.start();
return logger;
}
@Provides
@Singleton
LogsRepository logsRepository(Logger logger) {
return (LogsRepository)logger;
}
@Provides
@Singleton
AsyncRunner asyncRunner() {
Handler uiHandler = new Handler();
return new AsyncRunnerImpl(uiHandler, 4);
}
}

View File

@ -45,7 +45,7 @@ import com.owncloud.android.ui.activity.ExternalSiteWebView;
import com.owncloud.android.ui.activity.FileDisplayActivity;
import com.owncloud.android.ui.activity.FilePickerActivity;
import com.owncloud.android.ui.activity.FolderPickerActivity;
import com.owncloud.android.ui.activity.LogHistoryActivity;
import com.owncloud.android.ui.activity.LogsActivity;
import com.owncloud.android.ui.activity.ManageAccountsActivity;
import com.owncloud.android.ui.activity.ManageSpaceActivity;
import com.owncloud.android.ui.activity.NotificationsActivity;
@ -101,7 +101,7 @@ abstract class ComponentsModule {
@ContributesAndroidInjector abstract FilePickerActivity filePickerActivity();
@ContributesAndroidInjector abstract FirstRunActivity firstRunActivity();
@ContributesAndroidInjector abstract FolderPickerActivity folderPickerActivity();
@ContributesAndroidInjector abstract LogHistoryActivity logHistoryActivity();
@ContributesAndroidInjector abstract LogsActivity logsActivity();
@ContributesAndroidInjector abstract ManageAccountsActivity manageAccountsActivity();
@ContributesAndroidInjector abstract ManageSpaceActivity manageSpaceActivity();
@ContributesAndroidInjector abstract NotificationsActivity notificationsActivity();

View File

@ -22,6 +22,7 @@ package com.nextcloud.client.di
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.nextcloud.client.etm.EtmViewModel
import com.nextcloud.client.logger.ui.LogsViewModel
import dagger.Binds
import dagger.Module
import dagger.multibindings.IntoMap
@ -33,6 +34,11 @@ abstract class ViewModelModule {
@ViewModelKey(EtmViewModel::class)
abstract fun etmViewModel(vm: EtmViewModel): ViewModel
@Binds
@IntoMap
@ViewModelKey(LogsViewModel::class)
abstract fun logsViewModel(vm: LogsViewModel): ViewModel
@Binds
abstract fun bindViewModelFactory(factory: ViewModelFactory): ViewModelProvider.Factory
}

View File

@ -0,0 +1,131 @@
/*
* Nextcloud Android client application
*
* @author Chris Narkiewicz
* Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* 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 <http://www.gnu.org/licenses/>.
*/
package com.nextcloud.client.logger
import java.io.File
import java.io.FileNotFoundException
import java.io.FileOutputStream
import java.io.IOException
import java.nio.charset.Charset
/**
* Very simple log writer with file rotations.
*
* Files are rotated when writing entry causes log file to exceed it's maximum size.
* Last entry is not truncated and final log file can exceed max file size, but
* no further entries will be written to it.
*/
internal class FileLogHandler(private val logDir: File, private val logFilename: String, private val maxSize: Long) {
companion object {
const val ROTATED_LOGS_COUNT = 3
}
private var writer: FileOutputStream? = null
private var size: Long = 0
private val rotationList = listOf(
"$logFilename.2",
"$logFilename.1",
"$logFilename.0",
logFilename
)
val logFile: File
get() {
return File(logDir, logFilename)
}
val isOpened: Boolean
get() {
return writer != null
}
val maxLogFilesCount get() = rotationList.size
fun open() {
try {
writer = FileOutputStream(logFile, true)
size = logFile.length()
} catch (ex: FileNotFoundException) {
logFile.parentFile.mkdirs()
writer = FileOutputStream(logFile, true)
size = logFile.length()
}
}
fun write(logEntry: String) {
val rawLogEntry = logEntry.toByteArray(Charset.forName("UTF-8"))
writer?.write(rawLogEntry)
size += rawLogEntry.size
if (size > maxSize) {
rotateLogs()
}
}
fun close() {
writer?.close()
writer = null
size = 0L
}
fun deleteAll() {
rotationList
.map { File(logDir, it) }
.forEach { it.delete() }
}
fun rotateLogs() {
val rotatatingOpenedLog = isOpened
if (rotatatingOpenedLog) {
close()
}
val existingLogFiles = logDir.listFiles().associate { it.name to it }
existingLogFiles[rotationList.first()]?.delete()
for (i in 0 until rotationList.size - 1) {
val nextFile = File(logDir, rotationList[i])
val previousFile = existingLogFiles[rotationList[i + 1]]
previousFile?.renameTo(nextFile)
}
if (rotatatingOpenedLog) {
open()
}
}
fun loadLogFiles(rotated: Int = ROTATED_LOGS_COUNT): List<String> {
if (rotated < 0) {
throw IllegalArgumentException("Negative index")
}
val allLines = mutableListOf<String>()
for (i in 0..Math.min(rotated, rotationList.size - 1)) {
val file = File(logDir, rotationList[i])
if (!file.exists()) continue
try {
val rotatedLines = file.readLines(charset = Charsets.UTF_8)
allLines.addAll(rotatedLines)
} catch (ex: IOException) {
// ignore failing file
}
}
return allLines
}
}

View File

@ -0,0 +1,61 @@
/*
* Nextcloud Android client application
*
* @author Chris Narkiewicz
* Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* 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 <http://www.gnu.org/licenses/>.
*/
package com.nextcloud.client.logger
import com.owncloud.android.lib.common.utils.Log_OC
import java.lang.Exception
/**
* This adapter is used by legacy [Log_OC] logger to redirect logs to custom logger implementation.
*/
class LegacyLoggerAdapter(private val logger: Logger) : Log_OC.Adapter {
override fun i(tag: String, message: String) {
logger.d(tag, message)
}
override fun d(tag: String, message: String) {
logger.d(tag, message)
}
override fun d(tag: String, message: String, e: Exception) {
logger.d(tag, message, e)
}
override fun e(tag: String, message: String) {
logger.e(tag, message)
}
override fun e(tag: String, message: String, t: Throwable) {
logger.e(tag, message, t)
}
override fun v(tag: String, message: String) {
logger.v(tag, message)
}
override fun w(tag: String, message: String) {
logger.w(tag, message)
}
override fun wtf(tag: String, message: String) {
logger.e(tag, message)
}
}

View File

@ -0,0 +1,43 @@
/*
* Nextcloud Android client application
*
* @author Chris Narkiewicz
* Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* 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 <http://www.gnu.org/licenses/>.
*/
package com.nextcloud.client.logger
enum class Level(val tag: String) {
UNKNOWN("U"),
VERBOSE("V"),
DEBUG("D"),
INFO("I"),
WARNING("W"),
ERROR("E"),
ASSERT("A");
companion object {
@JvmStatic
fun fromTag(tag: String): Level = when (tag) {
"V" -> VERBOSE
"D" -> DEBUG
"I" -> INFO
"W" -> WARNING
"E" -> ERROR
"A" -> ASSERT
else -> UNKNOWN
}
}
}

View File

@ -0,0 +1,107 @@
/*
* Nextcloud Android client application
*
* @author Chris Narkiewicz
* Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* 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 <http://www.gnu.org/licenses/>.
*/
package com.nextcloud.client.logger
import java.text.ParseException
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
data class LogEntry(val timestamp: Date, val level: Level, val tag: String, val message: String) {
companion object {
private const val UTC_DATE_FORMAT = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"
private const val TZ_DATE_FORMAT = "yyyy-MM-dd'T'HH:mm:ss.SSSZ"
private val TIME_ZONE = TimeZone.getTimeZone("UTC")
private val DATE_GROUP_INDEX = 1
private val LEVEL_GROUP_INDEX = 2
private val TAG_GROUP_INDEX = 3
private val MESSAGE_GROUP_INDEX = 4
/**
* <iso8601 date>;<level tag>;<entry tag>;<message>
* 1970-01-01T00:00:00.000Z;D;tag;some message
*/
private val ENTRY_PARSE_REGEXP = Regex(
pattern = """(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z);([ADEIVW]);([^;]+);(.*)"""
)
@JvmStatic
fun buildDateFormat(tz: TimeZone? = null): SimpleDateFormat {
return if (tz == null) {
SimpleDateFormat(UTC_DATE_FORMAT, Locale.US).apply {
timeZone = TIME_ZONE
isLenient = false
}
} else {
SimpleDateFormat(TZ_DATE_FORMAT, Locale.US).apply {
timeZone = tz
isLenient = false
}
}
}
@Suppress("ReturnCount")
@JvmStatic
fun parse(s: String): LogEntry? {
val result = ENTRY_PARSE_REGEXP.matchEntire(s) ?: return null
val date = try {
buildDateFormat().parse(result.groupValues[DATE_GROUP_INDEX])
} catch (ex: ParseException) {
return null
}
val level: Level = Level.fromTag(result.groupValues[LEVEL_GROUP_INDEX])
val tag = result.groupValues[TAG_GROUP_INDEX]
val message = result.groupValues[MESSAGE_GROUP_INDEX].replace("\\n", "\n")
return LogEntry(
timestamp = date,
level = level,
tag = tag,
message = message
)
}
}
override fun toString(): String {
val sb = StringBuilder()
format(sb, buildDateFormat())
return sb.toString()
}
fun toString(tz: TimeZone): String {
val sb = StringBuilder()
format(sb, buildDateFormat(tz))
return sb.toString()
}
private fun format(sb: StringBuilder, dateFormat: SimpleDateFormat) {
sb.append(dateFormat.format(timestamp))
sb.append(';')
sb.append(level.tag)
sb.append(';')
sb.append(tag.replace(';', ' '))
sb.append(';')
sb.append(message.replace("\n", "\\n"))
}
}

View File

@ -0,0 +1,31 @@
/*
* Nextcloud Android client application
*
* @author Chris Narkiewicz
* Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* 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 <http://www.gnu.org/licenses/>.
*/
package com.nextcloud.client.logger
interface Logger {
fun v(tag: String, message: String)
fun d(tag: String, message: String)
fun d(tag: String, message: String, t: Throwable)
fun i(tag: String, message: String)
fun w(tag: String, message: String)
fun e(tag: String, message: String)
fun e(tag: String, message: String, t: Throwable)
}

View File

@ -0,0 +1,165 @@
/*
* Nextcloud Android client application
*
* @author Chris Narkiewicz
* Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* 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 <http://www.gnu.org/licenses/>.
*/
package com.nextcloud.client.logger
import android.os.Handler
import android.util.Log
import com.nextcloud.client.core.Clock
import java.util.Date
import java.util.concurrent.BlockingQueue
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
@Suppress("TooManyFunctions")
internal class LoggerImpl(
private val clock: Clock,
private val handler: FileLogHandler,
private val mainThreadHandler: Handler,
queueCapacity: Int
) : Logger, LogsRepository {
data class Load(val listener: LogsRepository.Listener)
class Delete
private val looper = ThreadLoop()
private val eventQueue: BlockingQueue<Any> = LinkedBlockingQueue(queueCapacity)
private val processedEvents = mutableListOf<Any>()
private val otherEvents = mutableListOf<Any>()
private val missedLogs = AtomicBoolean()
private val missedLogsCount = AtomicLong()
override val lostEntries: Boolean
get() {
return missedLogs.get()
}
fun start() {
looper.start(this::eventLoop)
}
override fun v(tag: String, message: String) {
Log.v(tag, message)
enqueue(Level.VERBOSE, tag, message)
}
override fun d(tag: String, message: String) {
Log.d(tag, message)
enqueue(Level.DEBUG, tag, message)
}
override fun d(tag: String, message: String, t: Throwable) {
Log.d(tag, message)
enqueue(Level.DEBUG, tag, message)
}
override fun i(tag: String, message: String) {
Log.i(tag, message)
enqueue(Level.INFO, tag, message)
}
override fun w(tag: String, message: String) {
Log.w(tag, message)
enqueue(Level.WARNING, tag, message)
}
override fun e(tag: String, message: String) {
Log.e(tag, message)
enqueue(Level.ERROR, tag, message)
}
override fun e(tag: String, message: String, t: Throwable) {
Log.e(tag, message)
enqueue(Level.ERROR, tag, message)
}
override fun load(listener: LogsRepository.Listener) {
eventQueue.put(Load(listener = listener))
}
override fun deleteAll() {
eventQueue.put(Delete())
}
private fun enqueue(level: Level, tag: String, message: String) {
val entry = LogEntry(timestamp = clock.currentDate, level = level, tag = tag, message = message)
val enqueued = eventQueue.offer(entry, 1, TimeUnit.SECONDS)
if (!enqueued) {
missedLogs.set(true)
missedLogsCount.incrementAndGet()
}
}
private fun eventLoop() {
try {
processedEvents.clear()
otherEvents.clear()
processedEvents.add(eventQueue.take())
eventQueue.drainTo(processedEvents)
// process all writes in bulk - this is most frequest use case and we can
// assume handler must be opened 99.999% of time; anything that is not a log
// write should be deferred
handler.open()
for (event in processedEvents) {
if (event is LogEntry) {
handler.write(event.toString() + "\n")
} else {
otherEvents.add(event)
}
}
handler.close()
// Those events are very sporadic and we don't have to be clever here
for (event in otherEvents) {
when (event) {
is Load -> {
val entries = handler.loadLogFiles().mapNotNull { LogEntry.parse(it) }
mainThreadHandler.post { event.listener.onLoaded(entries) }
}
is Delete -> handler.deleteAll()
}
}
checkAndLogLostMessages()
} catch (ex: InterruptedException) {
handler.close()
throw ex
}
}
private fun checkAndLogLostMessages() {
val lastMissedLogsCount = missedLogsCount.getAndSet(0)
if (lastMissedLogsCount > 0) {
handler.open()
val warning = LogEntry(
timestamp = Date(),
level = Level.WARNING,
tag = "Logger",
message = "Logger queue overflow. Approx $lastMissedLogsCount entries lost. You write too much."
).toString()
handler.write(warning)
handler.close()
}
}
}

View File

@ -0,0 +1,51 @@
/*
* Nextcloud Android client application
*
* @author Chris Narkiewicz
* Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* 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 <http://www.gnu.org/licenses/>.
*/
package com.nextcloud.client.logger
/**
* This interface provides safe, read only access to application
* logs stored on a device.
*/
interface LogsRepository {
@FunctionalInterface
interface Listener {
fun onLoaded(entries: List<LogEntry>)
}
/**
* If true, logger was unable to handle some messages, which means
* it cannot cope with amount of logged data.
*
* This property is thread-safe.
*/
val lostEntries: Boolean
/**
* Asynchronously load available logs. Load can be scheduled on any thread,
* but the listener will be called on main thread.
*/
fun load(listener: Listener)
/**
* Asynchronously delete logs.
*/
fun deleteAll()
}

View File

@ -0,0 +1,76 @@
/*
* Nextcloud Android client application
*
* @author Chris Narkiewicz
* Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* 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 <http://www.gnu.org/licenses/>.
*/
package com.nextcloud.client.logger
/**
* This utility runs provided loop body continuously in a loop on a background thread
* and allows start and stop the loop thread in a safe way.
*/
internal class ThreadLoop {
private val lock = Object()
private var thread: Thread? = null
private var loopBody: (() -> Unit)? = null
/**
* Start running [loopBody] in a loop on a background [Thread].
* If loop is already started, it no-ops.
*
* This method is thread safe.
*
* @throws IllegalStateException if loop is already running
*/
fun start(loopBody: () -> Unit) {
synchronized(lock) {
if (thread == null) {
this.loopBody = loopBody
this.thread = Thread(this::loop)
this.thread?.start()
}
}
}
/**
* Stops the background [Thread] by interrupting it and waits for [Thread.join].
* If loop is not started, it no-ops.
*
* This method is thread safe.
*
* @throws IllegalStateException if thread is not running
*/
fun stop() {
synchronized(lock) {
if (thread != null) {
thread?.interrupt()
thread?.join()
}
}
}
private fun loop() {
try {
while (true) {
loopBody?.invoke()
}
} catch (ex: InterruptedException) {
return
}
}
}

View File

@ -0,0 +1,60 @@
/*
* Nextcloud Android client application
*
* @author Chris Narkiewicz
* Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* 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 <http://www.gnu.org/licenses/>.
*/
package com.nextcloud.client.logger.ui
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import com.nextcloud.client.logger.LogEntry
import com.owncloud.android.R
import java.text.SimpleDateFormat
import java.util.Locale
class LogsAdapter(context: Context) : RecyclerView.Adapter<LogsAdapter.ViewHolder>() {
class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
val header = view.findViewById<TextView>(R.id.log_entry_list_item_header)
val message = view.findViewById<TextView>(R.id.log_entry_list_item_message)
}
private val timestampFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
private val inflater = LayoutInflater.from(context)
var entries: List<LogEntry> = listOf()
set(value) {
field = value
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
ViewHolder(inflater.inflate(R.layout.log_entry_list_item, parent, false))
override fun getItemCount() = entries.size
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val entry = entries[position]
val header = "${timestampFormat.format(entry.timestamp)} ${entry.level.tag} ${entry.tag}"
holder.header.text = header
holder.message.text = entry.message
}
}

View File

@ -0,0 +1,93 @@
/*
* Nextcloud Android client application
*
* @author Chris Narkiewicz
* Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* 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 <http://www.gnu.org/licenses/>.
*/
package com.nextcloud.client.logger.ui
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.widget.Toast
import androidx.core.content.FileProvider
import com.nextcloud.client.core.AsyncRunner
import com.nextcloud.client.core.Cancellable
import com.nextcloud.client.core.Clock
import com.nextcloud.client.logger.LogEntry
import com.owncloud.android.R
import java.io.File
import java.io.FileWriter
import java.util.TimeZone
class LogsEmailSender(private val context: Context, private val clock: Clock, private val runner: AsyncRunner) {
private companion object {
const val LOGS_MIME_TYPE = "text/plain"
}
private class Task(
private val context: Context,
private val logs: List<LogEntry>,
private val file: File,
private val tz: TimeZone
) : Function0<Uri?> {
override fun invoke(): Uri? {
file.parentFile.mkdirs()
val fo = FileWriter(file, false)
logs.forEach {
fo.write(it.toString(tz))
fo.write("\n")
}
fo.close()
return FileProvider.getUriForFile(context, context.getString(R.string.file_provider_authority), file)
}
}
private var task: Cancellable? = null
fun send(logs: List<LogEntry>) {
if (task == null) {
val outFile = File(context.cacheDir, "attachments/logs.txt")
task = runner.post(Task(context, logs, outFile, clock.tz), onResult = { task = null; send(it) })
}
}
fun stop() {
if (task != null) {
task?.cancel()
task = null
}
}
private fun send(uri: Uri?) {
task = null
val intent = Intent(Intent.ACTION_SEND_MULTIPLE)
intent.putExtra(Intent.EXTRA_EMAIL, context.getString(R.string.mail_logger))
val subject = context.getString(R.string.log_send_mail_subject).format(context.getString(R.string.app_name))
intent.putExtra(Intent.EXTRA_SUBJECT, subject)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
intent.type = LOGS_MIME_TYPE
intent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, arrayListOf(uri))
try {
context.startActivity(intent)
} catch (ex: ActivityNotFoundException) {
Toast.makeText(context, R.string.log_send_no_mail_app, Toast.LENGTH_SHORT).show()
}
}
}

View File

@ -0,0 +1,67 @@
/*
* Nextcloud Android client application
*
* @author Chris Narkiewicz
* Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* 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 <http://www.gnu.org/licenses/>.
*/
package com.nextcloud.client.logger.ui
import android.content.Context
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import com.nextcloud.client.core.AsyncRunner
import com.nextcloud.client.core.Clock
import com.nextcloud.client.logger.LogEntry
import com.nextcloud.client.logger.LogsRepository
import javax.inject.Inject
class LogsViewModel @Inject constructor(
context: Context,
clock: Clock,
asyncRunner: AsyncRunner,
private val logsRepository: LogsRepository
) : ViewModel() {
private val sender = LogsEmailSender(context, clock, asyncRunner)
val entries: LiveData<List<LogEntry>> = MutableLiveData()
private val listener = object : LogsRepository.Listener {
override fun onLoaded(entries: List<LogEntry>) {
this@LogsViewModel.entries as MutableLiveData
this@LogsViewModel.entries.value = entries
}
}
fun send() {
entries.value?.let {
sender.send(it)
}
}
fun load() {
logsRepository.load(listener)
}
fun deleteAll() {
logsRepository.deleteAll()
(entries as MutableLiveData).value = emptyList()
}
override fun onCleared() {
super.onCleared()
sender.stop()
}
}

View File

@ -48,6 +48,8 @@ import com.nextcloud.client.device.PowerManagementService;
import com.nextcloud.client.di.ActivityInjector;
import com.nextcloud.client.di.DaggerAppComponent;
import com.nextcloud.client.errorhandling.ExceptionHandler;
import com.nextcloud.client.logger.LegacyLoggerAdapter;
import com.nextcloud.client.logger.Logger;
import com.nextcloud.client.network.ConnectivityService;
import com.nextcloud.client.onboarding.OnboardingService;
import com.nextcloud.client.preferences.AppPreferences;
@ -137,6 +139,9 @@ public class MainApp extends MultiDexApplication implements HasAndroidInjector {
@Inject PowerManagementService powerManagementService;
@Inject
Logger logger;
private PassCodeManager passCodeManager;
@SuppressWarnings("unused")
@ -248,6 +253,7 @@ public class MainApp extends MultiDexApplication implements HasAndroidInjector {
if (BuildConfig.DEBUG || getApplicationContext().getResources().getBoolean(R.bool.logger_enabled)) {
// use app writable dir, no permissions needed
Log_OC.setLoggerImplementation(new LegacyLoggerAdapter(logger));
Log_OC.startLogging(getAppContext());
Log_OC.d("Debug", "start logging");
}

View File

@ -1,289 +0,0 @@
/*
* ownCloud Android client application
*
* Copyright (C) 2015 ownCloud Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* 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 General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
package com.owncloud.android.ui.activity;
import android.content.ActivityNotFoundException;
import android.content.Intent;
import android.graphics.PorterDuff;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Build;
import android.os.Bundle;
import android.view.MenuItem;
import android.widget.Button;
import android.widget.TextView;
import com.google.android.material.snackbar.Snackbar;
import com.owncloud.android.R;
import com.owncloud.android.lib.common.utils.Log_OC;
import com.owncloud.android.ui.dialog.LoadingDialog;
import com.owncloud.android.utils.ThemeUtils;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.lang.ref.WeakReference;
import java.nio.charset.Charset;
import java.util.ArrayList;
import androidx.core.content.FileProvider;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager;
import androidx.fragment.app.FragmentTransaction;
import butterknife.BindView;
import butterknife.ButterKnife;
import butterknife.OnClick;
import butterknife.Unbinder;
public class LogHistoryActivity extends ToolbarActivity {
private static final String MAIL_ATTACHMENT_TYPE = "text/plain";
private static final String KEY_LOG_TEXT = "LOG_TEXT";
private static final String TAG = LogHistoryActivity.class.getSimpleName();
private static final String DIALOG_WAIT_TAG = "DIALOG_WAIT";
private Unbinder unbinder;
private String logPath = Log_OC.getLogPath();
private File logDir;
private String logText;
@BindView(R.id.deleteLogHistoryButton)
Button deleteHistoryButton;
@BindView(R.id.sendLogHistoryButton)
Button sendHistoryButton;
@BindView(R.id.logTV)
TextView logTV;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.log_send_file);
unbinder = ButterKnife.bind(this);
setupToolbar();
setTitle(getText(R.string.actionbar_logger));
if (getSupportActionBar() != null) {
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
}
sendHistoryButton.getBackground().setColorFilter(ThemeUtils.primaryColor(this), PorterDuff.Mode.SRC_ATOP);
deleteHistoryButton.setTextColor(ThemeUtils.primaryColor(this, true));
if (savedInstanceState == null) {
if (logPath != null) {
logDir = new File(logPath);
}
if (logDir != null && logDir.isDirectory()) {
// Show a dialog while log data is being loaded
showLoadingDialog();
// Start a new thread that will load all the log data
LoadingLogTask task = new LoadingLogTask(logTV);
task.execute();
}
} else {
logText = savedInstanceState.getString(KEY_LOG_TEXT);
logTV.setText(logText);
}
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
boolean retval = true;
switch (item.getItemId()) {
case android.R.id.home:
finish();
break;
default:
retval = super.onOptionsItemSelected(item);
break;
}
return retval;
}
@OnClick(R.id.deleteLogHistoryButton)
void deleteHistoryLogging() {
Log_OC.deleteHistoryLogging();
finish();
}
/**
* Start activity for sending email with logs attached
*/
@OnClick(R.id.sendLogHistoryButton)
void sendMail() {
String emailAddress = getString(R.string.mail_logger);
ArrayList<Uri> uris = new ArrayList<>();
// Convert from paths to Android friendly Parcelable Uri's
for (String file : Log_OC.getLogFileNames()) {
File logFile = new File(logPath, file);
if (logFile.exists()) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
uris.add(Uri.fromFile(logFile));
} else {
uris.add(FileProvider.getUriForFile(this, getString(R.string.file_provider_authority), logFile));
}
}
}
Intent intent = new Intent(Intent.ACTION_SEND_MULTIPLE);
intent.putExtra(Intent.EXTRA_EMAIL, emailAddress);
String subject = String.format(getString(R.string.log_send_mail_subject), getString(R.string.app_name));
intent.putExtra(Intent.EXTRA_SUBJECT, subject);
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
intent.setType(MAIL_ATTACHMENT_TYPE);
intent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris);
try {
startActivity(intent);
} catch (ActivityNotFoundException e) {
Snackbar.make(findViewById(android.R.id.content), R.string.log_send_no_mail_app, Snackbar.LENGTH_LONG).show();
Log_OC.i(TAG, "Could not find app for sending log history.");
}
}
/**
* Class for loading the log data async
*/
private class LoadingLogTask extends AsyncTask<String, Void, String> {
private final WeakReference<TextView> textViewReference;
LoadingLogTask(TextView logTV) {
// Use of a WeakReference to ensure the TextView can be garbage collected
textViewReference = new WeakReference<>(logTV);
}
protected String doInBackground(String... args) {
return readLogFile();
}
protected void onPostExecute(String result) {
if (result != null) {
final TextView logTV = textViewReference.get();
if (logTV != null) {
logText = result;
logTV.setText(logText);
dismissLoadingDialog();
}
}
}
/**
* Read and show log file info
*/
private String readLogFile() {
String[] logFileName = Log_OC.getLogFileNames();
//Read text from files
StringBuilder text = new StringBuilder();
BufferedReader br = null;
try {
String line;
for (int i = logFileName.length - 1; i >= 0; i--) {
File file = new File(logPath, logFileName[i]);
if (file.exists()) {
// Check if FileReader is ready
try (InputStreamReader inputStreamReader = new InputStreamReader(new FileInputStream(file),
Charset.forName("UTF-8"))) {
if (inputStreamReader.ready()) {
br = new BufferedReader(inputStreamReader);
while ((line = br.readLine()) != null) {
// Append the log info
text.append(line);
text.append('\n');
}
}
}
}
}
} catch (IOException e) {
Log_OC.d(TAG, e.getMessage());
} finally {
if (br != null) {
try {
br.close();
} catch (IOException e) {
// ignore
Log_OC.d(TAG, "Error closing log reader", e);
}
}
}
return text.toString();
}
}
/**
* Show loading dialog
*/
public void showLoadingDialog() {
// Construct dialog
LoadingDialog loading = LoadingDialog.newInstance(getResources().getString(R.string.log_progress_dialog_text));
FragmentManager fm = getSupportFragmentManager();
FragmentTransaction ft = fm.beginTransaction();
loading.show(ft, DIALOG_WAIT_TAG);
}
/**
* Dismiss loading dialog
*/
public void dismissLoadingDialog() {
Fragment frag = getSupportFragmentManager().findFragmentByTag(DIALOG_WAIT_TAG);
if (frag != null) {
LoadingDialog loading = (LoadingDialog) frag;
loading.dismissAllowingStateLoss();
}
}
@Override
protected void onSaveInstanceState(Bundle outState) {
super.onSaveInstanceState(outState);
if (isChangingConfigurations()) {
// global state
outState.putString(KEY_LOG_TEXT, logText);
}
}
@Override
protected void onDestroy() {
super.onDestroy();
unbinder.unbind();
}
}

View File

@ -0,0 +1,120 @@
/*
* ownCloud Android client application
*
* Copyright (C) 2015 ownCloud Inc.
* Copyright (C) Chris Narkiewicz <hello@ezaquarii.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 2,
* 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 General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.owncloud.android.ui.activity;
import android.graphics.PorterDuff;
import android.os.Bundle;
import android.view.Menu;
import android.view.MenuItem;
import android.widget.Button;
import com.nextcloud.client.di.ViewModelFactory;
import com.nextcloud.client.logger.ui.LogsAdapter;
import com.nextcloud.client.logger.ui.LogsViewModel;
import com.owncloud.android.R;
import com.owncloud.android.utils.ThemeUtils;
import javax.inject.Inject;
import androidx.lifecycle.ViewModelProvider;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import butterknife.BindView;
import butterknife.ButterKnife;
import butterknife.OnClick;
import butterknife.Unbinder;
public class LogsActivity extends ToolbarActivity {
private Unbinder unbinder;
@BindView(R.id.deleteLogHistoryButton)
Button deleteHistoryButton;
@BindView(R.id.sendLogHistoryButton)
Button sendHistoryButton;
@BindView(R.id.logsList)
RecyclerView logListView;
@Inject ViewModelFactory viewModelFactory;
private LogsViewModel vm;
@Override
public boolean onPrepareOptionsMenu(Menu menu) {
return super.onPrepareOptionsMenu(menu);
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.logs_activity);
unbinder = ButterKnife.bind(this);
final LogsAdapter logsAdapter = new LogsAdapter(this);
logListView.setLayoutManager(new LinearLayoutManager(this));
logListView.setAdapter(logsAdapter);
vm = new ViewModelProvider(this, viewModelFactory).get(LogsViewModel.class);
vm.getEntries().observe(this, logsAdapter::setEntries);
vm.load();
setupToolbar();
setTitle(getText(R.string.actionbar_logger));
if (getSupportActionBar() != null) {
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
}
sendHistoryButton.getBackground().setColorFilter(ThemeUtils.primaryColor(this), PorterDuff.Mode.SRC_ATOP);
deleteHistoryButton.setTextColor(ThemeUtils.primaryColor(this, true));
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
boolean retval = true;
switch (item.getItemId()) {
case android.R.id.home:
finish();
break;
default:
retval = super.onOptionsItemSelected(item);
break;
}
return retval;
}
@OnClick(R.id.deleteLogHistoryButton)
void deleteLogs() {
vm.deleteAll();
finish();
}
@OnClick(R.id.sendLogHistoryButton)
void sendLogs() {
vm.send();
}
@Override
protected void onDestroy() {
super.onDestroy();
unbinder.unbind();
}
}

View File

@ -360,7 +360,7 @@ public class SettingsActivity extends PreferenceActivity
if (pLogger != null) {
if (loggerEnabled) {
pLogger.setOnPreferenceClickListener(preference -> {
Intent loggerIntent = new Intent(getApplicationContext(), LogHistoryActivity.class);
Intent loggerIntent = new Intent(getApplicationContext(), LogsActivity.class);
startActivity(loggerIntent);
return true;

View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="@dimen/standard_quarter_margin">
<TextView
android:id="@+id/log_entry_list_item_header"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textStyle="bold"
android:ellipsize="end"
android:lines="1"/>
<TextView
android:id="@+id/log_entry_list_item_message"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
</LinearLayout>

View File

@ -26,28 +26,13 @@
<include
layout="@layout/toolbar_standard" />
<ScrollView
android:id="@+id/scrollView1"
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/logsList"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginBottom="@dimen/standard_margin"
android:layout_weight="1">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingLeft="@dimen/standard_padding"
android:paddingRight="@dimen/standard_padding">
<TextView
android:id="@+id/logTV"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:text="@string/empty"
android:typeface="monospace"/>
</LinearLayout>
</ScrollView>
</androidx.recyclerview.widget.RecyclerView>
<LinearLayout
android:id="@+id/historyButtonBar"

View File

@ -4,6 +4,9 @@
<files-path
path="log/"
name="log"/>
<cache-path
name="attachments"
path="attachments"/>
<external-path name="external_files" path="."/>
<root-path name="external_files" path="/storage/" />
<!-- yes, valid for ALL external storage and not only our app folder, since we can't use @string/data_folder

View File

@ -0,0 +1,114 @@
package com.nextcloud.client.core
import android.os.Handler
import com.nhaarman.mockitokotlin2.any
import com.nhaarman.mockitokotlin2.argThat
import com.nhaarman.mockitokotlin2.doAnswer
import com.nhaarman.mockitokotlin2.eq
import com.nhaarman.mockitokotlin2.mock
import com.nhaarman.mockitokotlin2.never
import com.nhaarman.mockitokotlin2.spy
import com.nhaarman.mockitokotlin2.verify
import com.nhaarman.mockitokotlin2.whenever
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class AsyncRunnerTest {
private lateinit var handler: Handler
private lateinit var r: AsyncRunnerImpl
@Before
fun setUp() {
handler = spy(Handler())
r = AsyncRunnerImpl(handler, 1)
}
fun assertAwait(latch: CountDownLatch, seconds: Long = 3) {
val called = latch.await(seconds, TimeUnit.SECONDS)
assertTrue(called)
}
@Test
fun `posted task is run on background thread`() {
val latch = CountDownLatch(1)
val callerThread = Thread.currentThread()
var taskThread: Thread? = null
r.post({
taskThread = Thread.currentThread()
latch.countDown()
})
assertAwait(latch)
assertNotEquals(callerThread.id, taskThread?.id)
}
@Test
fun `returns result via handler`() {
val afterPostLatch = CountDownLatch(1)
doAnswer {
(it.arguments[0] as Runnable).run()
afterPostLatch.countDown()
}.whenever(handler).post(any())
val onResult: OnResultCallback<String> = mock()
r.post({
"result"
}, onResult = onResult)
assertAwait(afterPostLatch)
verify(onResult).invoke(eq("result"))
}
@Test
fun `returns error via handler`() {
val afterPostLatch = CountDownLatch(1)
doAnswer {
(it.arguments[0] as Runnable).run()
afterPostLatch.countDown()
}.whenever(handler).post(any())
val onResult: OnResultCallback<String> = mock()
val onError: OnErrorCallback = mock()
r.post({
throw IllegalArgumentException("whatever")
}, onResult = onResult, onError = onError)
assertAwait(afterPostLatch)
verify(onResult, never()).invoke(any())
verify(onError).invoke(argThat { this is java.lang.IllegalArgumentException })
}
@Test
fun `cancelled task does not return result`() {
val taskIsCancelled = CountDownLatch(1)
val taskIsRunning = CountDownLatch(1)
val t = r.post({
taskIsRunning.countDown()
taskIsCancelled.await()
"result"
}, onResult = {}, onError = {})
assertAwait(taskIsRunning)
t.cancel()
taskIsCancelled.countDown()
Thread.sleep(500) // yuck!
verify(handler, never()).post(any())
}
@Test
fun `cancelled task does not return error`() {
val taskIsCancelled = CountDownLatch(1)
val taskIsRunning = CountDownLatch(1)
val t = r.post({
taskIsRunning.countDown()
taskIsCancelled.await()
throw RuntimeException("whatever")
}, onResult = {}, onError = {})
assertAwait(taskIsRunning)
t.cancel()
taskIsCancelled.countDown()
Thread.sleep(500) // yuck!
verify(handler, never()).post(any())
}
}

View File

@ -0,0 +1,142 @@
/*
* Nextcloud Android client application
*
* @author Chris Narkiewicz
* Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* 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 <http://www.gnu.org/licenses/>.
*/
package com.nextcloud.client.logger
import java.util.Date
import java.util.SimpleTimeZone
import java.util.concurrent.TimeUnit
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.Suite
@RunWith(Suite::class)
@Suite.SuiteClasses(
LogEntryTest.ToString::class,
LogEntryTest.Parse::class
)
class LogEntryTest {
class ToString {
@Test
fun `to string`() {
val entry = LogEntry(
timestamp = Date(0),
level = Level.DEBUG,
tag = "tag",
message = "some message"
)
assertEquals("1970-01-01T00:00:00.000Z;D;tag;some message", entry.toString())
}
@Test
fun `to string with custom time zone`() {
val entry = LogEntry(
timestamp = Date(0),
level = Level.DEBUG,
tag = "tag",
message = "some message"
)
val sevenHours = TimeUnit.HOURS.toMillis(7).toInt()
val tz = SimpleTimeZone(sevenHours, "+0700")
assertEquals("1970-01-01T07:00:00.000+0700;D;tag;some message", entry.toString(tz))
}
@Test
fun `semicolons are removed from entry tags`() {
val entry = LogEntry(
timestamp = Date(0),
level = Level.DEBUG,
tag = "t;a;g",
message = "some message"
)
assertEquals("1970-01-01T00:00:00.000Z;D;t a g;some message", entry.toString())
}
@Test
fun `message newline is converted`() {
val entry = LogEntry(
timestamp = Date(0),
level = Level.DEBUG,
tag = "tag",
message = "multine\nmessage\n"
)
assertTrue(entry.toString().endsWith(";multine\\nmessage\\n"))
}
@Test
fun `tag can contain unicode characters`() {
val entry = LogEntry(
timestamp = Date(0),
level = Level.DEBUG,
tag = """靖康緗素雜記""",
message = "夏炉冬扇"
)
assertEquals("1970-01-01T00:00:00.000Z;D;靖康緗素雜記;夏炉冬扇", entry.toString())
}
}
class Parse {
@Test
fun `regexp parser`() {
val entry = "1970-01-01T00:00:00.000Z;D;tag;some message"
val parsed = LogEntry.parse(entry)
assertNotNull(parsed)
parsed as LogEntry
assertEquals(Date(0), parsed.timestamp)
assertEquals(Level.DEBUG, parsed.level)
assertEquals("tag", parsed.tag)
assertEquals("some message", parsed.message)
}
@Test
fun `malformed log entries are rejected`() {
assertNull("no miliseconds", LogEntry.parse("1970-01-01T00:00:00Z;D;tag;a message"))
assertNull("not zulu", LogEntry.parse("1970-01-01T00:00:00.000+00:00;D;tag;a message"))
assertNull("not utc", LogEntry.parse("1970-01-01T01:00:00.000+01:00;D;tag;a message"))
assertNull("bad month", LogEntry.parse("1970-13-01T00:00:00.000Z;D;tag;a message"))
assertNull("bad year", LogEntry.parse("0000-01-01T00:00:00.000Z;D;tag;a message"))
assertNull("bad day", LogEntry.parse("1970-01-32T00:00:00.000Z;D;tag;a message"))
assertNull("bad hour", LogEntry.parse("1970-01-01T25:00:00.000Z;D;tag;a message"))
assertNull("bad minute", LogEntry.parse("1970-01-01T00:61:00.000Z;D;tag;a message"))
assertNull("bad second", LogEntry.parse("1970-01-01T00:00:61.000Z;D;tag;a message"))
assertNull("bad level", LogEntry.parse("1970-01-01T00:00:00.000Z;?;tag;a message"))
assertNull("empty tag", LogEntry.parse("1970-01-01T00:00:00.000Z;D;;a message"))
assertNull("empty string", LogEntry.parse(""))
}
@Test
fun `semicolon in tag tears the tag`() {
val parsed = LogEntry.parse("1970-01-01T00:00:00.000Z;D;t;ag;a message")
assertNotNull(parsed)
assertEquals("Tag is cut; no parse error expected", "t", parsed?.tag)
assertEquals("Tag is cut; no parse error expected", "ag;a message", parsed?.message)
}
@Test
fun `message can have semicolons`() {
val parsed = LogEntry.parse("1970-01-01T00:00:00.000Z;D;tag;a;message;with;semi;colons")
assertEquals("a;message;with;semi;colons", parsed?.message)
}
}
}

View File

@ -0,0 +1,226 @@
/*
* Nextcloud Android client application
*
* @author Chris Narkiewicz
* Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* 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 <http://www.gnu.org/licenses/>.
*/
package com.nextcloud.client.logger
import java.io.File
import java.nio.charset.Charset
import java.nio.file.Files
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class TestFileLogHandler {
private lateinit var logDir: File
private fun readLogFile(name: String): String {
val logFile = File(logDir, name)
val raw = Files.readAllBytes(logFile.toPath())
return String(raw, Charset.forName("UTF-8"))
}
private fun writeLogFile(name: String, content: String) {
val logFile = File(logDir, name)
Files.write(logFile.toPath(), content.toByteArray(Charsets.UTF_8))
}
@Before
fun setUp() {
logDir = Files.createTempDirectory("logger-test-").toFile()
}
@Test
fun `logs dir is created on open`() {
// GIVEN
// logs directory does not exist
val nonexistingLogsDir = File(logDir, "subdir")
assertFalse(nonexistingLogsDir.exists())
// WHEN
// file is opened
val handler = FileLogHandler(nonexistingLogsDir, "log.txt", 1000)
handler.open()
// THEN
// directory is created
assertTrue(nonexistingLogsDir.exists())
}
@Test
fun `log test helpers`() {
val filename = "test.txt"
val expected = "Hello, world!"
writeLogFile(filename, expected)
val readBack = readLogFile(filename)
assertEquals(expected, readBack)
}
@Test
fun `rotate files`() {
// GIVEN
// log contains files
writeLogFile("log.txt", "0")
writeLogFile("log.txt.0", "1")
writeLogFile("log.txt.1", "2")
writeLogFile("log.txt.2", "3")
val writer = FileLogHandler(logDir, "log.txt", 1024)
// WHEN
// files are rotated
writer.rotateLogs()
// THEN
// last file is removed
// all remaining files are advanced by 1 step
assertFalse(File(logDir, "log.txt").exists())
assertEquals("0", readLogFile("log.txt.0"))
assertEquals("1", readLogFile("log.txt.1"))
assertEquals("2", readLogFile("log.txt.2"))
}
@Test
fun `log file is rotated when crossed max size`() {
// GIVEN
// log file contains 10 bytes
// log file limit is 20 bytes
// log writer is opened
writeLogFile("log.txt", "0123456789")
val writer = FileLogHandler(logDir, "log.txt", 20)
writer.open()
// WHEN
// writing 2nd log entry of 11 bytes
writer.write("0123456789!") // 11 bytes
// THEN
// log file is closed and rotated
val rotatedContent = readLogFile("log.txt.0")
assertEquals("01234567890123456789!", rotatedContent)
}
@Test
fun `log file is reopened after rotation`() {
// GIVEN
// log file contains 10 bytes
// log file limit is 20 bytes
// log writer is opened
writeLogFile("log.txt", "0123456789")
val writer = FileLogHandler(logDir, "log.txt", 20)
writer.open()
// WHEN
// writing 2nd log entry of 11 bytes
// writing another log entry
// closing log
writer.write("0123456789!") // 11 bytes
writer.write("Hello!")
writer.close()
// THEN
// current log contains last entry
val lastEntry = readLogFile("log.txt")
assertEquals("Hello!", lastEntry)
}
@Test
fun `load log lines from files`() {
// GIVEN
// multiple log files exist
// log files have lines
writeLogFile("log.txt.2", "line1\nline2\nline3")
writeLogFile("log.txt.1", "line4\nline5\nline6")
writeLogFile("log.txt.0", "line7\nline8\nline9")
writeLogFile("log.txt", "line10\nline11\nline12")
// WHEN
// log file is read including rotated content
val writer = FileLogHandler(logDir, "log.txt", 1000)
val lines = writer.loadLogFiles(3)
// THEN
// all files are loaded
// lines are loaded in correct order
assertEquals(12, lines.size)
assertEquals(
listOf(
"line1", "line2", "line3",
"line4", "line5", "line6",
"line7", "line8", "line9",
"line10", "line11", "line12"
),
lines
)
}
@Test
fun `load log lines from files with gaps between rotated files`() {
// GIVEN
// multiple log files exist
// log files have lines
// some rotated files are deleted
writeLogFile("log.txt", "line1\nline2\nline3")
writeLogFile("log.txt.2", "line4\nline5\nline6")
// WHEN
// log file is read including rotated content
val writer = FileLogHandler(logDir, "log.txt", 1000)
val lines = writer.loadLogFiles(3)
// THEN
// all files are loaded
assertEquals(6, lines.size)
}
@Test(expected = IllegalArgumentException::class)
fun `load log lines - negative count is illegal`() {
// WHEN
// requesting negative number of rotated files
val writer = FileLogHandler(logDir, "log.txt", 1000)
val lines = writer.loadLogFiles(-1)
// THEN
// illegal argument exception
}
@Test
fun `all log files are deleted`() {
// GIVEN
// log files exist
val handler = FileLogHandler(logDir, "log.txt", 100)
for (i in 0 until handler.maxLogFilesCount) {
handler.rotateLogs()
handler.open()
handler.write("new log entry")
handler.close()
}
assertEquals(handler.maxLogFilesCount, logDir.listFiles().size)
// WHEN
// files are deleted
handler.deleteAll()
// THEN
// all files are deleted
assertEquals(0, logDir.listFiles().size)
}
}

View File

@ -0,0 +1,285 @@
/*
* Nextcloud Android client application
*
* @author Chris Narkiewicz
* Copyright (C) 2019 Chris Narkiewicz <hello@ezaquarii.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* 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 <http://www.gnu.org/licenses/>.
*/
package com.nextcloud.client.logger
import android.os.Handler
import com.nextcloud.client.core.Clock
import com.nextcloud.client.core.ClockImpl
import com.nhaarman.mockitokotlin2.any
import com.nhaarman.mockitokotlin2.argThat
import com.nhaarman.mockitokotlin2.capture
import com.nhaarman.mockitokotlin2.doAnswer
import com.nhaarman.mockitokotlin2.inOrder
import com.nhaarman.mockitokotlin2.mock
import com.nhaarman.mockitokotlin2.spy
import com.nhaarman.mockitokotlin2.times
import com.nhaarman.mockitokotlin2.verify
import com.nhaarman.mockitokotlin2.whenever
import java.nio.file.Files
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.mockito.ArgumentCaptor
import org.mockito.MockitoAnnotations
class TestLogger {
private companion object {
const val QUEUE_CAPACITY = 100
}
private lateinit var clock: Clock
private lateinit var logHandler: FileLogHandler
private lateinit var osHandler: Handler
private lateinit var logger: LoggerImpl
@Before
fun setUp() {
MockitoAnnotations.initMocks(this)
val tempDir = Files.createTempDirectory("log-test").toFile()
clock = ClockImpl()
logHandler = spy(FileLogHandler(tempDir, "log.txt", 1024))
osHandler = mock()
logger = LoggerImpl(clock, logHandler, osHandler, QUEUE_CAPACITY)
}
@Test
fun `write is done on background thread`() {
val callerThreadId = Thread.currentThread().id
val writerThreadIds = mutableListOf<Long>()
val latch = CountDownLatch(3)
doAnswer {
writerThreadIds.add(Thread.currentThread().id)
it.callRealMethod()
latch.countDown()
}.whenever(logHandler).open()
doAnswer {
writerThreadIds.add(Thread.currentThread().id)
it.callRealMethod()
latch.countDown()
}.whenever(logHandler).write(any())
doAnswer {
writerThreadIds.add(Thread.currentThread().id)
it.callRealMethod()
latch.countDown()
}.whenever(logHandler).close()
// GIVEN
// logger event loop is running
logger.start()
// WHEN
// message is logged
logger.d("tag", "message")
// THEN
// message is processed on bg thread
// all handler invocations happen on bg thread
// all handler invocations happen on single thread
assertTrue(latch.await(3, TimeUnit.SECONDS))
writerThreadIds.forEach { writerThreadId ->
assertNotEquals("All requests must be made on bg thread", callerThreadId, writerThreadId)
}
writerThreadIds.forEach {
assertEquals("All requests must be made on single thread", writerThreadIds[0], it)
}
}
@Test
fun `message is written via log handler`() {
val tag = "test tag"
val message = "test log message"
val latch = CountDownLatch(3)
doAnswer { it.callRealMethod(); latch.countDown() }.whenever(logHandler).open()
doAnswer { it.callRealMethod(); latch.countDown() }.whenever(logHandler).write(any())
doAnswer { it.callRealMethod(); latch.countDown() }.whenever(logHandler).close()
// GIVEN
// logger event loop is running
logger.start()
// WHEN
// log message is written
logger.d(tag, message)
// THEN
// log handler opens log file
// log handler writes entry
// log handler closes log file
// no lost messages
val called = latch.await(3, TimeUnit.SECONDS)
assertTrue("Expected open(), write() and close() calls on bg thread", called)
val inOrder = inOrder(logHandler)
inOrder.verify(logHandler).open()
inOrder.verify(logHandler).write(argThat {
tag in this && message in this
})
inOrder.verify(logHandler).close()
assertFalse(logger.lostEntries)
}
@Test
fun `logs are loaded in background thread and posted to main thread`() {
val currentThreadId = Thread.currentThread().id
var loggerThreadId: Long = -1
val listener: LogsRepository.Listener = mock()
val latch = CountDownLatch(2)
// log handler will be called on bg thread
doAnswer {
loggerThreadId = Thread.currentThread().id
latch.countDown()
it.callRealMethod()
}.whenever(logHandler).loadLogFiles(any())
// os handler will be called on bg thread
whenever(osHandler.post(any())).thenAnswer {
latch.countDown()
true
}
// GIVEN
// logger event loop is running
logger.start()
// WHEN
// messages are logged
// log contents are requested
logger.d("tag", "message 1")
logger.d("tag", "message 2")
logger.d("tag", "message 3")
logger.load(listener)
val called = latch.await(3, TimeUnit.SECONDS)
assertTrue("Response not posted", called)
// THEN
// log contents are loaded on background thread
// logs are posted to main thread handler
// contents contain logged messages
// messages are in order of writes
assertNotEquals(currentThreadId, loggerThreadId)
val postedCaptor = ArgumentCaptor.forClass(Runnable::class.java)
verify(osHandler).post(capture(postedCaptor))
postedCaptor.value.run()
val logsCaptor = ArgumentCaptor.forClass(List::class.java) as ArgumentCaptor<List<LogEntry>>
verify(listener).onLoaded(capture(logsCaptor))
assertEquals(3, logsCaptor.value.size)
assertTrue("message 1" in logsCaptor.value[0].message)
assertTrue("message 2" in logsCaptor.value[1].message)
assertTrue("message 3" in logsCaptor.value[2].message)
}
@Test
fun `log level can be decoded from tags`() {
Level.values().forEach {
val decodedLevel = Level.fromTag(it.tag)
assertEquals(it, decodedLevel)
}
}
@Test
fun `queue limit is enforced`() {
// GIVEN
// logger event loop is no running
// WHEN
// queue is filled up to it's capacity
for (i in 0 until QUEUE_CAPACITY + 1) {
logger.d("tag", "Message $i")
}
// THEN
// overflow flag is raised
assertTrue(logger.lostEntries)
}
@Test
fun `queue overflow warning is logged`() {
// GIVEN
// logger loop is overflown
for (i in 0..QUEUE_CAPACITY + 1) {
logger.d("tag", "Message $i")
}
// WHEN
// logger event loop processes events
//
logger.start()
// THEN
// overflow occurence is logged
val posted = CountDownLatch(1)
whenever(osHandler.post(any())).thenAnswer {
(it.arguments[0] as Runnable).run()
posted.countDown()
true
}
val listener: LogsRepository.Listener = mock()
logger.load(listener)
assertTrue("Logs not loaded", posted.await(1, TimeUnit.SECONDS))
verify(listener).onLoaded(argThat {
"Logger queue overflow" in last().message
})
}
@Test
fun `all log files are deleted`() {
val latch = CountDownLatch(1)
doAnswer {
it.callRealMethod()
latch.countDown()
}.whenever(logHandler).deleteAll()
// GIVEN
// logger is started
logger.start()
// WHEN
// logger has some writes
// logs are deleted
logger.d("tag", "message")
logger.d("tag", "message")
logger.d("tag", "message")
logger.deleteAll()
// THEN
// handler writes files
// handler deletes all files
assertTrue(latch.await(3, TimeUnit.SECONDS))
verify(logHandler, times(3)).write(any())
verify(logHandler).deleteAll()
assertEquals(0, logHandler.loadLogFiles(logHandler.maxLogFilesCount).size)
}
}