Log search functionality and log browser refactoring

* added log search in logs browser
* added logs browser view model test
* added universal async filtering utility
* refactored async runner and added manual runner
* migrated logs browser to Android DataBinding and Kotlin
* disabled imports ordering Ktlint rule as IDE does not
  support ktlint ordering
* added some missing tests around logger

Closes #4311

Signed-off-by: Chris Narkiewicz <hello@ezaquarii.com>
This commit is contained in:
Chris Narkiewicz 2019-08-11 13:11:21 +01:00
parent 3c331aa6c0
commit c35873f5dc
No known key found for this signature in database
GPG Key ID: 30D28CA4CCC665C6
33 changed files with 1333 additions and 307 deletions

View File

@ -34,3 +34,7 @@ trim_trailing_whitespace=false
[.drone.yml]
indent_size=2
[*.{kt,kts}]
# IDE does not follow this Ktlint rule strictly, but the default ordering is pretty good anyway, so let's ditch it
disabled_rules=import-ordering

View File

@ -244,6 +244,10 @@ android {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
dataBinding {
enabled true
}
}
dependencies {

View File

@ -26,6 +26,14 @@
<Bug pattern="IICU_INCORRECT_INTERNAL_CLASS_USE" />
</Match>
<!-- Data bindings autogenerated classes -->
<Match>
<Or>
<Class name="~.*BindingImpl"/>
<Class name="~.*\.DataBinderMapperImpl"/>
</Or>
</Match>
<Bug pattern="PATH_TRAVERSAL_IN" />
<Bug pattern="ANDROID_EXTERNAL_FILE_ACCESS" />
<Bug pattern="BAS_BLOATED_ASSIGNMENT_SCOPE" />

View File

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

View File

@ -19,7 +19,6 @@
*/
package com.nextcloud.client.core
typealias TaskBody<T> = () -> T
typealias OnResultCallback<T> = (T) -> Unit
typealias OnErrorCallback = (Throwable) -> Unit
@ -29,5 +28,5 @@ typealias OnErrorCallback = (Throwable) -> Unit
* 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
fun <T> post(task: () -> T, onResult: OnResultCallback<T>? = null, onError: OnErrorCallback? = null): Cancellable
}

View File

@ -1,65 +0,0 @@
/*
* 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,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.core
import java.util.ArrayDeque
/**
* This async runner is suitable for tests, where manual simulation of
* asynchronous operations is desirable.
*/
class ManualAsyncRunner : AsyncRunner {
private val queue: ArrayDeque<Task<*>> = ArrayDeque()
override fun <T> post(task: () -> T, onResult: OnResultCallback<T>?, onError: OnErrorCallback?): Cancellable {
val taskWrapper = Task(
postResult = { it.run() },
taskBody = task,
onSuccess = onResult,
onError = onError
)
queue.push(taskWrapper)
return taskWrapper
}
val size: Int get() = queue.size
val isEmpty: Boolean get() = queue.size == 0
/**
* Run all enqueued tasks until queue is empty. This will run also tasks
* enqueued by task callbacks.
*
* @param maximum max number of tasks to run to avoid infinite loopss
* @return number of executed tasks
*/
fun runAll(maximum: Int = 100): Int {
var c = 0
while (queue.size > 0) {
val t = queue.remove()
t.run()
c++
if (c > maximum) {
throw IllegalStateException("Maximum number of tasks run. Are you in infinite loop?")
}
}
return c
}
/**
* Run one pending task
*
* @return true if task has been run
*/
fun runOne(): Boolean {
val t = queue.pollFirst()
t?.run()
return t != null
}
}

View File

@ -0,0 +1,57 @@
/*
* 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.concurrent.atomic.AtomicBoolean
/**
* This is a wrapper for a function run in background.
*
* Runs task function and posts result if task is not cancelled.
*/
internal class Task<T>(
private val postResult: (Runnable) -> Unit,
private val taskBody: () -> 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 = taskBody.invoke()
if (!cancelled.get()) {
postResult.invoke(Runnable {
onSuccess?.invoke(result)
})
}
} catch (t: Throwable) {
if (!cancelled.get()) {
postResult(Runnable { onError?.invoke(t) })
}
}
}
override fun cancel() {
cancelled.set(true)
}
}

View File

@ -0,0 +1,44 @@
/*
* 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
/**
* This async runner uses [java.util.concurrent.ScheduledThreadPoolExecutor] to run tasks
* asynchronously.
*
* Tasks are run on multi-threaded pool. If serialized execution is desired, set [corePoolSize] to 1.
*/
internal class ThreadPoolAsyncRunner(private val uiThreadHandler: Handler, corePoolSize: Int) : AsyncRunner {
private val executor = ScheduledThreadPoolExecutor(corePoolSize)
override fun <T> post(task: () -> T, onResult: OnResultCallback<T>?, onError: OnErrorCallback?): Cancellable {
val taskWrapper = Task(this::postResult, task, onResult, onError)
executor.execute(taskWrapper)
return taskWrapper
}
private fun postResult(r: Runnable) {
uiThreadHandler.post(r)
}
}

View File

@ -31,7 +31,7 @@ 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.ThreadPoolAsyncRunner;
import com.nextcloud.client.core.Clock;
import com.nextcloud.client.core.ClockImpl;
import com.nextcloud.client.device.DeviceInfo;
@ -144,6 +144,6 @@ class AppModule {
@Singleton
AsyncRunner asyncRunner() {
Handler uiHandler = new Handler();
return new AsyncRunnerImpl(uiHandler, 4);
return new ThreadPoolAsyncRunner(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.LogsActivity;
import com.nextcloud.client.logger.ui.LogsActivity;
import com.owncloud.android.ui.activity.ManageAccountsActivity;
import com.owncloud.android.ui.activity.ManageSpaceActivity;
import com.owncloud.android.ui.activity.NotificationsActivity;

View File

@ -34,6 +34,8 @@ import java.nio.charset.Charset
*/
internal class FileLogHandler(private val logDir: File, private val logFilename: String, private val maxSize: Long) {
data class RawLogs(val lines: List<String>, val logSize: Long)
companion object {
const val ROTATED_LOGS_COUNT = 3
}
@ -111,21 +113,23 @@ internal class FileLogHandler(private val logDir: File, private val logFilename:
}
}
fun loadLogFiles(rotated: Int = ROTATED_LOGS_COUNT): List<String> {
fun loadLogFiles(rotated: Int = ROTATED_LOGS_COUNT): RawLogs {
if (rotated < 0) {
throw IllegalArgumentException("Negative index")
}
val allLines = mutableListOf<String>()
var size = 0L
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)
val lines = file.readLines(Charsets.UTF_8)
allLines.addAll(lines)
size += file.length()
} catch (ex: IOException) {
// ignore failing file
}
}
return allLines
return RawLogs(lines = allLines, logSize = size)
}
}

View File

@ -37,7 +37,7 @@ internal class LoggerImpl(
queueCapacity: Int
) : Logger, LogsRepository {
data class Load(val listener: LogsRepository.Listener)
data class Load(val onResult: (List<LogEntry>, Long) -> Unit)
class Delete
private val looper = ThreadLoop()
@ -92,8 +92,8 @@ internal class LoggerImpl(
enqueue(Level.ERROR, tag, message)
}
override fun load(listener: LogsRepository.Listener) {
eventQueue.put(Load(listener = listener))
override fun load(onLoaded: (entries: List<LogEntry>, totalLogSize: Long) -> Unit) {
eventQueue.put(Load(onLoaded))
}
override fun deleteAll() {
@ -134,8 +134,11 @@ internal class LoggerImpl(
for (event in otherEvents) {
when (event) {
is Load -> {
val entries = handler.loadLogFiles().mapNotNull { LogEntry.parse(it) }
mainThreadHandler.post { event.listener.onLoaded(entries) }
val loaded = handler.loadLogFiles()
val entries = loaded.lines.mapNotNull { LogEntry.parse(it) }
mainThreadHandler.post {
event.onResult(entries, loaded.logSize)
}
}
is Delete -> handler.deleteAll()
}

View File

@ -19,17 +19,14 @@
*/
package com.nextcloud.client.logger
typealias OnLogsLoaded = (entries: List<LogEntry>, totalLogSize: Long) -> Unit
/**
* 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.
@ -41,8 +38,10 @@ interface LogsRepository {
/**
* Asynchronously load available logs. Load can be scheduled on any thread,
* but the listener will be called on main thread.
*
* @param onLoaded: Callback with loaded logs; called on main thread
*/
fun load(listener: Listener)
fun load(onLoaded: OnLogsLoaded)
/**
* Asynchronously delete logs.

View File

@ -0,0 +1,83 @@
/*
* 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 com.nextcloud.client.core.AsyncRunner
import com.nextcloud.client.core.Cancellable
/**
* This utility class allows implementation of as-you-type filtering of large collections.
*
* It asynchronously filters collection in background and provide result via callback on the main thread.
* If new filter request is posted before current filtering task completes, request
* is stored as pending and is handled after currently running task completes.
*
* If a request is already running, another request is already pending and new request is posted
* (ex. if somebody types faster than live search can finish), the pending request is overwritten
* by a new one.
*/
class AsyncFilter(private val asyncRunner: AsyncRunner, private val time: () -> Long = System::currentTimeMillis) {
private var filterTask: Cancellable? = null
private var pendingRequest: (() -> Unit)? = null
private val isRunning get() = filterTask != null
private var startTime = 0L
/**
* Schedule filtering request.
*
* @param collection items to appy fitler to; items should not be modified when request is being processed
* @param predicate filter predicate
* @param onResult result callback called on the main thread
*/
fun <T> filter(
collection: Iterable<T>,
predicate: (T) -> Boolean,
onResult: (filtered: List<T>, durationMs: Long) -> Unit
) {
pendingRequest = {
filterAsync(collection, predicate, onResult)
}
if (!isRunning) {
pendingRequest?.invoke()
}
}
private fun <T> filterAsync(collection: Iterable<T>, predicate: (T) -> Boolean, onResult: (List<T>, Long) -> Unit) {
startTime = time.invoke()
filterTask = asyncRunner.post(
task = {
collection.filter { predicate.invoke(it) }
},
onResult = { filtered: List<T> ->
onFilterCompleted(filtered, onResult)
}
)
pendingRequest = null
}
private fun <T> onFilterCompleted(filtered: List<T>, callback: (List<T>, Long) -> Unit) {
val dt = time.invoke() - startTime
callback.invoke(filtered, dt)
filterTask = null
startTime = 0L
pendingRequest?.invoke()
}
}

View File

@ -0,0 +1,104 @@
/*
* 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.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.widget.ProgressBar
import androidx.appcompat.widget.SearchView
import androidx.databinding.DataBindingUtil
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProvider
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.nextcloud.client.di.ViewModelFactory
import com.owncloud.android.R
import com.owncloud.android.databinding.LogsActivityBinding
import com.owncloud.android.ui.activity.ToolbarActivity
import com.owncloud.android.utils.ThemeUtils
import javax.inject.Inject
class LogsActivity : ToolbarActivity() {
@Inject
protected lateinit var viewModelFactory: ViewModelFactory
private lateinit var vm: LogsViewModel
private lateinit var binding: LogsActivityBinding
private lateinit var logsAdapter: LogsAdapter
private val searchBoxListener = object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String): Boolean {
return false
}
override fun onQueryTextChange(newText: String): Boolean {
vm.filter(newText)
return false
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
vm = ViewModelProvider(this, viewModelFactory).get(LogsViewModel::class.java)
binding = DataBindingUtil.setContentView<LogsActivityBinding>(this, R.layout.logs_activity).apply {
lifecycleOwner = this@LogsActivity
vm = this@LogsActivity.vm
}
findViewById<ProgressBar>(R.id.logs_loading_progress).apply {
ThemeUtils.themeProgressBar(context, this)
}
logsAdapter = LogsAdapter(this)
findViewById<RecyclerView>(R.id.logsList).apply {
layoutManager = LinearLayoutManager(this@LogsActivity)
adapter = logsAdapter
}
vm.entries.observe(this, Observer { logsAdapter.entries = it })
vm.load()
setupToolbar()
title = getText(R.string.logs_title)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.logs_menu, menu)
(menu.findItem(R.id.action_search).actionView as SearchView).apply {
setOnQueryTextListener(searchBoxListener)
ThemeUtils.themeSearchView(context, this, true)
}
return super.onCreateOptionsMenu(menu)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
var retval = true
when (item.itemId) {
android.R.id.home -> finish()
R.id.action_delete_logs -> vm.deleteAll()
R.id.action_send_logs -> vm.send()
R.id.action_refresh_logs -> vm.load()
else -> retval = super.onOptionsItemSelected(item)
}
return retval
}
}

View File

@ -52,7 +52,8 @@ class LogsAdapter(context: Context) : RecyclerView.Adapter<LogsAdapter.ViewHolde
override fun getItemCount() = entries.size
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val entry = entries[position]
val reversedPosition = entries.size - position - 1
val entry = entries[reversedPosition]
val header = "${timestampFormat.format(entry.timestamp)} ${entry.level.tag} ${entry.tag}"
holder.header.text = header
holder.message.text = entry.message

View File

@ -27,24 +27,32 @@ 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 com.owncloud.android.R
import javax.inject.Inject
class LogsViewModel @Inject constructor(
context: Context,
private val 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
}
private companion object {
const val KILOBYTE = 1024L
}
private val asyncFilter = AsyncFilter(asyncRunner)
private val sender = LogsEmailSender(context, clock, asyncRunner)
private var allEntries = emptyList<LogEntry>()
private var logsSize = -1L
private var filterDurationMs = 0L
private var isFiltered = false
val isLoading: LiveData<Boolean> = MutableLiveData<Boolean>().apply { value = false }
val size: LiveData<Long> = MutableLiveData<Long>().apply { value = 0 }
val entries: LiveData<List<LogEntry>> = MutableLiveData<List<LogEntry>>().apply { value = emptyList() }
val status: LiveData<String> = MutableLiveData<String>().apply { value = "" }
fun send() {
entries.value?.let {
sender.send(it)
@ -52,7 +60,22 @@ class LogsViewModel @Inject constructor(
}
fun load() {
logsRepository.load(listener)
if (isLoading.value != true) {
logsRepository.load(this::onLoaded)
(isLoading as MutableLiveData).value = true
}
}
private fun onLoaded(entries: List<LogEntry>, logsSize: Long) {
this.entries as MutableLiveData
this.isLoading as MutableLiveData
this.status as MutableLiveData
this.entries.value = entries
this.allEntries = entries
this.logsSize = logsSize
isLoading.value = false
this.status.value = formatStatus()
}
fun deleteAll() {
@ -60,8 +83,44 @@ class LogsViewModel @Inject constructor(
(entries as MutableLiveData).value = emptyList()
}
fun filter(pattern: String) {
if (isLoading.value == false) {
isFiltered = pattern.isNotEmpty()
val predicate = when (isFiltered) {
true -> { it: LogEntry -> it.tag.contains(pattern, true) || it.message.contains(pattern, true) }
false -> { _ -> true }
}
asyncFilter.filter(
collection = allEntries,
predicate = predicate,
onResult = this::onFiltered
)
}
}
override fun onCleared() {
super.onCleared()
sender.stop()
}
private fun onFiltered(filtered: List<LogEntry>, filterDurationMs: Long) {
(entries as MutableLiveData).value = filtered
this.filterDurationMs = filterDurationMs
(status as MutableLiveData).value = formatStatus()
}
private fun formatStatus(): String {
val displayedEntries = entries.value?.size ?: allEntries.size
val sizeKb = logsSize / KILOBYTE
return when {
isLoading.value == true -> context.getString(R.string.logs_status_loading)
isFiltered -> context.getString(R.string.logs_status_filtered,
sizeKb,
displayedEntries,
allEntries.size,
filterDurationMs)
!isFiltered -> context.getString(R.string.logs_status_not_filtered, sizeKb)
else -> ""
}
}
}

View File

@ -1,120 +0,0 @@
/*
* 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

@ -56,6 +56,7 @@ import android.webkit.URLUtil;
import com.nextcloud.client.account.UserAccountManager;
import com.nextcloud.client.di.Injectable;
import com.nextcloud.client.etm.EtmActivity;
import com.nextcloud.client.logger.ui.LogsActivity;
import com.nextcloud.client.preferences.AppPreferences;
import com.nextcloud.client.preferences.AppPreferencesImpl;
import com.owncloud.android.BuildConfig;

View File

@ -485,6 +485,11 @@ public final class ThemeUtils {
themeEditText(context, editText, themedBackground);
}
public static void themeProgressBar(Context context, ProgressBar progressBar) {
int color = ThemeUtils.primaryAccentColor(context);
progressBar.getIndeterminateDrawable().setColorFilter(color, PorterDuff.Mode.SRC_IN);
}
public static void tintCheckbox(AppCompatCheckBox checkBox, int color) {
CompoundButtonCompat.setButtonTintList(checkBox, new ColorStateList(
new int[][]{

View File

@ -1,72 +1,64 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
ownCloud Android client application
Nextcloud Android client application
Copyright (C) 2015 ownCloud Inc.
@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 General Public License version 2,
as published by the Free Software Foundation.
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 General Public License for more details.
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 General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
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/>.
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:weightSum="1">
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<include
layout="@layout/toolbar_standard" />
<data>
<import type="android.view.View" />
<variable name="vm" type="com.nextcloud.client.logger.ui.LogsViewModel" />
</data>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/logsList"
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginBottom="@dimen/standard_margin"
android:layout_weight="1">
</androidx.recyclerview.widget.RecyclerView>
android:layout_height="match_parent">
<LinearLayout
android:id="@+id/historyButtonBar"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="@dimen/standard_margin"
android:layout_marginLeft="@dimen/standard_margin"
android:layout_marginRight="@dimen/standard_margin">
<include layout="@layout/toolbar_standard" />
<com.google.android.material.button.MaterialButton
android:id="@+id/deleteLogHistoryButton"
android:theme="@style/OutlinedButton"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:layout_weight="1"
android:text="@string/prefs_log_delete_history_button"
android:layout_marginEnd="@dimen/standard_quarter_margin"
android:layout_marginRight="@dimen/standard_quarter_margin"
app:cornerRadius="@dimen/button_corner_radius" />
<ProgressBar
android:id="@+id/logs_loading_progress"
android:visibility="@{safeUnbox(vm.isLoading) ? View.VISIBLE : View.GONE}"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_behavior="@string/appbar_scrolling_view_behavior"
android:layout_gravity="center"/>
<com.google.android.material.button.MaterialButton
android:id="@+id/sendLogHistoryButton"
android:theme="@style/Button.Primary"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:layout_weight="1"
android:text="@string/log_send_history_button"
android:layout_marginStart="@dimen/standard_quarter_margin"
android:layout_marginLeft="@dimen/standard_quarter_margin"
app:cornerRadius="@dimen/button_corner_radius" />
<LinearLayout
android:visibility="@{safeUnbox(vm.isLoading) ? View.INVISIBLE : View.VISIBLE}"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior"
android:orientation="vertical">
</LinearLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/logsList"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"/>
</LinearLayout>
<TextView
android:id="@+id/logs_status"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="12sp"
android:text="@{vm.status}"/>
</LinearLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
</layout>

View File

@ -0,0 +1,49 @@
<!--
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/>.
-->
<menu xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_search"
android:icon="@android:drawable/ic_menu_search"
android:orderInCategory="0"
android:title="@string/logs_menu_search"
app:showAsAction="ifRoom"
app:actionViewClass="androidx.appcompat.widget.SearchView" />
<item
android:id="@+id/action_refresh_logs"
android:title="@string/logs_menu_refresh"
app:showAsAction="never"
android:orderInCategory="100"
android:icon="@drawable/ic_action_refresh"/>
<item
android:id="@+id/action_send_logs"
android:title="@string/logs_menu_send"
app:showAsAction="never"
android:orderInCategory="200"
android:icon="@drawable/ic_send"/>
<item
android:id="@+id/action_delete_logs"
android:title="@string/logs_menu_delete"
app:showAsAction="never"
android:orderInCategory="300"
android:icon="@drawable/ic_delete"/>
</menu>

View File

@ -47,7 +47,6 @@
<string name="prefs_show_hidden_files">Show hidden files</string>
<string name="prefs_enable_media_scan_notifications">Show media scan notifications</string>
<string name="prefs_enable_media_scan_notifications_summary">Notify about newly found media folders</string>
<string name="prefs_log_delete_history_button">Delete history</string>
<string name="prefs_calendar_contacts">Sync calendar &amp; contacts</string>
<string name="prefs_calendar_contacts_summary">Set up DAVx5 (formerly known as DAVdroid) (v1.3.0+) for current account</string>
<string name="prefs_calendar_contacts_address_resolve_error">Server address for the account could not be resolved for DAVx5 (formerly known as DAVdroid)</string>
@ -407,11 +406,12 @@
<string name="drawer_manage_accounts">Manage accounts</string>
<string name="auth_redirect_non_secure_connection_title">Secure connection redirected through an unsecured route.</string>
<string name="actionbar_logger">Logs</string>
<string name="log_send_history_button">Send history</string>
<string name="logs_title">Logs</string>
<string name="logs_menu_refresh">Refresh</string>
<string name="logs_menu_send">Send logs by e-mail</string>
<string name="logs_menu_delete">Delete logs</string>
<string name="log_send_no_mail_app">No app for sending logs found. Please install an e-mail client.</string>
<string name="log_send_mail_subject">%1$s Android app logs</string>
<string name="log_progress_dialog_text">Loading data…</string>
<string name="saml_authentication_required_text">Password required</string>
<string name="saml_authentication_wrong_pass">Wrong password</string>
@ -888,6 +888,11 @@
<string name="etm_title">Engineering Test Mode</string>
<string name="etm_preferences">Preferences</string>
<string name="logs_status_loading">Loading…</string>
<string name="logs_status_filtered">Logs: %1$d kB, query matched %2$d / %3$d in %4$d ms</string>
<string name="logs_status_not_filtered">Logs: %1$d kB, no filter</string>
<string name="logs_menu_search">Search logs</string>
<string name="error_report_issue_text">Report issue to tracker? (requires a Github account)</string>
<string name="error_report_issue_action">Report</string>
<string name="error_crash_title">%1$s crashed</string>

View File

@ -69,7 +69,7 @@
<Preference android:title="@string/prefs_help" android:key="help" />
<Preference android:title="@string/prefs_recommend" android:key="recommend" />
<Preference android:title="@string/prefs_feedback" android:key="feedback" />
<Preference android:title="@string/actionbar_logger" android:key="logger" />
<Preference android:title="@string/logs_title" android:key="logger" />
<Preference android:title="@string/prefs_imprint" android:key="imprint" />
</PreferenceCategory>
<PreferenceCategory android:title="@string/prefs_category_about" android:key="about">

View File

@ -0,0 +1,148 @@
/*
* 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 com.nhaarman.mockitokotlin2.any
import com.nhaarman.mockitokotlin2.eq
import com.nhaarman.mockitokotlin2.times
import com.nhaarman.mockitokotlin2.verify
import com.nhaarman.mockitokotlin2.whenever
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.mockito.Mock
import org.mockito.MockitoAnnotations
class ManualAsyncRunnerTest {
private lateinit var runner: ManualAsyncRunner
@Mock
private lateinit var task: () -> Int
@Mock
private lateinit var onResult: OnResultCallback<Int>
@Mock
private lateinit var onError: OnErrorCallback
private var taskCalls: Int = 0
@Before
fun setUp() {
MockitoAnnotations.initMocks(this)
runner = ManualAsyncRunner()
taskCalls = 0
whenever(task.invoke()).thenAnswer { taskCalls++; taskCalls }
}
@Test
fun `tasks are queued`() {
assertEquals(0, runner.size)
runner.post(task, onResult, onError)
runner.post(task, onResult, onError)
runner.post(task, onResult, onError)
assertEquals("Expected 3 tasks to be enqueued", 3, runner.size)
}
@Test
fun `run one enqueued task`() {
runner.post(task, onResult, onError)
runner.post(task, onResult, onError)
runner.post(task, onResult, onError)
assertEquals("Queue should contain all enqueued tasks", 3, runner.size)
val run = runner.runOne()
assertTrue("Executed task should be acknowledged", run)
assertEquals("One task should be run", 1, taskCalls)
verify(onResult).invoke(eq(1))
assertEquals("Only 1 task should be consumed", 2, runner.size)
}
@Test
fun `run all enqueued tasks`() {
runner.post(task, onResult, onError)
runner.post(task, onResult, onError)
assertEquals("Queue should contain all enqueued tasks", 2, runner.size)
val count = runner.runAll()
assertEquals("Executed tasks should be acknowledged", 2, count)
verify(task, times(2)).invoke()
verify(onResult, times(2)).invoke(any())
assertEquals("Entire queue should be processed", 0, runner.size)
}
@Test
fun `run one task when queue is empty`() {
assertFalse("No task should be run", runner.runOne())
}
@Test
fun `run all tasks when queue is empty`() {
assertEquals("No task should be run", 0, runner.runAll())
}
@Test
fun `tasks started from callbacks are processed`() {
val task = { "result" }
// WHEN
// one task is scheduled
// task callback schedules another task
runner.post(task, {
runner.post(task, {
runner.post(task)
})
})
assertEquals(1, runner.size)
// WHEN
// runs all
val count = runner.runAll()
// THEN
// all subsequently scheduled tasks are run too
assertEquals(3, count)
}
@Test(expected = IllegalStateException::class, timeout = 10000)
fun `runner detects infinite loops caused by scheduling tasks recusively`() {
val recursiveTask: () -> String = object : Function0<String> {
override fun invoke(): String {
runner.post(this)
return "result"
}
}
// WHEN
// one task is scheduled
// task will schedule itself again, causing infinite loop
runner.post(recursiveTask)
// WHEN
// runs all
runner.runAll()
// THEN
// maximum number of task runs is reached
// exception is thrown
}
}

View File

@ -0,0 +1,86 @@
/*
* 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 com.nhaarman.mockitokotlin2.any
import com.nhaarman.mockitokotlin2.eq
import com.nhaarman.mockitokotlin2.never
import com.nhaarman.mockitokotlin2.same
import com.nhaarman.mockitokotlin2.verify
import com.nhaarman.mockitokotlin2.whenever
import org.junit.Before
import org.junit.Test
import org.mockito.Mock
import org.mockito.MockitoAnnotations
class TaskTest {
@Mock
private lateinit var taskBody: () -> String
@Mock
private lateinit var onResult: OnResultCallback<String>
@Mock
private lateinit var onError: OnErrorCallback
private lateinit var task: Task<String>
@Before
fun setUp() {
MockitoAnnotations.initMocks(this)
val postResult = { r: Runnable -> r.run() }
task = Task(postResult, taskBody, onResult, onError)
}
@Test
fun `task result is posted`() {
whenever(taskBody.invoke()).thenReturn("result")
task.run()
verify(onResult).invoke(eq("result"))
verify(onError, never()).invoke(any())
}
@Test
fun `task result is not posted when cancelled`() {
whenever(taskBody.invoke()).thenReturn("result")
task.cancel()
task.run()
verify(onResult, never()).invoke(any())
verify(onError, never()).invoke(any())
}
@Test
fun `task error is posted`() {
val exception = RuntimeException("")
whenever(taskBody.invoke()).thenThrow(exception)
task.run()
verify(onResult, never()).invoke(any())
verify(onError).invoke(same(exception))
}
@Test
fun `task error is not posted when cancelled`() {
val exception = RuntimeException("")
whenever(taskBody.invoke()).thenThrow(exception)
task.cancel()
task.run()
verify(onResult, never()).invoke(any())
verify(onError, never()).invoke(any())
}
}

View File

@ -1,3 +1,22 @@
/*
* 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
@ -10,22 +29,22 @@ 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
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
class AsyncRunnerTest {
class ThreadPoolAsyncRunnerTest {
private lateinit var handler: Handler
private lateinit var r: AsyncRunnerImpl
private lateinit var r: ThreadPoolAsyncRunner
@Before
fun setUp() {
handler = spy(Handler())
r = AsyncRunnerImpl(handler, 1)
r = ThreadPoolAsyncRunner(handler, 1)
}
fun assertAwait(latch: CountDownLatch, seconds: Long = 3) {

View File

@ -19,16 +19,16 @@
*/
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
import java.io.File
import java.nio.charset.Charset
import java.nio.file.Files
class TestFileLogHandler {
class FileLogHandlerTest {
private lateinit var logDir: File
@ -38,9 +38,16 @@ class TestFileLogHandler {
return String(raw, Charset.forName("UTF-8"))
}
private fun writeLogFile(name: String, content: String) {
/**
* Write raw content to file in log dir.
*
* @return size of written data in bytes
*/
private fun writeLogFile(name: String, content: String): Int {
val logFile = File(logDir, name)
Files.write(logFile.toPath(), content.toByteArray(Charsets.UTF_8))
val rawContent = content.toByteArray(Charsets.UTF_8)
Files.write(logFile.toPath(), rawContent)
return rawContent.size
}
@Before
@ -147,20 +154,22 @@ class TestFileLogHandler {
// 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")
var totalLogsSize = 0L
totalLogsSize += writeLogFile("log.txt.2", "line1\nline2\nline3")
totalLogsSize += writeLogFile("log.txt.1", "line4\nline5\nline6")
totalLogsSize += writeLogFile("log.txt.0", "line7\nline8\nline9")
totalLogsSize += 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)
val rawLogs = writer.loadLogFiles(3)
// THEN
// all files are loaded
// lines are loaded in correct order
assertEquals(12, lines.size)
// log files size is correctly reported
assertEquals(12, rawLogs.lines.size)
assertEquals(
listOf(
"line1", "line2", "line3",
@ -168,8 +177,9 @@ class TestFileLogHandler {
"line7", "line8", "line9",
"line10", "line11", "line12"
),
lines
rawLogs.lines
)
assertEquals(totalLogsSize, rawLogs.logSize)
}
@Test
@ -188,7 +198,9 @@ class TestFileLogHandler {
// THEN
// all files are loaded
assertEquals(6, lines.size)
// log file size is non-zero
assertEquals(6, lines.lines.size)
assertTrue(lines.logSize > 0)
}
@Test(expected = IllegalArgumentException::class)

View File

@ -0,0 +1,39 @@
/*
* 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 org.junit.Assert.assertEquals
import org.junit.Test
class LevelTest {
@Test
fun `parsing level tag`() {
Level.values().forEach {
val parsed = Level.fromTag(it.tag)
assertEquals(parsed, it)
}
}
@Test
fun `level parser handles unkown values`() {
assertEquals(Level.UNKNOWN, Level.fromTag("non-existing-tag"))
}
}

View File

@ -32,9 +32,6 @@ 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
@ -43,8 +40,11 @@ import org.junit.Before
import org.junit.Test
import org.mockito.ArgumentCaptor
import org.mockito.MockitoAnnotations
import java.nio.file.Files
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
class TestLogger {
class LoggerTest {
private companion object {
const val QUEUE_CAPACITY = 100
@ -149,7 +149,7 @@ class TestLogger {
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 listener: OnLogsLoaded = mock()
val latch = CountDownLatch(2)
// log handler will be called on bg thread
@ -191,7 +191,8 @@ class TestLogger {
postedCaptor.value.run()
val logsCaptor = ArgumentCaptor.forClass(List::class.java) as ArgumentCaptor<List<LogEntry>>
verify(listener).onLoaded(capture(logsCaptor))
val sizeCaptor = ArgumentCaptor.forClass(Long::class.java)
verify(listener).invoke(capture(logsCaptor), capture(sizeCaptor))
assertEquals(3, logsCaptor.value.size)
assertTrue("message 1" in logsCaptor.value[0].message)
assertTrue("message 2" in logsCaptor.value[1].message)
@ -245,13 +246,13 @@ class TestLogger {
true
}
val listener: LogsRepository.Listener = mock()
val listener: OnLogsLoaded = mock()
logger.load(listener)
assertTrue("Logs not loaded", posted.await(1, TimeUnit.SECONDS))
verify(listener).onLoaded(argThat {
verify(listener).invoke(argThat {
"Logger queue overflow" in last().message
})
}, any())
}
@Test
@ -280,6 +281,8 @@ class TestLogger {
assertTrue(latch.await(3, TimeUnit.SECONDS))
verify(logHandler, times(3)).write(any())
verify(logHandler).deleteAll()
assertEquals(0, logHandler.loadLogFiles(logHandler.maxLogFilesCount).size)
val loaded = logHandler.loadLogFiles(logHandler.maxLogFilesCount)
assertEquals(0, loaded.lines.size)
assertEquals(0L, loaded.logSize)
}
}

View File

@ -0,0 +1,167 @@
/*
* 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 com.nextcloud.client.core.ManualAsyncRunner
import com.nhaarman.mockitokotlin2.any
import com.nhaarman.mockitokotlin2.mock
import com.nhaarman.mockitokotlin2.never
import com.nhaarman.mockitokotlin2.verify
import com.nhaarman.mockitokotlin2.whenever
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
class AsyncFilterTest {
class OnResult<T> : (List<T>, Long) -> Unit {
var arg: List<T>? = null
var dt: Long? = null
override fun invoke(arg: List<T>, dt: Long) {
this.arg = arg
this.dt = dt
}
}
private lateinit var time: () -> Long
private lateinit var runner: ManualAsyncRunner
private lateinit var filter: AsyncFilter
@Before
fun setUp() {
time = mock()
whenever(time.invoke()).thenReturn(System.currentTimeMillis())
runner = ManualAsyncRunner()
filter = AsyncFilter(runner, time)
}
@Test
fun `collection is filtered asynchronously`() {
val collection = listOf(1, 2, 3, 4, 5, 6)
val predicate = { arg: Int -> arg > 3 }
val result = OnResult<Int>()
// GIVEN
// filtering is scheduled
filter.filter(collection, predicate, result)
assertEquals(1, runner.size)
assertNull(result.arg)
// WHEN
// task completes
runner.runOne()
// THEN
// result is delivered via callback
assertEquals(listOf(4, 5, 6), result.arg)
}
@Test
fun `filtering request is enqueued if one already running`() {
val collection = listOf(1, 2, 3)
val firstPredicate = { arg: Int -> arg > 1 }
val secondPredicate = { arg: Int -> arg > 2 }
val firstResult = OnResult<Int>()
val secondResult = OnResult<Int>()
// GIVEN
// filtering task is already running
filter.filter(collection, firstPredicate, firstResult)
assertEquals(1, runner.size)
// WHEN
// new filtering is requested
// first filtering task completes
filter.filter(collection, secondPredicate, secondResult)
runner.runOne()
// THEN
// first filtering task result is delivered
// second filtering task is scheduled immediately
// second filtering result will be delivered when completes
assertEquals(listOf(2, 3), firstResult.arg)
assertEquals(1, runner.size)
runner.runOne()
assertEquals(listOf(3), secondResult.arg)
assertEquals(0, runner.size)
}
@Test
fun `pending requests are overwritten by new requests`() {
val collection = listOf(1, 2, 3, 4, 5, 6)
val firstPredicate = { arg: Int -> arg > 1 }
val firstResult = OnResult<Int>()
val secondPredicate: (Int) -> Boolean = mock()
whenever(secondPredicate.invoke(any())).thenReturn(false)
val secondResult = OnResult<Int>()
val thirdPredicate = { arg: Int -> arg > 3 }
val thirdResult = OnResult<Int>()
// GIVEN
// filtering task is already running
filter.filter(collection, firstPredicate, firstResult)
assertEquals(1, runner.size)
// WHEN
// few new filtering requests are enqueued
// first filtering task completes
filter.filter(collection, secondPredicate, secondResult)
filter.filter(collection, thirdPredicate, thirdResult)
runner.runOne()
assertEquals(1, runner.size)
runner.runOne()
// THEN
// second filtering task is overwritten
// second filtering task never runs
// third filtering task runs and completes
// no new tasks are scheduled
verify(secondPredicate, never()).invoke(any())
assertNull(secondResult.arg)
assertEquals(listOf(4, 5, 6), thirdResult.arg)
assertEquals(0, runner.size)
}
@Test
fun `filtering is timed`() {
// GIVEN
// filtering operation is scheduled
val startTime = System.currentTimeMillis()
whenever(time.invoke()).thenReturn(startTime)
val result = OnResult<Int>()
filter.filter(listOf(1, 2, 3), { true }, result)
// WHEN
// result is delivered with a delay
val delayMs = 123L
whenever(time.invoke()).thenReturn(startTime + delayMs)
runner.runAll()
// THEN
// delay is calculated from current time
assertEquals(result.dt, delayMs)
}
}

View File

@ -0,0 +1,240 @@
/*
* 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.arch.core.executor.testing.InstantTaskExecutorRule
import com.nextcloud.client.core.Clock
import com.nextcloud.client.core.ManualAsyncRunner
import com.nextcloud.client.logger.Level
import com.nextcloud.client.logger.LogEntry
import com.nextcloud.client.logger.LogsRepository
import com.nextcloud.client.logger.OnLogsLoaded
import com.nhaarman.mockitokotlin2.any
import com.nhaarman.mockitokotlin2.mock
import com.nhaarman.mockitokotlin2.whenever
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.Suite
import org.mockito.MockitoAnnotations
import java.util.Date
@RunWith(Suite::class)
@Suite.SuiteClasses(
LogsViewModelTest.Loading::class,
LogsViewModelTest.Filtering::class
)
class LogsViewModelTest {
private companion object {
val TEST_LOG_ENTRIES = listOf(
LogEntry(Date(), Level.DEBUG, "test", "entry 1"),
LogEntry(Date(), Level.DEBUG, "test", "entry 2"),
LogEntry(Date(), Level.DEBUG, "test", "entry 3")
)
val TEST_LOG_SIZE_KILOBYTES = 42L
val TEST_LOG_SIZE_BYTES = TEST_LOG_SIZE_KILOBYTES * 1024L
}
class TestLogRepository : LogsRepository {
var loadRequestCount = 0
var onLoadedCallback: OnLogsLoaded? = null
override val lostEntries: Boolean = false
override fun load(onLoaded: OnLogsLoaded) { this.onLoadedCallback = onLoaded; loadRequestCount++ }
override fun deleteAll() {}
}
abstract class Fixture {
protected lateinit var context: Context
protected lateinit var clock: Clock
protected lateinit var repository: TestLogRepository
protected lateinit var runner: ManualAsyncRunner
protected lateinit var vm: LogsViewModel
@get:Rule
val rule = InstantTaskExecutorRule()
@Before
fun setUpFixture() {
MockitoAnnotations.initMocks(this)
context = mock()
clock = mock()
repository = TestLogRepository()
runner = ManualAsyncRunner()
vm = LogsViewModel(context, clock, runner, repository)
whenever(context.getString(any(), any())).thenAnswer {
"${it.arguments}"
}
}
}
class Loading : Fixture() {
@Test
fun `all observable properties have initial values`() {
assertNotNull(vm.isLoading)
assertNotNull(vm.size)
assertNotNull(vm.entries)
assertNotNull(vm.status)
}
@Test
fun `load logs entries from repository`() {
// GIVEN
// entries are not loaded
assertEquals(0, vm.entries.value!!.size)
assertEquals(false, vm.isLoading.value)
// WHEN
// load is initiated
vm.load()
// THEN
// loading status is true
// repository request is posted
assertTrue(vm.isLoading.value!!)
assertNotNull(repository.onLoadedCallback)
}
@Test
fun `on logs loaded`() {
// GIVEN
// logs are being loaded
vm.load()
assertNotNull(repository.onLoadedCallback)
assertTrue(vm.isLoading.value!!)
// WHEN
// logs loading finishes
repository.onLoadedCallback?.invoke(TEST_LOG_ENTRIES, TEST_LOG_SIZE_BYTES)
// THEN
// logs are displayed
// logs size is displyed
// status is displayed
assertFalse(vm.isLoading.value!!)
assertSame(vm.entries.value, TEST_LOG_ENTRIES)
assertNotNull(vm.status.value)
}
@Test
fun `cannot start loading when loading is in progress`() {
// GIVEN
// logs loading is started
vm.load()
assertEquals(1, repository.loadRequestCount)
assertTrue(vm.isLoading.value!!)
// WHEN
// load is requested
repository.onLoadedCallback = null
vm.load()
// THEN
// request is ignored
assertNull(repository.onLoadedCallback)
assertEquals(1, repository.loadRequestCount)
}
}
class Filtering : Fixture() {
@Before
fun setUp() {
vm.load()
repository.onLoadedCallback?.invoke(TEST_LOG_ENTRIES, TEST_LOG_SIZE_BYTES)
assertFalse(vm.isLoading.value!!)
assertEquals(TEST_LOG_ENTRIES.size, vm.entries.value?.size)
}
@Test
fun `filtering cannot be started when loading`() {
// GIVEN
// loading is in progress
vm.load()
assertTrue(vm.isLoading.value!!)
// WHEN
// filtering is requested
vm.filter("some pattern")
// THEN
// filtering is not enqueued
assertTrue(runner.isEmpty)
}
@Test
fun `filtering task is started`() {
// GIVEN
// logs are loaded
assertEquals(TEST_LOG_ENTRIES.size, vm.entries.value?.size)
// WHEN
// logs filtering is not running
// logs filtering is requested
assertTrue(runner.isEmpty)
vm.filter(TEST_LOG_ENTRIES[0].message)
// THEN
// filter request is enqueued
assertEquals(1, runner.size)
}
@Test
fun `filtered logs are displayed`() {
var statusArgs: Array<Any> = emptyArray()
whenever(context.getString(any(), any())).thenAnswer {
statusArgs = it.arguments
"${it.arguments}"
}
// GIVEN
// filtering is in progress
val pattern = TEST_LOG_ENTRIES[0].message
vm.filter(pattern)
// WHEN
// filtering finishes
assertEquals(1, runner.runAll())
// THEN
// vm displays filtered results
// vm displays status
assertNotNull(vm.entries.value)
assertEquals(1, vm.entries.value?.size)
val filteredEntry = vm.entries.value?.get(0)!!
assertTrue(filteredEntry.message.contains(pattern))
assertEquals("Status should contain size in kB", TEST_LOG_SIZE_KILOBYTES, statusArgs[1])
assertEquals("Status should show matched entries count", vm.entries.value?.size, statusArgs[2])
assertEquals("Status should contain total entries count", TEST_LOG_ENTRIES.size, statusArgs[3])
assertTrue("Status should contain query time in ms", statusArgs[4] is Long)
}
}
}