From c35873f5dcb77bf9a33111c1fee2d25584f6a22d Mon Sep 17 00:00:00 2001 From: Chris Narkiewicz Date: Sun, 11 Aug 2019 13:11:21 +0100 Subject: [PATCH 1/2] 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 --- .editorconfig | 4 + build.gradle | 4 + spotbugs-filter.xml | 8 + src/main/AndroidManifest.xml | 2 +- .../com/nextcloud/client/core/AsyncRunner.kt | 3 +- .../nextcloud/client/core/AsyncRunnerImpl.kt | 65 ----- .../client/core/ManualAsyncRunner.kt | 76 ++++++ .../java/com/nextcloud/client/core/Task.kt | 57 +++++ .../client/core/ThreadPoolAsyncRunner.kt | 44 ++++ .../com/nextcloud/client/di/AppModule.java | 4 +- .../nextcloud/client/di/ComponentsModule.java | 2 +- .../nextcloud/client/logger/FileLogHandler.kt | 12 +- .../com/nextcloud/client/logger/LoggerImpl.kt | 13 +- .../nextcloud/client/logger/LogsRepository.kt | 11 +- .../nextcloud/client/logger/ui/AsyncFilter.kt | 83 ++++++ .../client/logger/ui/LogsActivity.kt | 104 ++++++++ .../nextcloud/client/logger/ui/LogsAdapter.kt | 3 +- .../client/logger/ui/LogsViewModel.kt | 77 +++++- .../android/ui/activity/LogsActivity.java | 120 --------- .../android/ui/activity/SettingsActivity.java | 1 + .../owncloud/android/utils/ThemeUtils.java | 5 + src/main/res/layout/logs_activity.xml | 106 ++++---- src/main/res/menu/logs_menu.xml | 49 ++++ src/main/res/values/strings.xml | 13 +- src/main/res/xml/preferences.xml | 2 +- .../client/core/ManualAsyncRunnerTest.kt | 148 +++++++++++ .../com/nextcloud/client/core/TaskTest.kt | 86 +++++++ ...erTest.kt => ThreadPoolAsyncRunnerTest.kt} | 29 ++- ...ileLogHandler.kt => FileLogHandlerTest.kt} | 40 ++- .../com/nextcloud/client/logger/LevelTest.kt | 39 +++ .../logger/{TestLogger.kt => LoggerTest.kt} | 23 +- .../client/logger/ui/AsyncFilterTest.kt | 167 ++++++++++++ .../client/logger/ui/LogsViewModelTest.kt | 240 ++++++++++++++++++ 33 files changed, 1333 insertions(+), 307 deletions(-) delete mode 100644 src/main/java/com/nextcloud/client/core/AsyncRunnerImpl.kt create mode 100644 src/main/java/com/nextcloud/client/core/ManualAsyncRunner.kt create mode 100644 src/main/java/com/nextcloud/client/core/Task.kt create mode 100644 src/main/java/com/nextcloud/client/core/ThreadPoolAsyncRunner.kt create mode 100644 src/main/java/com/nextcloud/client/logger/ui/AsyncFilter.kt create mode 100644 src/main/java/com/nextcloud/client/logger/ui/LogsActivity.kt delete mode 100644 src/main/java/com/owncloud/android/ui/activity/LogsActivity.java create mode 100644 src/main/res/menu/logs_menu.xml create mode 100644 src/test/java/com/nextcloud/client/core/ManualAsyncRunnerTest.kt create mode 100644 src/test/java/com/nextcloud/client/core/TaskTest.kt rename src/test/java/com/nextcloud/client/core/{AsyncRunnerTest.kt => ThreadPoolAsyncRunnerTest.kt} (78%) rename src/test/java/com/nextcloud/client/logger/{TestFileLogHandler.kt => FileLogHandlerTest.kt} (86%) create mode 100644 src/test/java/com/nextcloud/client/logger/LevelTest.kt rename src/test/java/com/nextcloud/client/logger/{TestLogger.kt => LoggerTest.kt} (95%) create mode 100644 src/test/java/com/nextcloud/client/logger/ui/AsyncFilterTest.kt create mode 100644 src/test/java/com/nextcloud/client/logger/ui/LogsViewModelTest.kt diff --git a/.editorconfig b/.editorconfig index 8aa4377dd23a..10cf82d2f242 100644 --- a/.editorconfig +++ b/.editorconfig @@ -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 diff --git a/build.gradle b/build.gradle index bdbc099fba4d..7b158b9f6f65 100644 --- a/build.gradle +++ b/build.gradle @@ -244,6 +244,10 @@ android { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } + + dataBinding { + enabled true + } } dependencies { diff --git a/spotbugs-filter.xml b/spotbugs-filter.xml index b37c066a698d..e2fcf8223de4 100644 --- a/spotbugs-filter.xml +++ b/spotbugs-filter.xml @@ -26,6 +26,14 @@ + + + + + + + + diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml index 622340b0b64a..c0a6c4a66a99 100644 --- a/src/main/AndroidManifest.xml +++ b/src/main/AndroidManifest.xml @@ -318,7 +318,7 @@ - + = () -> T typealias OnResultCallback = (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 post(block: () -> T, onResult: OnResultCallback? = null, onError: OnErrorCallback? = null): Cancellable + fun post(task: () -> T, onResult: OnResultCallback? = null, onError: OnErrorCallback? = null): Cancellable } diff --git a/src/main/java/com/nextcloud/client/core/AsyncRunnerImpl.kt b/src/main/java/com/nextcloud/client/core/AsyncRunnerImpl.kt deleted file mode 100644 index 836eb8880c84..000000000000 --- a/src/main/java/com/nextcloud/client/core/AsyncRunnerImpl.kt +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Nextcloud Android client application - * - * @author Chris Narkiewicz - * Copyright (C) 2019 Chris Narkiewicz - * - * 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 . - */ -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( - private val handler: Handler, - private val callable: () -> T, - private val onSuccess: OnResultCallback?, - 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 post(block: () -> T, onResult: OnResultCallback?, onError: OnErrorCallback?): Cancellable { - val task = Task(uiThreadHandler, block, onResult, onError) - executor.execute(task) - return task - } -} diff --git a/src/main/java/com/nextcloud/client/core/ManualAsyncRunner.kt b/src/main/java/com/nextcloud/client/core/ManualAsyncRunner.kt new file mode 100644 index 000000000000..6130aa57a402 --- /dev/null +++ b/src/main/java/com/nextcloud/client/core/ManualAsyncRunner.kt @@ -0,0 +1,76 @@ +/* + * Nextcloud Android client application + * + * @author Chris Narkiewicz + * Copyright (C) 2019 Chris Narkiewicz + * + * 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 . + */ +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> = ArrayDeque() + + override fun post(task: () -> T, onResult: OnResultCallback?, 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 + } +} diff --git a/src/main/java/com/nextcloud/client/core/Task.kt b/src/main/java/com/nextcloud/client/core/Task.kt new file mode 100644 index 000000000000..6de6dfdfd316 --- /dev/null +++ b/src/main/java/com/nextcloud/client/core/Task.kt @@ -0,0 +1,57 @@ +/* + * Nextcloud Android client application + * + * @author Chris Narkiewicz + * Copyright (C) 2019 Chris Narkiewicz + * + * 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 . + */ +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( + private val postResult: (Runnable) -> Unit, + private val taskBody: () -> T, + private val onSuccess: OnResultCallback?, + 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) + } +} diff --git a/src/main/java/com/nextcloud/client/core/ThreadPoolAsyncRunner.kt b/src/main/java/com/nextcloud/client/core/ThreadPoolAsyncRunner.kt new file mode 100644 index 000000000000..096e0cafafea --- /dev/null +++ b/src/main/java/com/nextcloud/client/core/ThreadPoolAsyncRunner.kt @@ -0,0 +1,44 @@ +/* + * Nextcloud Android client application + * + * @author Chris Narkiewicz + * Copyright (C) 2019 Chris Narkiewicz + * + * 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 . + */ +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 post(task: () -> T, onResult: OnResultCallback?, onError: OnErrorCallback?): Cancellable { + val taskWrapper = Task(this::postResult, task, onResult, onError) + executor.execute(taskWrapper) + return taskWrapper + } + + private fun postResult(r: Runnable) { + uiThreadHandler.post(r) + } +} diff --git a/src/main/java/com/nextcloud/client/di/AppModule.java b/src/main/java/com/nextcloud/client/di/AppModule.java index a04f92188463..d7ca17a02d73 100644 --- a/src/main/java/com/nextcloud/client/di/AppModule.java +++ b/src/main/java/com/nextcloud/client/di/AppModule.java @@ -31,7 +31,7 @@ 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 @@ LogsRepository logsRepository(Logger logger) { @Singleton AsyncRunner asyncRunner() { Handler uiHandler = new Handler(); - return new AsyncRunnerImpl(uiHandler, 4); + return new ThreadPoolAsyncRunner(uiHandler, 4); } } diff --git a/src/main/java/com/nextcloud/client/di/ComponentsModule.java b/src/main/java/com/nextcloud/client/di/ComponentsModule.java index 8054366f39c5..a55aa14a9b78 100644 --- a/src/main/java/com/nextcloud/client/di/ComponentsModule.java +++ b/src/main/java/com/nextcloud/client/di/ComponentsModule.java @@ -45,7 +45,7 @@ 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; diff --git a/src/main/java/com/nextcloud/client/logger/FileLogHandler.kt b/src/main/java/com/nextcloud/client/logger/FileLogHandler.kt index f477c2c38e47..3f701d5759a3 100644 --- a/src/main/java/com/nextcloud/client/logger/FileLogHandler.kt +++ b/src/main/java/com/nextcloud/client/logger/FileLogHandler.kt @@ -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, 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 { + fun loadLogFiles(rotated: Int = ROTATED_LOGS_COUNT): RawLogs { if (rotated < 0) { throw IllegalArgumentException("Negative index") } val allLines = mutableListOf() + 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) } } diff --git a/src/main/java/com/nextcloud/client/logger/LoggerImpl.kt b/src/main/java/com/nextcloud/client/logger/LoggerImpl.kt index 45b5a2730bec..d6105f9b4841 100644 --- a/src/main/java/com/nextcloud/client/logger/LoggerImpl.kt +++ b/src/main/java/com/nextcloud/client/logger/LoggerImpl.kt @@ -37,7 +37,7 @@ internal class LoggerImpl( queueCapacity: Int ) : Logger, LogsRepository { - data class Load(val listener: LogsRepository.Listener) + data class Load(val onResult: (List, 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, 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() } diff --git a/src/main/java/com/nextcloud/client/logger/LogsRepository.kt b/src/main/java/com/nextcloud/client/logger/LogsRepository.kt index 714321e1e706..9468fe5e946b 100644 --- a/src/main/java/com/nextcloud/client/logger/LogsRepository.kt +++ b/src/main/java/com/nextcloud/client/logger/LogsRepository.kt @@ -19,17 +19,14 @@ */ package com.nextcloud.client.logger +typealias OnLogsLoaded = (entries: List, 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) - } - /** * 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. diff --git a/src/main/java/com/nextcloud/client/logger/ui/AsyncFilter.kt b/src/main/java/com/nextcloud/client/logger/ui/AsyncFilter.kt new file mode 100644 index 000000000000..7846469f97e8 --- /dev/null +++ b/src/main/java/com/nextcloud/client/logger/ui/AsyncFilter.kt @@ -0,0 +1,83 @@ +/* + * Nextcloud Android client application + * + * @author Chris Narkiewicz + * Copyright (C) 2019 Chris Narkiewicz + * + * 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 . + */ +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 filter( + collection: Iterable, + predicate: (T) -> Boolean, + onResult: (filtered: List, durationMs: Long) -> Unit + ) { + pendingRequest = { + filterAsync(collection, predicate, onResult) + } + if (!isRunning) { + pendingRequest?.invoke() + } + } + + private fun filterAsync(collection: Iterable, predicate: (T) -> Boolean, onResult: (List, Long) -> Unit) { + startTime = time.invoke() + filterTask = asyncRunner.post( + task = { + collection.filter { predicate.invoke(it) } + }, + onResult = { filtered: List -> + onFilterCompleted(filtered, onResult) + } + ) + pendingRequest = null + } + + private fun onFilterCompleted(filtered: List, callback: (List, Long) -> Unit) { + val dt = time.invoke() - startTime + callback.invoke(filtered, dt) + filterTask = null + startTime = 0L + pendingRequest?.invoke() + } +} diff --git a/src/main/java/com/nextcloud/client/logger/ui/LogsActivity.kt b/src/main/java/com/nextcloud/client/logger/ui/LogsActivity.kt new file mode 100644 index 000000000000..c36697e589b2 --- /dev/null +++ b/src/main/java/com/nextcloud/client/logger/ui/LogsActivity.kt @@ -0,0 +1,104 @@ +/* + * Nextcloud Android client application + * + * @author Chris Narkiewicz + * Copyright (C) 2019 Chris Narkiewicz + * + * 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 . + */ +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(this, R.layout.logs_activity).apply { + lifecycleOwner = this@LogsActivity + vm = this@LogsActivity.vm + } + + findViewById(R.id.logs_loading_progress).apply { + ThemeUtils.themeProgressBar(context, this) + } + + logsAdapter = LogsAdapter(this) + findViewById(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 + } +} diff --git a/src/main/java/com/nextcloud/client/logger/ui/LogsAdapter.kt b/src/main/java/com/nextcloud/client/logger/ui/LogsAdapter.kt index 1600e8e6cb97..ffc43a76803a 100644 --- a/src/main/java/com/nextcloud/client/logger/ui/LogsAdapter.kt +++ b/src/main/java/com/nextcloud/client/logger/ui/LogsAdapter.kt @@ -52,7 +52,8 @@ class LogsAdapter(context: Context) : RecyclerView.Adapter> = MutableLiveData() - private val listener = object : LogsRepository.Listener { - override fun onLoaded(entries: List) { - 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() + private var logsSize = -1L + private var filterDurationMs = 0L + private var isFiltered = false + + val isLoading: LiveData = MutableLiveData().apply { value = false } + val size: LiveData = MutableLiveData().apply { value = 0 } + val entries: LiveData> = MutableLiveData>().apply { value = emptyList() } + val status: LiveData = MutableLiveData().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, 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, 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 -> "" + } + } } diff --git a/src/main/java/com/owncloud/android/ui/activity/LogsActivity.java b/src/main/java/com/owncloud/android/ui/activity/LogsActivity.java deleted file mode 100644 index fabdc7a90ccd..000000000000 --- a/src/main/java/com/owncloud/android/ui/activity/LogsActivity.java +++ /dev/null @@ -1,120 +0,0 @@ -/* - * ownCloud Android client application - * - * Copyright (C) 2015 ownCloud Inc. - * Copyright (C) Chris Narkiewicz - * - * 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 . - */ - -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(); - } -} diff --git a/src/main/java/com/owncloud/android/ui/activity/SettingsActivity.java b/src/main/java/com/owncloud/android/ui/activity/SettingsActivity.java index 2d3893fa639f..a3963a898a25 100644 --- a/src/main/java/com/owncloud/android/ui/activity/SettingsActivity.java +++ b/src/main/java/com/owncloud/android/ui/activity/SettingsActivity.java @@ -56,6 +56,7 @@ 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; diff --git a/src/main/java/com/owncloud/android/utils/ThemeUtils.java b/src/main/java/com/owncloud/android/utils/ThemeUtils.java index c8829aca5a78..9d4e9ad412a9 100644 --- a/src/main/java/com/owncloud/android/utils/ThemeUtils.java +++ b/src/main/java/com/owncloud/android/utils/ThemeUtils.java @@ -485,6 +485,11 @@ public static void themeSearchView(Context context, SearchView searchView, boole 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[][]{ diff --git a/src/main/res/layout/logs_activity.xml b/src/main/res/layout/logs_activity.xml index 9ee6e8074e55..2fe7cefc3d9d 100644 --- a/src/main/res/layout/logs_activity.xml +++ b/src/main/res/layout/logs_activity.xml @@ -1,72 +1,64 @@ - + - + + + + - - + android:layout_height="match_parent"> - + - + - + - + - + + + + diff --git a/src/main/res/menu/logs_menu.xml b/src/main/res/menu/logs_menu.xml new file mode 100644 index 000000000000..a1bafa4cfab9 --- /dev/null +++ b/src/main/res/menu/logs_menu.xml @@ -0,0 +1,49 @@ + + + + + + + + + + diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml index e523a75fd731..6d906e6fa82a 100644 --- a/src/main/res/values/strings.xml +++ b/src/main/res/values/strings.xml @@ -47,7 +47,6 @@ Show hidden files Show media scan notifications Notify about newly found media folders - Delete history Sync calendar & contacts Set up DAVx5 (formerly known as DAVdroid) (v1.3.0+) for current account Server address for the account could not be resolved for DAVx5 (formerly known as DAVdroid) @@ -407,11 +406,12 @@ Manage accounts Secure connection redirected through an unsecured route. - Logs - Send history + Logs + Refresh + Send logs by e-mail + Delete logs No app for sending logs found. Please install an e-mail client. %1$s Android app logs - Loading data… Password required Wrong password @@ -888,6 +888,11 @@ Engineering Test Mode Preferences + Loading… + Logs: %1$d kB, query matched %2$d / %3$d in %4$d ms + Logs: %1$d kB, no filter + Search logs + Report issue to tracker? (requires a Github account) Report %1$s crashed diff --git a/src/main/res/xml/preferences.xml b/src/main/res/xml/preferences.xml index 9c9bec635834..37f455b87c13 100644 --- a/src/main/res/xml/preferences.xml +++ b/src/main/res/xml/preferences.xml @@ -69,7 +69,7 @@ - + diff --git a/src/test/java/com/nextcloud/client/core/ManualAsyncRunnerTest.kt b/src/test/java/com/nextcloud/client/core/ManualAsyncRunnerTest.kt new file mode 100644 index 000000000000..aff2b7b1a60b --- /dev/null +++ b/src/test/java/com/nextcloud/client/core/ManualAsyncRunnerTest.kt @@ -0,0 +1,148 @@ +/* + * Nextcloud Android client application + * + * @author Chris Narkiewicz + * Copyright (C) 2019 Chris Narkiewicz + * + * 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 . + */ +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 + + @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 { + 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 + } +} diff --git a/src/test/java/com/nextcloud/client/core/TaskTest.kt b/src/test/java/com/nextcloud/client/core/TaskTest.kt new file mode 100644 index 000000000000..968fc73cbaec --- /dev/null +++ b/src/test/java/com/nextcloud/client/core/TaskTest.kt @@ -0,0 +1,86 @@ +/* + * Nextcloud Android client application + * + * @author Chris Narkiewicz + * Copyright (C) 2019 Chris Narkiewicz + * + * 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 . + */ +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 + @Mock + private lateinit var onError: OnErrorCallback + + private lateinit var task: Task + + @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()) + } +} diff --git a/src/test/java/com/nextcloud/client/core/AsyncRunnerTest.kt b/src/test/java/com/nextcloud/client/core/ThreadPoolAsyncRunnerTest.kt similarity index 78% rename from src/test/java/com/nextcloud/client/core/AsyncRunnerTest.kt rename to src/test/java/com/nextcloud/client/core/ThreadPoolAsyncRunnerTest.kt index b63bc0408c58..bd921ea71498 100644 --- a/src/test/java/com/nextcloud/client/core/AsyncRunnerTest.kt +++ b/src/test/java/com/nextcloud/client/core/ThreadPoolAsyncRunnerTest.kt @@ -1,3 +1,22 @@ +/* + * Nextcloud Android client application + * + * @author Chris Narkiewicz + * Copyright (C) 2019 Chris Narkiewicz + * + * 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 . + */ 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) { diff --git a/src/test/java/com/nextcloud/client/logger/TestFileLogHandler.kt b/src/test/java/com/nextcloud/client/logger/FileLogHandlerTest.kt similarity index 86% rename from src/test/java/com/nextcloud/client/logger/TestFileLogHandler.kt rename to src/test/java/com/nextcloud/client/logger/FileLogHandlerTest.kt index d930761fe867..708fa4f7df78 100644 --- a/src/test/java/com/nextcloud/client/logger/TestFileLogHandler.kt +++ b/src/test/java/com/nextcloud/client/logger/FileLogHandlerTest.kt @@ -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) diff --git a/src/test/java/com/nextcloud/client/logger/LevelTest.kt b/src/test/java/com/nextcloud/client/logger/LevelTest.kt new file mode 100644 index 000000000000..4af6d81f3816 --- /dev/null +++ b/src/test/java/com/nextcloud/client/logger/LevelTest.kt @@ -0,0 +1,39 @@ +/* + * Nextcloud Android client application + * + * @author Chris Narkiewicz + * Copyright (C) 2019 Chris Narkiewicz + * + * 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 . + */ +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")) + } +} diff --git a/src/test/java/com/nextcloud/client/logger/TestLogger.kt b/src/test/java/com/nextcloud/client/logger/LoggerTest.kt similarity index 95% rename from src/test/java/com/nextcloud/client/logger/TestLogger.kt rename to src/test/java/com/nextcloud/client/logger/LoggerTest.kt index 3f6ec62095db..ab12042c9c0c 100644 --- a/src/test/java/com/nextcloud/client/logger/TestLogger.kt +++ b/src/test/java/com/nextcloud/client/logger/LoggerTest.kt @@ -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> - 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) } } diff --git a/src/test/java/com/nextcloud/client/logger/ui/AsyncFilterTest.kt b/src/test/java/com/nextcloud/client/logger/ui/AsyncFilterTest.kt new file mode 100644 index 000000000000..08532141838f --- /dev/null +++ b/src/test/java/com/nextcloud/client/logger/ui/AsyncFilterTest.kt @@ -0,0 +1,167 @@ +/* + * Nextcloud Android client application + * + * @author Chris Narkiewicz + * Copyright (C) 2019 Chris Narkiewicz + * + * 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 . + */ +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 : (List, Long) -> Unit { + var arg: List? = null + var dt: Long? = null + override fun invoke(arg: List, 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() + + // 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() + val secondResult = OnResult() + + // 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() + + val secondPredicate: (Int) -> Boolean = mock() + whenever(secondPredicate.invoke(any())).thenReturn(false) + val secondResult = OnResult() + + val thirdPredicate = { arg: Int -> arg > 3 } + val thirdResult = OnResult() + + // 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() + 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) + } +} diff --git a/src/test/java/com/nextcloud/client/logger/ui/LogsViewModelTest.kt b/src/test/java/com/nextcloud/client/logger/ui/LogsViewModelTest.kt new file mode 100644 index 000000000000..99af65fd8235 --- /dev/null +++ b/src/test/java/com/nextcloud/client/logger/ui/LogsViewModelTest.kt @@ -0,0 +1,240 @@ +/* + * Nextcloud Android client application + * + * @author Chris Narkiewicz + * Copyright (C) 2019 Chris Narkiewicz + * + * 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 . + */ +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 = 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) + } + } +} From 725ffc4caaf43ed4269f9afac3f803077c8c6b10 Mon Sep 17 00:00:00 2001 From: nextcloud-android-bot Date: Wed, 14 Aug 2019 20:44:55 +0000 Subject: [PATCH 2/2] Drone: update Lint results to reflect reduced error/warning count [skip ci] Signed-off-by: nextcloud-android-bot --- scripts/analysis/lint-results.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/analysis/lint-results.txt b/scripts/analysis/lint-results.txt index 07b704560add..fd047cb4a658 100644 --- a/scripts/analysis/lint-results.txt +++ b/scripts/analysis/lint-results.txt @@ -1,2 +1,2 @@ DO NOT TOUCH; GENERATED BY DRONE - Lint Report: 59 warnings + Lint Report: 58 warnings