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:
parent
3c331aa6c0
commit
c35873f5dc
|
@ -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
|
||||
|
|
|
@ -244,6 +244,10 @@ android {
|
|||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
|
||||
dataBinding {
|
||||
enabled true
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
|
|
@ -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" />
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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 -> ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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[][]{
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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 & 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>
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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())
|
||||
}
|
||||
}
|
|
@ -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) {
|
|
@ -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)
|
|
@ -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"))
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue