diff --git a/app/src/test/java/cash/z/ecc/android/ExampleUnitTest.kt b/app/src/test/java/cash/z/ecc/android/ExampleUnitTest.kt
deleted file mode 100644
index 5b00ab6..0000000
--- a/app/src/test/java/cash/z/ecc/android/ExampleUnitTest.kt
+++ /dev/null
@@ -1,17 +0,0 @@
-package cash.z.ecc.android
-
-import org.junit.Test
-
-import org.junit.Assert.*
-
-/**
- * Example local unit test, which will execute on the development machine (host).
- *
- * See [testing documentation](http://d.android.com/tools/testing).
- */
-class ExampleUnitTest {
- @Test
- fun addition_isCorrect() {
- assertEquals(4, 2 + 2)
- }
-}
diff --git a/buildSrc/src/main/java/cash/z/ecc/android/Dependencies.kt b/buildSrc/src/main/java/cash/z/ecc/android/Dependencies.kt
index 6cb54c0..b7374f2 100644
--- a/buildSrc/src/main/java/cash/z/ecc/android/Dependencies.kt
+++ b/buildSrc/src/main/java/cash/z/ecc/android/Dependencies.kt
@@ -36,8 +36,8 @@ object Deps {
val STDLIB = "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$version"
object Coroutines : Version("1.3.2") {
val ANDROID = "org.jetbrains.kotlinx:kotlinx-coroutines-android:$version"
-
val CORE = "org.jetbrains.kotlinx:kotlinx-coroutines-core:$version"
+ val TEST = "org.jetbrains.kotlinx:kotlinx-coroutines-test:$version"
}
}
diff --git a/feedback/.gitignore b/feedback/.gitignore
new file mode 100644
index 0000000..796b96d
--- /dev/null
+++ b/feedback/.gitignore
@@ -0,0 +1 @@
+/build
diff --git a/feedback/build.gradle b/feedback/build.gradle
new file mode 100644
index 0000000..7aba54b
--- /dev/null
+++ b/feedback/build.gradle
@@ -0,0 +1,37 @@
+import cash.z.ecc.android.Deps
+
+apply plugin: 'com.android.library'
+apply plugin: 'kotlin-android'
+apply plugin: 'kotlin-android-extensions'
+
+android {
+ compileSdkVersion Deps.compileSdkVersion
+ buildToolsVersion Deps.buildToolsVersion
+
+ defaultConfig {
+ minSdkVersion Deps.minSdkVersion
+ targetSdkVersion Deps.targetSdkVersion
+ versionCode 1
+ versionName "1.0"
+
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+ consumerProguardFiles 'consumer-rules.pro'
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
+ }
+ }
+
+}
+
+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
+}
diff --git a/feedback/consumer-rules.pro b/feedback/consumer-rules.pro
new file mode 100644
index 0000000..e69de29
diff --git a/feedback/proguard-rules.pro b/feedback/proguard-rules.pro
new file mode 100644
index 0000000..f1b4245
--- /dev/null
+++ b/feedback/proguard-rules.pro
@@ -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
diff --git a/feedback/src/main/AndroidManifest.xml b/feedback/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..c0e0563
--- /dev/null
+++ b/feedback/src/main/AndroidManifest.xml
@@ -0,0 +1,3 @@
+
diff --git a/feedback/src/main/java/cash/z/ecc/android/feedback/Action.kt b/feedback/src/main/java/cash/z/ecc/android/feedback/Action.kt
new file mode 100644
index 0000000..8b872c2
--- /dev/null
+++ b/feedback/src/main/java/cash/z/ecc/android/feedback/Action.kt
@@ -0,0 +1,5 @@
+package cash.z.ecc.android.feedback
+
+interface Action {
+ val name: String
+}
\ No newline at end of file
diff --git a/feedback/src/main/java/cash/z/ecc/android/feedback/Feedback.kt b/feedback/src/main/java/cash/z/ecc/android/feedback/Feedback.kt
new file mode 100644
index 0000000..391ffe1
--- /dev/null
+++ b/feedback/src/main/java/cash/z/ecc/android/feedback/Feedback.kt
@@ -0,0 +1,143 @@
+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 kotlin.coroutines.coroutineContext
+
+class Feedback(capacity: Int = 256) {
+ lateinit var scope: CoroutineScope
+ private set
+
+ private val _metrics = BroadcastChannel(capacity)
+ private val _actions = BroadcastChannel(capacity)
+ private var onStartListener: () -> Unit = {}
+
+ private val jobs = CompositeJob()
+
+ val metrics: Flow = _metrics.asFlow()
+ val actions: Flow = _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 {
+ check(!::scope.isInitialized) {
+ "Error: cannot initialize feedback because it has already been initialized."
+ }
+ scope = CoroutineScope(Dispatchers.IO + SupervisorJob(coroutineContext[Job]))
+ scope.coroutineContext[Job]!!.invokeOnCompletion {
+ _metrics.close()
+ _actions.close()
+ }
+ onStartListener()
+ return this
+ }
+
+ /**
+ * Invokes the given callback after the scope has been initialized or immediately, if the scope
+ * has already been initialized. This is used by [FeedbackProcessor] 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 {
+ this.onStartListener = 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 measure(description: Any = "measurement", block: () -> T) {
+ ensureScope()
+ val metric = TimeMetric(description.toString()).markTime()
+ block()
+ metric.markTime()
+ report(metric)
+ }
+
+ /**
+ * Add the given metric to the stream of metric events.
+ *
+ * @param metric the metric to add.
+ */
+ fun report(metric: Metric) {
+ jobs += scope.launch {
+ _metrics.send(metric)
+ }
+ }
+
+ /**
+ * Add the given action to the stream of action events.
+ *
+ * @param action the action to add.
+ */
+ fun report(action: Action) {
+ jobs += scope.launch {
+ _actions.send(action)
+ }
+ }
+
+ /**
+ * 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()
+
+ 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(", ")}.")
+ }
+
+}
\ No newline at end of file
diff --git a/feedback/src/main/java/cash/z/ecc/android/feedback/FeedbackProcessor.kt b/feedback/src/main/java/cash/z/ecc/android/feedback/FeedbackProcessor.kt
new file mode 100644
index 0000000..3b853c0
--- /dev/null
+++ b/feedback/src/main/java/cash/z/ecc/android/feedback/FeedbackProcessor.kt
@@ -0,0 +1,83 @@
+package cash.z.ecc.android.feedback
+
+import cash.z.ecc.android.feedback.util.CompositeJob
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withContext
+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 FeedbackProcessor(
+ val feedback: Feedback,
+ val onMetricListener: (Metric) -> Unit = {},
+ val onActionListener: (Action) -> Unit = {}
+) {
+
+ init {
+ feedback.onStart {
+ initMetrics()
+ initActions()
+ }
+ }
+
+ private var contextMetrics = Dispatchers.IO
+ private var contextActions = Dispatchers.IO
+ private var jobs = CompositeJob()
+
+ /**
+ * Wait for any in-flight listeners to complete.
+ */
+ suspend fun await() {
+ jobs.await()
+ }
+
+ fun metricsOn(dispatcher: CoroutineDispatcher): FeedbackProcessor {
+ contextMetrics = dispatcher
+ return this
+ }
+
+ fun actionsOn(dispatcher: CoroutineDispatcher): FeedbackProcessor {
+ contextActions = dispatcher
+ return this
+ }
+
+ private fun initMetrics() {
+ feedback.metrics.onEach {
+ jobs += feedback.scope.launch {
+ withContext(contextMetrics) {
+ mutex.withLock {
+ onMetricListener(it)
+ }
+ }
+ }
+ }.launchIn(feedback.scope)
+ }
+
+ private fun initActions() {
+ feedback.actions.onEach {
+ val id = coroutineContext.hashCode()
+ jobs += feedback.scope.launch {
+ withContext(contextActions) {
+ mutex.withLock {
+ onActionListener(it)
+ }
+ }
+ }
+ }.launchIn(feedback.scope)
+ }
+
+ companion object {
+ private val mutex: Mutex = Mutex()
+ }
+}
diff --git a/feedback/src/main/java/cash/z/ecc/android/feedback/Metric.kt b/feedback/src/main/java/cash/z/ecc/android/feedback/Metric.kt
new file mode 100644
index 0000000..dd06e59
--- /dev/null
+++ b/feedback/src/main/java/cash/z/ecc/android/feedback/Metric.kt
@@ -0,0 +1,23 @@
+package cash.z.ecc.android.feedback
+
+interface Metric {
+ val name: String
+}
+
+data class TimeMetric(
+ override val name: String,
+ val times: MutableList = mutableListOf()
+) : Metric {
+ val startTime: Long?
+ get() = times.firstOrNull()
+
+ val endTime: Long?
+ get() = times.lastOrNull()
+
+ val elapsedTime: Long? get() = endTime?.minus(startTime ?: 0)
+
+ fun markTime(): TimeMetric {
+ times.add(System.currentTimeMillis())
+ return this
+ }
+}
diff --git a/feedback/src/main/java/cash/z/ecc/android/feedback/util/CompositeJob.kt b/feedback/src/main/java/cash/z/ecc/android/feedback/util/CompositeJob.kt
new file mode 100644
index 0000000..64cdfd5
--- /dev/null
+++ b/feedback/src/main/java/cash/z/ecc/android/feedback/util/CompositeJob.kt
@@ -0,0 +1,41 @@
+package cash.z.ecc.android.feedback.util
+
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+
+class CompositeJob {
+
+ private val jobs = mutableListOf()
+ val size: Int get() = jobs.size
+
+ fun add(job: Job) {
+ jobs.add(job)
+ job.invokeOnCompletion {
+ remove(job)
+ }
+ }
+
+ fun remove(job: Job): Boolean {
+ return jobs.remove(job)
+ }
+
+ fun isActive(): Boolean {
+ return jobs.any { isActive() }
+ }
+
+ suspend fun await() {
+ var activeJobs = jobs.filter { it.isActive }
+ while (activeJobs.isNotEmpty()) {
+ // allow for concurrent modification since the list isn't coroutine or thread safe
+ repeat(jobs.size) {
+ if (it < jobs.size) jobs[it].join()
+ }
+ delay(100)
+ activeJobs = jobs.filter { it.isActive }
+ }
+ }
+
+ operator fun plusAssign(also: Job) {
+ add(also)
+ }
+}
\ No newline at end of file
diff --git a/feedback/src/test/java/cash/z/ecc/android/feedback/CoroutineTestRule.kt b/feedback/src/test/java/cash/z/ecc/android/feedback/CoroutineTestRule.kt
new file mode 100644
index 0000000..8e53444
--- /dev/null
+++ b/feedback/src/test/java/cash/z/ecc/android/feedback/CoroutineTestRule.kt
@@ -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()
+ }
+}
\ No newline at end of file
diff --git a/feedback/src/test/java/cash/z/ecc/android/feedback/FeedbackProcessorTest.kt b/feedback/src/test/java/cash/z/ecc/android/feedback/FeedbackProcessorTest.kt
new file mode 100644
index 0000000..9bd53f5
--- /dev/null
+++ b/feedback/src/test/java/cash/z/ecc/android/feedback/FeedbackProcessorTest.kt
@@ -0,0 +1,49 @@
+package cash.z.ecc.android.feedback
+
+import kotlinx.coroutines.runBlocking
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class FeedbackProcessorTest {
+
+ private val processors = mutableListOf()
+ private var counter: Int = 0
+ private val simpleAction = object : Action {
+ override val name = "ButtonClick"
+ }
+
+ @Test
+ fun testConcurrency() = runBlocking {
+ val actionCount = 40
+ val processorCount = 40
+ val expectedTotal = actionCount * processorCount
+
+ val feedback = Feedback().start()
+ repeat(processorCount) {
+ createProcessor(feedback)
+ }
+ repeat(actionCount) {
+ sendAction(feedback)
+ }
+
+ feedback.await()
+ processors.forEach { it.await() }
+ feedback.stop()
+ assertEquals(
+ "Concurrent modification happened ${expectedTotal - counter} times",
+ expectedTotal,
+ counter
+ )
+ }
+
+ private fun createProcessor(feedback: Feedback) {
+ processors += FeedbackProcessor(feedback) {
+ counter++
+ }
+ }
+
+ private fun sendAction(feedback: Feedback) {
+ feedback.report(simpleAction)
+ }
+
+}
\ No newline at end of file
diff --git a/feedback/src/test/java/cash/z/ecc/android/feedback/FeedbackTest.kt b/feedback/src/test/java/cash/z/ecc/android/feedback/FeedbackTest.kt
new file mode 100644
index 0000000..f683775
--- /dev/null
+++ b/feedback/src/test/java/cash/z/ecc/android/feedback/FeedbackTest.kt
@@ -0,0 +1,111 @@
+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.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+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 : Action {
+ override val name: String = "ButtonClick"
+ }
+ val feedback = Feedback().start()
+ verifyAction(feedback, simpleAction.name)
+
+ feedback.report(simpleAction)
+ }
+
+ @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 { _, _ -> }
+ }
+
+ 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? 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.name}", name == it.name)
+ feedback.stop()
+ }.launchIn(feedback.scope)
+ }
+
+ private fun workBlocking(duration: Long) {
+ Thread.sleep(duration)
+ }
+
+ private suspend fun workSuspending(duration: Long) {
+ delay(duration)
+ }
+}
\ No newline at end of file
diff --git a/qrecycler/src/test/java/cash/z/android/qrecycler/ExampleUnitTest.java b/qrecycler/src/test/java/cash/z/android/qrecycler/ExampleUnitTest.java
deleted file mode 100644
index a8d3b97..0000000
--- a/qrecycler/src/test/java/cash/z/android/qrecycler/ExampleUnitTest.java
+++ /dev/null
@@ -1,17 +0,0 @@
-package cash.z.android.qrecycler;
-
-import org.junit.Test;
-
-import static org.junit.Assert.*;
-
-/**
- * Example local unit test, which will execute on the development machine (host).
- *
- * @see Testing documentation
- */
-public class ExampleUnitTest {
- @Test
- public void addition_isCorrect() {
- assertEquals(4, 2 + 2);
- }
-}
\ No newline at end of file
diff --git a/settings.gradle b/settings.gradle
index f1e57ef..972a465 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -1,2 +1,2 @@
rootProject.name='Zcash Wallet'
-include ':app', ':qrecycler'
+include ':app', ':qrecycler', ':feedback'