commit
6d12ada9a5
|
@ -74,6 +74,7 @@ android {
|
|||
dependencies {
|
||||
implementation fileTree(dir: 'libs', include: ['*.jar'])
|
||||
implementation project(':qrecycler')
|
||||
implementation project(':feedback')
|
||||
|
||||
// Kotlin
|
||||
implementation Deps.Kotlin.STDLIB
|
||||
|
@ -94,6 +95,17 @@ dependencies {
|
|||
kapt Deps.Dagger.ANDROID_PROCESSOR
|
||||
kapt Deps.Dagger.COMPILER
|
||||
|
||||
// grpc-java
|
||||
implementation "io.grpc:grpc-okhttp:1.21.0"
|
||||
implementation "io.grpc:grpc-android:1.21.0"
|
||||
implementation "io.grpc:grpc-protobuf-lite:1.21.0"
|
||||
implementation "io.grpc:grpc-stub:1.21.0"
|
||||
implementation 'javax.annotation:javax.annotation-api:1.3.2'
|
||||
// solves error: Duplicate class com.google.common.util.concurrent.ListenableFuture found in modules jetified-guava-26.0-android.jar (com.google.guava:guava:26.0-android) and listenablefuture-1.0.jar (com.google.guava:listenablefuture:1.0)
|
||||
// per this recommendation from Chris Povirk, given guava's decision to split ListenableFuture away from Guava: https://groups.google.com/d/msg/guava-discuss/GghaKwusjcY/bCIAKfzOEwAJ
|
||||
implementation 'com.google.guava:guava:27.0.1-android'
|
||||
|
||||
implementation 'com.mixpanel.android:mixpanel-android:5.6.3'
|
||||
|
||||
// Tests
|
||||
|
||||
|
|
|
@ -1,23 +1,6 @@
|
|||
# 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 *;
|
||||
#}
|
||||
|
||||
-dontobfuscate
|
||||
|
||||
# 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
|
||||
## Okio
|
||||
# Animal Sniffer compileOnly dependency to ensure APIs are compatible with older versions of Java
|
||||
-dontwarn org.codehaus.mojo.animal_sniffer.*
|
|
@ -18,5 +18,15 @@
|
|||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Mixpanel options -->
|
||||
<meta-data android:name="com.mixpanel.android.MPConfig.AutoShowMixpanelUpdates" android:value="false" />
|
||||
<meta-data android:name="com.mixpanel.android.MPConfig.EnableDebugLogging" android:value="false" />
|
||||
<meta-data android:name="com.mixpanel.android.MPConfig.DisableDecideChecker" android:value="true" />
|
||||
<meta-data android:name="com.mixpanel.android.MPConfig.DisableEmulatorBindingUI" android:value="true" />
|
||||
<meta-data android:name="com.mixpanel.android.MPConfig.DisableGestureBindingUI" android:value="true" />
|
||||
<meta-data android:name="com.mixpanel.android.MPConfig.DisableViewCrawler" android:value="true" />
|
||||
<meta-data android:name="com.mixpanel.android.MPConfig.IgnoreInvisibleViewsVisualEditor" android:value="true" />
|
||||
|
||||
</application>
|
||||
</manifest>
|
||||
|
|
|
@ -3,19 +3,34 @@ package cash.z.ecc.android
|
|||
import android.content.Context
|
||||
import android.os.Build
|
||||
import cash.z.ecc.android.di.DaggerAppComponent
|
||||
import cash.z.ecc.android.feedback.FeedbackCoordinator
|
||||
import dagger.android.AndroidInjector
|
||||
import dagger.android.DaggerApplication
|
||||
import javax.inject.Inject
|
||||
|
||||
|
||||
class ZcashWalletApp : DaggerApplication() {
|
||||
|
||||
@Inject
|
||||
lateinit var feedbackCoordinator: FeedbackCoordinator
|
||||
|
||||
@Inject
|
||||
lateinit var feedbackObservers: Set<@JvmSuppressWildcards FeedbackCoordinator.FeedbackObserver>
|
||||
|
||||
var creationTime: Long = 0
|
||||
private set
|
||||
|
||||
var creationMeasured: Boolean = false
|
||||
|
||||
override fun onCreate() {
|
||||
creationTime = System.currentTimeMillis()
|
||||
instance = this
|
||||
// Setup handler for uncaught exceptions.
|
||||
super.onCreate()
|
||||
|
||||
Thread.setDefaultUncaughtExceptionHandler(ExceptionReporter(Thread.getDefaultUncaughtExceptionHandler()))
|
||||
// Twig.plant(TroubleshootingTwig())
|
||||
feedbackObservers.forEach { feedbackCoordinator.addObserver(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -0,0 +1,36 @@
|
|||
package cash.z.ecc.android.di
|
||||
|
||||
import cash.z.ecc.android.feedback.FeedbackConsole
|
||||
import cash.z.ecc.android.feedback.FeedbackCoordinator
|
||||
import cash.z.ecc.android.feedback.FeedbackFile
|
||||
import cash.z.ecc.android.feedback.FeedbackMixpanel
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.multibindings.IntoSet
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
abstract class AppBindingModule {
|
||||
|
||||
//
|
||||
// Feedback Observer Set
|
||||
//
|
||||
|
||||
@Singleton
|
||||
@Binds
|
||||
@IntoSet
|
||||
abstract fun provideFeedbackFile(implementation: FeedbackFile)
|
||||
: FeedbackCoordinator.FeedbackObserver
|
||||
|
||||
@Singleton
|
||||
@Binds
|
||||
@IntoSet
|
||||
abstract fun provideFeedbackConsole(implementation: FeedbackConsole)
|
||||
: FeedbackCoordinator.FeedbackObserver
|
||||
|
||||
@Singleton
|
||||
@Binds
|
||||
@IntoSet
|
||||
abstract fun provideFeedbackMixpanel(implementation: FeedbackMixpanel)
|
||||
: FeedbackCoordinator.FeedbackObserver
|
||||
}
|
|
@ -18,6 +18,8 @@ import javax.inject.Singleton
|
|||
modules = [
|
||||
AndroidSupportInjectionModule::class,
|
||||
|
||||
AppModule::class,
|
||||
|
||||
// Activities
|
||||
MainActivityModule::class,
|
||||
|
||||
|
|
|
@ -0,0 +1,20 @@
|
|||
package cash.z.ecc.android.di
|
||||
|
||||
import cash.z.ecc.android.feedback.*
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.multibindings.IntoSet
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module(includes = [AppBindingModule::class])
|
||||
class AppModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideFeedback() = Feedback()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideFeedbackCoordinator(feedback: Feedback) = FeedbackCoordinator(feedback)
|
||||
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
package cash.z.ecc.android.ext
|
||||
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.annotation.ColorRes
|
||||
import androidx.annotation.IntegerRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.core.content.res.ResourcesCompat
|
||||
import cash.z.ecc.android.ZcashWalletApp
|
||||
|
||||
/**
|
||||
* Grab a color out of the application resources, using the default theme
|
||||
*/
|
||||
@ColorInt
|
||||
internal inline fun @receiver:ColorRes Int.toAppColor(): Int {
|
||||
return ResourcesCompat.getColor(ZcashWalletApp.instance.resources, this, ZcashWalletApp.instance.theme)
|
||||
}
|
||||
|
||||
/**
|
||||
* Grab a string from the application resources
|
||||
*/
|
||||
internal inline fun @receiver:StringRes Int.toAppString(): String {
|
||||
return ZcashWalletApp.instance.getString(this)}
|
||||
|
||||
|
||||
/**
|
||||
* Grab an integer from the application resources
|
||||
*/
|
||||
internal inline fun @receiver:IntegerRes Int.toAppInt(): Int {
|
||||
return ZcashWalletApp.instance.resources.getInteger(this)}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
package cash.z.ecc.android.feedback
|
||||
|
||||
import android.util.Log
|
||||
|
||||
class FeedbackConsole : FeedbackCoordinator.FeedbackObserver {
|
||||
|
||||
override fun onMetric(metric: Feedback.Metric) {
|
||||
log(metric.toString())
|
||||
}
|
||||
|
||||
override fun onAction(action: Feedback.Action) {
|
||||
log(action.toString())
|
||||
}
|
||||
|
||||
override fun flush() {
|
||||
// TODO: flush logs (once we have the real logging in place)
|
||||
}
|
||||
|
||||
private fun log(message: String) {
|
||||
Log.d("@TWIG", message)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
package cash.z.ecc.android.feedback
|
||||
|
||||
import cash.z.ecc.android.ZcashWalletApp
|
||||
import okio.Okio
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
|
||||
class FeedbackFile(fileName: String = "feedback.log") :
|
||||
FeedbackCoordinator.FeedbackObserver {
|
||||
|
||||
private val file = File(ZcashWalletApp.instance.noBackupFilesDir, fileName)
|
||||
private val format = SimpleDateFormat("MM-dd HH:mm:ss.SSS")
|
||||
|
||||
|
||||
override fun onMetric(metric: Feedback.Metric) {
|
||||
appendToFile(metric.toString())
|
||||
}
|
||||
|
||||
override fun onAction(action: Feedback.Action) {
|
||||
appendToFile(action.toString())
|
||||
}
|
||||
|
||||
override fun flush() {
|
||||
// TODO: be more sophisticated about how we open/close the file. And then flush it here.
|
||||
}
|
||||
|
||||
private fun appendToFile(message: String) {
|
||||
Okio.buffer(Okio.appendingSink(file)).use {
|
||||
it.writeUtf8("${format.format(System.currentTimeMillis())}|\t$message\n")
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
package cash.z.ecc.android.feedback
|
||||
|
||||
import cash.z.ecc.android.R
|
||||
import cash.z.ecc.android.ZcashWalletApp
|
||||
import cash.z.ecc.android.ext.toAppString
|
||||
import com.mixpanel.android.mpmetrics.MixpanelAPI
|
||||
|
||||
class FeedbackMixpanel : FeedbackCoordinator.FeedbackObserver {
|
||||
|
||||
private val mixpanel =
|
||||
MixpanelAPI.getInstance(ZcashWalletApp.instance, R.string.mixpanel_project.toAppString())
|
||||
|
||||
override fun onMetric(metric: Feedback.Metric) {
|
||||
track(metric.key, metric.toMap())
|
||||
}
|
||||
|
||||
override fun onAction(action: Feedback.Action) {
|
||||
track(action.key, action.toMap())
|
||||
}
|
||||
|
||||
override fun flush() {
|
||||
mixpanel.flush()
|
||||
}
|
||||
|
||||
private fun track(eventName: String, properties: Map<String, Any>) {
|
||||
mixpanel.trackMap(eventName, properties)
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
package cash.z.ecc.android.feedback
|
||||
|
||||
import cash.z.ecc.android.ZcashWalletApp
|
||||
|
||||
enum class NonUserAction(override val key: String, val description: String) : Feedback.Action {
|
||||
FEEDBACK_STARTED("action.feedback.start", "feedback started"),
|
||||
FEEDBACK_STOPPED("action.feedback.stop", "feedback stopped");
|
||||
|
||||
override fun toString(): String = description
|
||||
}
|
||||
|
||||
class LaunchMetric private constructor(private val metric: Feedback.TimeMetric) :
|
||||
Feedback.Metric by metric {
|
||||
constructor() : this(
|
||||
Feedback
|
||||
.TimeMetric("metric.app.launch", mutableListOf(ZcashWalletApp.instance.creationTime))
|
||||
.markTime()
|
||||
)
|
||||
override fun toString(): String {
|
||||
return "app launched in ${metric.elapsedTime}ms"
|
||||
}
|
||||
}
|
|
@ -6,6 +6,8 @@ import android.content.Context
|
|||
import android.graphics.Color
|
||||
import android.media.MediaPlayer
|
||||
import android.os.Bundle
|
||||
import android.os.FileObserver
|
||||
import android.os.SystemClock
|
||||
import android.os.Vibrator
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
|
@ -13,17 +15,30 @@ import android.view.WindowManager
|
|||
import android.view.inputmethod.InputMethodManager
|
||||
import android.widget.Toast
|
||||
import androidx.core.content.getSystemService
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.findNavController
|
||||
import cash.z.ecc.android.R
|
||||
import cash.z.ecc.android.ZcashWalletApp
|
||||
import cash.z.ecc.android.di.annotation.ActivityScope
|
||||
import cash.z.ecc.android.feedback.*
|
||||
import cash.z.ecc.android.feedback.NonUserAction.FEEDBACK_STARTED
|
||||
import cash.z.ecc.android.feedback.NonUserAction.FEEDBACK_STOPPED
|
||||
import dagger.Module
|
||||
import dagger.android.ContributesAndroidInjector
|
||||
import dagger.android.support.DaggerAppCompatActivity
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
|
||||
class MainActivity : DaggerAppCompatActivity() {
|
||||
|
||||
@Inject
|
||||
lateinit var feedback: Feedback
|
||||
|
||||
lateinit var navController: NavController
|
||||
|
||||
private val mediaPlayer: MediaPlayer = MediaPlayer()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
|
@ -40,6 +55,28 @@ class MainActivity : DaggerAppCompatActivity() {
|
|||
WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS,
|
||||
false
|
||||
)// | WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION, false)
|
||||
|
||||
lifecycleScope.launch { feedback.start() }
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
// keep track of app launch metrics
|
||||
// (how long does it take the app to open when it is not already in the foreground)
|
||||
ZcashWalletApp.instance.let { app ->
|
||||
if (!app.creationMeasured) {
|
||||
app.creationMeasured = true
|
||||
feedback.report(LaunchMetric())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
lifecycleScope.launch {
|
||||
feedback.report(FEEDBACK_STOPPED)
|
||||
feedback.stop()
|
||||
}
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun setWindowFlag(bits: Int, on: Boolean) {
|
||||
|
|
|
@ -1,4 +1,7 @@
|
|||
<resources>
|
||||
<string name="app_name">Zcash Wallet</string>
|
||||
<string name="receive_address_title">Your Shielded Address</string>
|
||||
|
||||
|
||||
<string name="mixpanel_project">a178e1ef062133fc121079cb12fa43c7</string>
|
||||
</resources>
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
package cash.z.ecc.android.feedback
|
||||
|
||||
interface Action {
|
||||
val name: String
|
||||
}
|
|
@ -13,7 +13,7 @@ class Feedback(capacity: Int = 256) {
|
|||
|
||||
private val _metrics = BroadcastChannel<Metric>(capacity)
|
||||
private val _actions = BroadcastChannel<Action>(capacity)
|
||||
private var onStartListener: () -> Unit = {}
|
||||
private var onStartListeners: MutableList<() -> Unit> = mutableListOf()
|
||||
|
||||
private val jobs = CompositeJob()
|
||||
|
||||
|
@ -36,17 +36,23 @@ class Feedback(capacity: Int = 256) {
|
|||
"Error: cannot initialize feedback because it has already been initialized."
|
||||
}
|
||||
scope = CoroutineScope(Dispatchers.IO + SupervisorJob(coroutineContext[Job]))
|
||||
scope.coroutineContext[Job]!!.invokeOnCompletion {
|
||||
invokeOnCompletion {
|
||||
_metrics.close()
|
||||
_actions.close()
|
||||
}
|
||||
onStartListener()
|
||||
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 [FeedbackProcessor] and things like it that
|
||||
* 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.
|
||||
*/
|
||||
|
@ -54,7 +60,7 @@ class Feedback(capacity: Int = 256) {
|
|||
if (::scope.isInitialized) {
|
||||
onStartListener()
|
||||
} else {
|
||||
this.onStartListener = onStartListener
|
||||
onStartListeners.add(onStartListener)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -140,4 +146,43 @@ class Feedback(capacity: Int = 256) {
|
|||
throw IllegalStateException("Feedback is still active because ${errors.joinToString(", ")}.")
|
||||
}
|
||||
|
||||
interface Metric : Mappable<String, Any> {
|
||||
val key: String
|
||||
val startTime: Long?
|
||||
val endTime: Long?
|
||||
val elapsedTime: Long?
|
||||
|
||||
override fun toMap(): Map<String, Any> {
|
||||
return mapOf(
|
||||
"key" to key,
|
||||
"startTime" to (startTime ?: 0),
|
||||
"endTime" to (endTime ?: 0),
|
||||
"elapsedTime" to (elapsedTime ?: 0)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
interface Action : Feedback.Mappable<String, Any> {
|
||||
val key: String
|
||||
override fun toMap(): Map<String, Any> {
|
||||
return mapOf("key" to key)
|
||||
}
|
||||
}
|
||||
|
||||
interface Mappable<K, V> {
|
||||
fun toMap(): Map<K, V>
|
||||
}
|
||||
|
||||
data class TimeMetric(
|
||||
override val key: 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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,14 +1,11 @@
|
|||
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.*
|
||||
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
|
||||
|
||||
/**
|
||||
|
@ -18,41 +15,75 @@ import kotlin.coroutines.coroutineContext
|
|||
* 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 = {}
|
||||
) {
|
||||
class FeedbackCoordinator(val feedback: Feedback) {
|
||||
|
||||
init {
|
||||
feedback.onStart {
|
||||
initMetrics()
|
||||
initActions()
|
||||
feedback.apply {
|
||||
onStart {
|
||||
invokeOnCompletion {
|
||||
flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var contextMetrics = Dispatchers.IO
|
||||
private var contextActions = Dispatchers.IO
|
||||
private var jobs = CompositeJob()
|
||||
private val jobs = CompositeJob()
|
||||
private val observers = mutableSetOf<FeedbackObserver>()
|
||||
|
||||
/**
|
||||
* Wait for any in-flight listeners to complete.
|
||||
*/
|
||||
suspend fun await() {
|
||||
jobs.await()
|
||||
flush()
|
||||
}
|
||||
|
||||
fun metricsOn(dispatcher: CoroutineDispatcher): FeedbackProcessor {
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
fun actionsOn(dispatcher: CoroutineDispatcher): FeedbackProcessor {
|
||||
/**
|
||||
* Inject the context on which to observe actions, mostly for testing purposes.
|
||||
*/
|
||||
fun actionsOn(dispatcher: CoroutineDispatcher): FeedbackCoordinator {
|
||||
contextActions = dispatcher
|
||||
return this
|
||||
}
|
||||
|
||||
private fun initMetrics() {
|
||||
/**
|
||||
* 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
|
||||
observeMetrics(observer::onMetric)
|
||||
observeActions(observer::onAction)
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeMetrics(onMetricListener: (Feedback.Metric) -> Unit) {
|
||||
feedback.metrics.onEach {
|
||||
jobs += feedback.scope.launch {
|
||||
withContext(contextMetrics) {
|
||||
|
@ -64,7 +95,7 @@ class FeedbackProcessor(
|
|||
}.launchIn(feedback.scope)
|
||||
}
|
||||
|
||||
private fun initActions() {
|
||||
private fun observeActions(onActionListener: (Feedback.Action) -> Unit) {
|
||||
feedback.actions.onEach {
|
||||
val id = coroutineContext.hashCode()
|
||||
jobs += feedback.scope.launch {
|
||||
|
@ -77,6 +108,12 @@ class FeedbackProcessor(
|
|||
}.launchIn(feedback.scope)
|
||||
}
|
||||
|
||||
interface FeedbackObserver {
|
||||
fun onMetric(metric: Feedback.Metric) {}
|
||||
fun onAction(action: Feedback.Action) {}
|
||||
fun flush() {}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val mutex: Mutex = Mutex()
|
||||
}
|
|
@ -1,23 +0,0 @@
|
|||
package cash.z.ecc.android.feedback
|
||||
|
||||
interface Metric {
|
||||
val name: String
|
||||
}
|
||||
|
||||
data class TimeMetric(
|
||||
override val name: String,
|
||||
val times: MutableList<Long> = 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
|
||||
}
|
||||
}
|
|
@ -1,7 +1,6 @@
|
|||
package cash.z.ecc.android.feedback.util
|
||||
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
class CompositeJob {
|
||||
|
||||
|
@ -24,15 +23,20 @@ class CompositeJob {
|
|||
}
|
||||
|
||||
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()
|
||||
// allow for concurrent modification since the list isn't coroutine or thread safe
|
||||
do {
|
||||
val job = jobs.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 { jobs.remove(job) } catch (t: Throwable) {}
|
||||
}
|
||||
delay(100)
|
||||
activeJobs = jobs.filter { it.isActive }
|
||||
}
|
||||
} while (size > 0)
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
jobs.filter { isActive() }.forEach { it.cancel() }
|
||||
}
|
||||
|
||||
operator fun plusAssign(also: Job) {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -1,49 +0,0 @@
|
|||
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<FeedbackProcessor>()
|
||||
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)
|
||||
}
|
||||
|
||||
}
|
|
@ -36,11 +36,11 @@ class FeedbackTest {
|
|||
|
||||
@Test
|
||||
fun testTrack() = runBlocking {
|
||||
val simpleAction = object : Action {
|
||||
override val name: String = "ButtonClick"
|
||||
val simpleAction = object : Feedback.Action {
|
||||
override val key = "ButtonClick"
|
||||
}
|
||||
val feedback = Feedback().start()
|
||||
verifyAction(feedback, simpleAction.name)
|
||||
verifyAction(feedback, simpleAction.key)
|
||||
|
||||
feedback.report(simpleAction)
|
||||
}
|
||||
|
@ -85,7 +85,7 @@ class FeedbackTest {
|
|||
|
||||
private fun verifyDuration(feedback: Feedback, duration: Long) {
|
||||
feedback.metrics.onEach {
|
||||
val metric = (it as? TimeMetric)?.elapsedTime
|
||||
val metric = (it as? Feedback.TimeMetric)?.elapsedTime
|
||||
assertTrue(
|
||||
"Measured time did not match duration. Expected $duration but was $metric",
|
||||
metric ?: 0 >= duration
|
||||
|
@ -96,7 +96,7 @@ class FeedbackTest {
|
|||
|
||||
private fun verifyAction(feedback: Feedback, name: String) {
|
||||
feedback.actions.onEach {
|
||||
assertTrue("Action did not match. Expected $name but was ${it.name}", name == it.name)
|
||||
assertTrue("Action did not match. Expected $name but was ${it.key}", name == it.key)
|
||||
feedback.stop()
|
||||
}.launchIn(feedback.scope)
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue