Initial commit
This initial commit includes HUSH specific changes starting at this commit:
d14637012c
This commit is contained in:
1
feedback/.gitignore
vendored
Normal file
1
feedback/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/build
|
||||
43
feedback/build.gradle
Normal file
43
feedback/build.gradle
Normal file
@@ -0,0 +1,43 @@
|
||||
import cash.z.ecc.android.Deps
|
||||
|
||||
apply plugin: 'com.android.library'
|
||||
apply plugin: 'kotlin-android'
|
||||
|
||||
android {
|
||||
compileSdkVersion Deps.compileSdkVersion
|
||||
|
||||
defaultConfig {
|
||||
minSdkVersion Deps.minSdkVersion
|
||||
targetSdkVersion Deps.targetSdkVersion
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
consumerProguardFiles 'consumer-rules.pro'
|
||||
}
|
||||
kotlinOptions {
|
||||
freeCompilerArgs += "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi"
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
}
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
namespace 'cash.z.ecc.android.feedback'
|
||||
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation Deps.Kotlin.STDLIB
|
||||
implementation Deps.AndroidX.APPCOMPAT
|
||||
implementation Deps.AndroidX.CORE_KTX
|
||||
implementation Deps.Kotlin.Coroutines.CORE
|
||||
implementation Deps.Kotlin.Coroutines.TEST
|
||||
testImplementation Deps.Test.JUNIT
|
||||
}
|
||||
0
feedback/consumer-rules.pro
Normal file
0
feedback/consumer-rules.pro
Normal file
21
feedback/proguard-rules.pro
vendored
Normal file
21
feedback/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
2
feedback/src/main/AndroidManifest.xml
Normal file
2
feedback/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,2 @@
|
||||
<manifest
|
||||
xmlns:android="http://schemas.android.com/apk/res/android" />
|
||||
284
feedback/src/main/java/cash/z/ecc/android/feedback/Feedback.kt
Normal file
284
feedback/src/main/java/cash/z/ecc/android/feedback/Feedback.kt
Normal file
@@ -0,0 +1,284 @@
|
||||
package cash.z.ecc.android.feedback
|
||||
|
||||
import cash.z.ecc.android.feedback.util.CompositeJob
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.channels.BroadcastChannel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.asFlow
|
||||
import java.io.PrintWriter
|
||||
import java.io.StringWriter
|
||||
import kotlin.coroutines.coroutineContext
|
||||
|
||||
// There are deprecations with the use of BroadcastChannel
|
||||
@OptIn(ObsoleteCoroutinesApi::class)
|
||||
class Feedback(capacity: Int = 256) {
|
||||
lateinit var scope: CoroutineScope
|
||||
private set
|
||||
|
||||
private val _metrics = BroadcastChannel<Metric>(capacity)
|
||||
private val _actions = BroadcastChannel<Action>(capacity)
|
||||
private var onStartListeners: MutableList<() -> Unit> = mutableListOf()
|
||||
|
||||
private val jobs = CompositeJob()
|
||||
|
||||
val metrics: Flow<Metric> = _metrics.asFlow()
|
||||
val actions: Flow<Action> = _actions.asFlow()
|
||||
|
||||
/**
|
||||
* Verifies that this class is not leaking anything. Checks that all underlying channels are
|
||||
* closed and all launched reporting jobs are inactive.
|
||||
*/
|
||||
val isStopped get() = ensureScope() && _metrics.isClosedForSend && _actions.isClosedForSend && !scope.isActive && !jobs.isActive()
|
||||
|
||||
/**
|
||||
* Starts this feedback as a child of the calling coroutineContext, meaning when that context
|
||||
* ends, this feedback's scope and anything it launced will cancel. Note that the [metrics] and
|
||||
* [actions] channels will remain open unless [stop] is also called on this instance.
|
||||
*/
|
||||
suspend fun start(): Feedback {
|
||||
if(::scope.isInitialized) {
|
||||
return this
|
||||
}
|
||||
scope = CoroutineScope(Dispatchers.IO + SupervisorJob(coroutineContext[Job]))
|
||||
invokeOnCompletion {
|
||||
_metrics.close()
|
||||
_actions.close()
|
||||
}
|
||||
onStartListeners.forEach { it() }
|
||||
onStartListeners.clear()
|
||||
return this
|
||||
}
|
||||
|
||||
fun invokeOnCompletion(block: CompletionHandler) {
|
||||
ensureScope()
|
||||
scope.coroutineContext[Job]!!.invokeOnCompletion(block)
|
||||
}
|
||||
|
||||
/**
|
||||
* Invokes the given callback after the scope has been initialized or immediately, if the scope
|
||||
* has already been initialized. This is used by [FeedbackCoordinator] and things like it that
|
||||
* want to immediately begin collecting the metrics/actions flows because any emissions that
|
||||
* occur before subscription are dropped.
|
||||
*/
|
||||
fun onStart(onStartListener: () -> Unit) {
|
||||
if (::scope.isInitialized) {
|
||||
onStartListener()
|
||||
} else {
|
||||
onStartListeners.add(onStartListener)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop this instance and close all reporting channels. This function will first wait for all
|
||||
* in-flight reports to complete.
|
||||
*/
|
||||
suspend fun stop() {
|
||||
// expose instances where stop is being called before start occurred.
|
||||
ensureScope()
|
||||
await()
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
/**
|
||||
* Suspends until all in-flight reports have completed. This is automatically called before
|
||||
* [stop].
|
||||
*/
|
||||
suspend fun await() {
|
||||
jobs.await()
|
||||
}
|
||||
|
||||
/**
|
||||
* Measures the given block of code by surrounding it with time metrics and the reporting the
|
||||
* result.
|
||||
*
|
||||
* Don't measure code that launches coroutines, instead measure the code within the coroutine
|
||||
* that gets launched. Otherwise, the timing will be incorrect because the launched coroutine
|
||||
* will run concurrently--meaning a "happens before" relationship between the measurer and the
|
||||
* measured cannot be established and thereby the concurrent action cannot be timed.
|
||||
*/
|
||||
inline fun <T> measure(key: String = "measurement.generic", description: Any = "measurement", block: () -> T): T {
|
||||
ensureScope()
|
||||
val metric = TimeMetric(key, description.toString()).markTime()
|
||||
val result = block()
|
||||
metric.markTime()
|
||||
report(metric)
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the given metric to the stream of metric events.
|
||||
*
|
||||
* @param metric the metric to add.
|
||||
*/
|
||||
fun report(metric: Metric): Feedback {
|
||||
jobs += scope.launch {
|
||||
_metrics.send(metric)
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the given action to the stream of action events.
|
||||
*
|
||||
* @param action the action to add.
|
||||
*/
|
||||
fun report(action: Action): Feedback {
|
||||
jobs += scope.launch {
|
||||
_actions.send(action)
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Report the given error to everything that is tracking feedback. Converts it to a Crash object
|
||||
* which is intended for use in property-based analytics.
|
||||
*
|
||||
* @param error the uncaught exception that occurred.
|
||||
*/
|
||||
fun report(error: Throwable?, isFatal: Boolean = false): Feedback {
|
||||
return if (isFatal) report(Crash(error)) else report(NonFatal(error, "reported"))
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures that the scope for this instance has been initialized.
|
||||
*/
|
||||
fun ensureScope(): Boolean {
|
||||
check(::scope.isInitialized) {
|
||||
"Error: feedback has not been initialized. Before attempting to use this feedback" +
|
||||
" object, ensure feedback.start() has been called."
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun ensureStopped(): Boolean {
|
||||
val errors = mutableListOf<String>()
|
||||
|
||||
if (!_metrics.isClosedForSend && !_actions.isClosedForSend) errors += "both channels are still open"
|
||||
else if (!_actions.isClosedForSend) errors += "the actions channel is still open"
|
||||
else if (!_metrics.isClosedForSend) errors += "the metrics channel is still open"
|
||||
|
||||
if (scope.isActive) errors += "the scope is still active"
|
||||
if (jobs.isActive()) errors += "reporting jobs are still active"
|
||||
if (errors.isEmpty()) return true
|
||||
throw IllegalStateException("Feedback is still active because ${errors.joinToString(", ")}.")
|
||||
}
|
||||
|
||||
|
||||
interface Metric : Mappable<String, Any>, Keyed<String> {
|
||||
override val key: String
|
||||
val startTime: Long?
|
||||
val endTime: Long?
|
||||
val elapsedTime: Long?
|
||||
val description: String
|
||||
|
||||
override fun toMap(): Map<String, Any> {
|
||||
return mapOf(
|
||||
"key" to key,
|
||||
"description" to description,
|
||||
"startTime" to (startTime ?: 0),
|
||||
"endTime" to (endTime ?: 0),
|
||||
"elapsedTime" to (elapsedTime ?: 0)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
interface Action : Feedback.Mappable<String, Any>, Keyed<String> {
|
||||
override val key: String
|
||||
override fun toMap(): Map<String, Any> {
|
||||
return mapOf("key" to key)
|
||||
}
|
||||
}
|
||||
|
||||
abstract class MappedAction private constructor(protected val propertyMap: MutableMap<String, Any> = mutableMapOf()) : Feedback.Action {
|
||||
constructor(vararg properties: Pair<String, Any>) : this(mutableMapOf(*properties))
|
||||
|
||||
override fun toMap(): Map<String, Any> {
|
||||
return propertyMap.apply { putAll(super.toMap()) }
|
||||
}
|
||||
}
|
||||
|
||||
abstract class Funnel(funnelName: String, stepName: String, step: Int, vararg properties: Pair<String, Any>) : MappedAction(
|
||||
"funnelName" to funnelName,
|
||||
"stepName" to stepName,
|
||||
"step" to step,
|
||||
*properties
|
||||
) {
|
||||
override fun toString() = key
|
||||
override val key: String = "funnel.$funnelName.$stepName.$step"
|
||||
}
|
||||
|
||||
interface Keyed<T> {
|
||||
val key: T
|
||||
}
|
||||
|
||||
interface Mappable<K, V> {
|
||||
fun toMap(): Map<K, V>
|
||||
}
|
||||
|
||||
data class TimeMetric(
|
||||
override val key: String,
|
||||
override val description: String,
|
||||
val times: MutableList<Long> = mutableListOf()
|
||||
) : Metric {
|
||||
override val startTime: Long? get() = times.firstOrNull()
|
||||
override val endTime: Long? get() = times.lastOrNull()
|
||||
override val elapsedTime: Long? get() = endTime?.minus(startTime ?: 0)
|
||||
fun markTime(): TimeMetric {
|
||||
times.add(System.currentTimeMillis())
|
||||
return this
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
return "$description in ${elapsedTime}ms"
|
||||
}
|
||||
}
|
||||
|
||||
open class AppError(name: String = "unknown", description: String? = null, isFatal: Boolean = false, vararg properties: Pair<String, Any>) : MappedAction(
|
||||
"isError" to true,
|
||||
"isFatal" to isFatal,
|
||||
"errorName" to name,
|
||||
"message" to (description ?: "None"),
|
||||
"description" to describe(name, description, isFatal),
|
||||
*properties
|
||||
) {
|
||||
val isFatal: Boolean by propertyMap
|
||||
val errorName: String by propertyMap
|
||||
val description: String by propertyMap
|
||||
constructor(name: String, exception: Throwable? = null, isFatal: Boolean = false) : this(
|
||||
name, exception?.toString(), isFatal,
|
||||
"exceptionString" to (exception?.toString() ?: "None"),
|
||||
"message" to (exception?.message ?: "None"),
|
||||
"cause" to (exception?.cause?.toString() ?: "None"),
|
||||
"cause.cause" to (exception?.cause?.cause?.toString() ?: "None"),
|
||||
"cause.cause.cause" to (exception?.cause?.cause?.cause?.toString() ?: "None")
|
||||
) {
|
||||
propertyMap.putAll(exception.stacktraceToMap())
|
||||
}
|
||||
|
||||
override val key = "error.${if (isFatal) "fatal" else "nonfatal"}.$name"
|
||||
override fun toString() = description
|
||||
|
||||
companion object {
|
||||
fun describe(name: String, description: String?, isFatal: Boolean) =
|
||||
"${if (isFatal) "Error: FATAL" else "Error: non-fatal"} $name error due to: ${description ?: "unknown error"}"
|
||||
}
|
||||
}
|
||||
|
||||
class Crash(val exception: Throwable? = null) : AppError( "crash", exception, true)
|
||||
class NonFatal(val exception: Throwable? = null, name: String) : AppError(name, exception, false)
|
||||
}
|
||||
|
||||
|
||||
|
||||
private fun Throwable?.stacktraceToMap(chunkSize: Int = 250): Map<out String, String> {
|
||||
val properties = mutableMapOf("stacktrace.0" to "None")
|
||||
if (this == null) return properties
|
||||
val stringWriter = StringWriter()
|
||||
|
||||
printStackTrace(PrintWriter(stringWriter))
|
||||
|
||||
stringWriter.toString().chunked(chunkSize).forEachIndexed { index, chunk ->
|
||||
properties["stacktrace.$index"] = chunk
|
||||
}
|
||||
return properties
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package cash.z.ecc.android.feedback
|
||||
|
||||
import cash.z.ecc.android.feedback.util.CompositeJob
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlin.coroutines.coroutineContext
|
||||
|
||||
/**
|
||||
* Takes care of the boilerplate involved in processing feedback emissions. Simply provide callbacks
|
||||
* and emissions will occur in a mutually exclusive way, across all processors, so that things like
|
||||
* writing to a file can occur without clobbering changes. This class also provides a mechanism for
|
||||
* waiting for any in-flight emissions to complete. Lastly, all monitoring will cleanly complete
|
||||
* whenever the feedback is stopped or its parent scope is cancelled.
|
||||
*/
|
||||
class FeedbackCoordinator(val feedback: Feedback, defaultObservers: Set<FeedbackObserver> = setOf()) {
|
||||
|
||||
init {
|
||||
feedback.apply {
|
||||
onStart {
|
||||
invokeOnCompletion {
|
||||
flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
defaultObservers.forEach {
|
||||
addObserver(it)
|
||||
}
|
||||
}
|
||||
|
||||
private var contextMetrics = Dispatchers.IO
|
||||
private var contextActions = Dispatchers.IO
|
||||
private val jobs = CompositeJob()
|
||||
val observers = mutableSetOf<FeedbackObserver>()
|
||||
|
||||
/**
|
||||
* Wait for any in-flight listeners to complete.
|
||||
*/
|
||||
suspend fun await() {
|
||||
jobs.await()
|
||||
flush()
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel all in-flight observer functions.
|
||||
*/
|
||||
fun cancel() {
|
||||
jobs.cancel()
|
||||
flush()
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush all observers so they can clear all pending buffers.
|
||||
*/
|
||||
fun flush() {
|
||||
observers.forEach { it.flush() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject the context on which to observe metrics, mostly for testing purposes.
|
||||
*/
|
||||
fun metricsOn(dispatcher: CoroutineDispatcher): FeedbackCoordinator {
|
||||
contextMetrics = dispatcher
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject the context on which to observe actions, mostly for testing purposes.
|
||||
*/
|
||||
fun actionsOn(dispatcher: CoroutineDispatcher): FeedbackCoordinator {
|
||||
contextActions = dispatcher
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a coordinated observer that will not clobber all other observers because their actions
|
||||
* are coordinated via a global mutex.
|
||||
*/
|
||||
fun addObserver(observer: FeedbackObserver) {
|
||||
feedback.onStart {
|
||||
observers += observer.initialize()
|
||||
observeMetrics(observer::onMetric)
|
||||
observeActions(observer::onAction)
|
||||
}
|
||||
}
|
||||
|
||||
inline fun <reified T: FeedbackObserver> findObserver(): T? {
|
||||
return observers.firstOrNull { it::class == T::class } as T?
|
||||
}
|
||||
|
||||
private fun observeMetrics(onMetricListener: (Feedback.Metric) -> Unit) {
|
||||
feedback.metrics.onEach {
|
||||
jobs += feedback.scope.launch {
|
||||
withContext(contextMetrics) {
|
||||
mutex.withLock {
|
||||
onMetricListener(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.launchIn(feedback.scope)
|
||||
}
|
||||
|
||||
private fun observeActions(onActionListener: (Feedback.Action) -> Unit) {
|
||||
feedback.actions.onEach {
|
||||
val id = coroutineContext.hashCode()
|
||||
jobs += feedback.scope.launch {
|
||||
withContext(contextActions) {
|
||||
mutex.withLock {
|
||||
onActionListener(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.launchIn(feedback.scope)
|
||||
}
|
||||
|
||||
interface FeedbackObserver {
|
||||
fun initialize(): FeedbackObserver { return this }
|
||||
fun onMetric(metric: Feedback.Metric) {}
|
||||
fun onAction(action: Feedback.Action) {}
|
||||
fun flush() {}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val mutex: Mutex = Mutex()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package cash.z.ecc.android.feedback.util
|
||||
|
||||
import kotlinx.coroutines.Job
|
||||
|
||||
class CompositeJob {
|
||||
|
||||
private val activeJobs = mutableListOf<Job>()
|
||||
val size: Int get() = activeJobs.size
|
||||
|
||||
fun add(job: Job) {
|
||||
activeJobs.add(job)
|
||||
job.invokeOnCompletion {
|
||||
remove(job)
|
||||
}
|
||||
}
|
||||
|
||||
fun remove(job: Job): Boolean {
|
||||
return activeJobs.remove(job)
|
||||
}
|
||||
|
||||
fun isActive(): Boolean {
|
||||
return activeJobs.any { isActive() }
|
||||
}
|
||||
|
||||
suspend fun await() {
|
||||
// allow for concurrent modification since the list isn't coroutine or thread safe
|
||||
do {
|
||||
val job = activeJobs.firstOrNull()
|
||||
if (job?.isActive == true) {
|
||||
job.join()
|
||||
} else {
|
||||
// prevents an infinite loop in the extreme edge case where the list has a null item
|
||||
try { activeJobs.remove(job) } catch (t: Throwable) {}
|
||||
}
|
||||
} while (size > 0)
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
activeJobs.filter { isActive() }.forEach { it.cancel() }
|
||||
}
|
||||
|
||||
operator fun plusAssign(also: Job) {
|
||||
add(also)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package cash.z.ecc.android.feedback
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.test.TestCoroutineDispatcher
|
||||
import kotlinx.coroutines.test.TestCoroutineScope
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.rules.TestWatcher
|
||||
import org.junit.runner.Description
|
||||
|
||||
class CoroutinesTestRule(
|
||||
val testDispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher()
|
||||
) : TestWatcher() {
|
||||
|
||||
lateinit var testScope: TestCoroutineScope
|
||||
|
||||
override fun starting(description: Description?) {
|
||||
super.starting(description)
|
||||
Dispatchers.setMain(testDispatcher)
|
||||
testScope = TestCoroutineScope()
|
||||
}
|
||||
|
||||
override fun finished(description: Description?) {
|
||||
super.finished(description)
|
||||
Dispatchers.resetMain()
|
||||
testDispatcher.cleanupTestCoroutines()
|
||||
if (testScope.coroutineContext[Job]?.isActive == true) testScope.cancel()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package cash.z.ecc.android.feedback
|
||||
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class FeedbackObserverTest {
|
||||
|
||||
private val feedback: Feedback = Feedback()
|
||||
private val feedbackCoordinator: FeedbackCoordinator = FeedbackCoordinator(feedback)
|
||||
|
||||
private var counter: Int = 0
|
||||
private val simpleAction = object : Feedback.Action {
|
||||
override val key = "ButtonClick"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testConcurrency() = runBlocking {
|
||||
val actionCount = 50
|
||||
val processorCount = 50
|
||||
val expectedTotal = actionCount * processorCount
|
||||
|
||||
repeat(processorCount) {
|
||||
addObserver()
|
||||
}
|
||||
|
||||
feedback.start()
|
||||
repeat(actionCount) {
|
||||
sendAction()
|
||||
}
|
||||
|
||||
feedback.await() // await sends
|
||||
feedbackCoordinator.await() // await processing
|
||||
feedback.stop()
|
||||
|
||||
assertEquals(
|
||||
"Concurrent modification happened ${expectedTotal - counter} times",
|
||||
expectedTotal,
|
||||
counter
|
||||
)
|
||||
}
|
||||
|
||||
private fun addObserver() {
|
||||
feedbackCoordinator.addObserver(object : FeedbackCoordinator.FeedbackObserver {
|
||||
override fun onAction(action: Feedback.Action) {
|
||||
counter++
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun sendAction() {
|
||||
feedback.report(simpleAction)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package cash.z.ecc.android.feedback
|
||||
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import java.lang.RuntimeException
|
||||
|
||||
class FeedbackTest {
|
||||
|
||||
@Test
|
||||
fun testMeasure_blocking() = runBlocking {
|
||||
val duration = 1_100L
|
||||
val feedback = Feedback().start()
|
||||
verifyDuration(feedback, duration)
|
||||
|
||||
feedback.measure {
|
||||
workBlocking(duration)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testMeasure_suspending() = runBlocking {
|
||||
val duration = 1_100L
|
||||
val feedback = Feedback().start()
|
||||
verifyDuration(feedback, duration)
|
||||
|
||||
feedback.measure {
|
||||
workSuspending(duration)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testTrack() = runBlocking {
|
||||
val simpleAction = object : Feedback.Action {
|
||||
override val key = "ButtonClick"
|
||||
}
|
||||
val feedback = Feedback().start()
|
||||
verifyAction(feedback, simpleAction.key)
|
||||
|
||||
feedback.report(simpleAction)
|
||||
Unit
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testCancellation_stop() = runBlocking {
|
||||
verifyFeedbackCancellation { feedback, _ ->
|
||||
feedback.stop()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testCancellation_cancel() = runBlocking {
|
||||
verifyFeedbackCancellation { _, parentJob ->
|
||||
parentJob.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
@Test(expected = IllegalStateException::class)
|
||||
fun testCancellation_noCancel() = runBlocking {
|
||||
verifyFeedbackCancellation { _, _ -> }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testCrash() {
|
||||
val rushing = RuntimeException("rushing")
|
||||
val speeding = RuntimeException("speeding", rushing)
|
||||
val runlight = RuntimeException("Run light", speeding)
|
||||
val crash = Feedback.Crash(RuntimeException("BOOM", runlight))
|
||||
val map = crash.toMap()
|
||||
printMap(map)
|
||||
|
||||
assertNotNull(map["cause"])
|
||||
assertNotNull(map["cause.cause"])
|
||||
assertNotNull(map["cause.cause"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testAppError_exception() {
|
||||
val rushing = RuntimeException("rushing")
|
||||
val speeding = RuntimeException("speeding", rushing)
|
||||
val runlight = RuntimeException("Run light", speeding)
|
||||
val error = Feedback.AppError("reported", RuntimeException("BOOM", runlight))
|
||||
val map = error.toMap()
|
||||
printMap(map)
|
||||
|
||||
assertFalse(error.isFatal)
|
||||
assertNotNull(map["cause"])
|
||||
assertNotNull(map["cause.cause"])
|
||||
assertNotNull(map["cause.cause"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testAppError_description() {
|
||||
val error = Feedback.AppError("reported", "The server was down while downloading blocks!")
|
||||
val map = error.toMap()
|
||||
printMap(map)
|
||||
|
||||
assertFalse(error.isFatal)
|
||||
}
|
||||
|
||||
private fun printMap(map: Map<String, Any>) {
|
||||
for (entry in map) {
|
||||
println("%-20s = %s".format(entry.key, entry.value))
|
||||
}
|
||||
}
|
||||
|
||||
private fun verifyFeedbackCancellation(testBlock: suspend (Feedback, Job) -> Unit) = runBlocking {
|
||||
val feedback = Feedback()
|
||||
var counter = 0
|
||||
val parentJob = launch {
|
||||
feedback.start()
|
||||
feedback.scope.launch {
|
||||
delay(50)
|
||||
counter = 1
|
||||
}
|
||||
}
|
||||
// give feedback.start a chance to happen before cancelling
|
||||
delay(25)
|
||||
// stop or cancel things here
|
||||
testBlock(feedback, parentJob)
|
||||
delay(75)
|
||||
feedback.ensureStopped()
|
||||
assertEquals(0, counter)
|
||||
}
|
||||
|
||||
private fun verifyDuration(feedback: Feedback, duration: Long) {
|
||||
feedback.metrics.onEach {
|
||||
val metric = (it as? Feedback.TimeMetric)?.elapsedTime
|
||||
assertTrue(
|
||||
"Measured time did not match duration. Expected $duration but was $metric",
|
||||
metric ?: 0 >= duration
|
||||
)
|
||||
feedback.stop()
|
||||
}.launchIn(feedback.scope)
|
||||
}
|
||||
|
||||
private fun verifyAction(feedback: Feedback, name: String) {
|
||||
feedback.actions.onEach {
|
||||
assertTrue("Action did not match. Expected $name but was ${it.key}", name == it.key)
|
||||
feedback.stop()
|
||||
}.launchIn(feedback.scope)
|
||||
}
|
||||
|
||||
private fun workBlocking(duration: Long) {
|
||||
Thread.sleep(duration)
|
||||
}
|
||||
|
||||
private suspend fun workSuspending(duration: Long) {
|
||||
delay(duration)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user