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'