Add support for writing feedback to the console, a file or mixpanel.

The app currently does all three. Closes #28. Closes #29.
This commit is contained in:
Kevin Gorham 2019-12-14 14:39:19 -05:00
parent 00813ba05f
commit 1af99c0263
No known key found for this signature in database
GPG Key ID: CCA55602DF49FC38
22 changed files with 449 additions and 133 deletions

View File

@ -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

View File

@ -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.*

View File

@ -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>

View File

@ -9,7 +9,13 @@ import dagger.android.DaggerApplication
class ZcashWalletApp : DaggerApplication() {
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()

View File

@ -0,0 +1,9 @@
package cash.z.ecc.android.di
import dagger.Binds
import dagger.Module
@Module
abstract class AppBindingModule {
}

View File

@ -18,6 +18,8 @@ import javax.inject.Singleton
modules = [
AndroidSupportInjectionModule::class,
AppModule::class,
// Activities
MainActivityModule::class,

View File

@ -0,0 +1,42 @@
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)
//
// Feedback Observer Set
//
@Provides
@Singleton
@IntoSet
fun provideFeedbackFile(feedbackCoordinator: FeedbackCoordinator)
: FeedbackCoordinator.FeedbackObserver = FeedbackFile(feedbackCoordinator)
@Provides
@Singleton
@IntoSet
fun provideFeedbackConsole(feedbackCoordinator: FeedbackCoordinator)
: FeedbackCoordinator.FeedbackObserver = FeedbackConsole(feedbackCoordinator)
@Provides
@Singleton
@IntoSet
fun provideFeedbackMixpanel(feedbackCoordinator: FeedbackCoordinator)
: FeedbackCoordinator.FeedbackObserver = FeedbackMixpanel(feedbackCoordinator)
}

View File

@ -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)}

View File

@ -0,0 +1,26 @@
package cash.z.ecc.android.feedback
import android.util.Log
class FeedbackConsole(coordinator: FeedbackCoordinator) : FeedbackCoordinator.FeedbackObserver {
init {
coordinator.addObserver(this)
}
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)
}
}

View File

@ -0,0 +1,36 @@
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(coordinator: FeedbackCoordinator, fileName: String = "feedback.log") :
FeedbackCoordinator.FeedbackObserver {
init {
coordinator.addObserver(this)
}
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")
}
}
}

View File

@ -0,0 +1,33 @@
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(coordinator: FeedbackCoordinator) : FeedbackCoordinator.FeedbackObserver {
init {
coordinator.addObserver(this)
}
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)
}
}

View File

@ -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"
}
}

View File

@ -6,6 +6,7 @@ import android.content.Context
import android.graphics.Color
import android.media.MediaPlayer
import android.os.Bundle
import android.os.SystemClock
import android.os.Vibrator
import android.util.Log
import android.view.View
@ -13,16 +14,31 @@ 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
@Inject
lateinit var observers: Set<@JvmSuppressWildcards FeedbackCoordinator.FeedbackObserver>
lateinit var navController: NavController
private val mediaPlayer: MediaPlayer = MediaPlayer()
@ -40,6 +56,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) {

View File

@ -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>

View File

@ -1,5 +0,0 @@
package cash.z.ecc.android.feedback
interface Action {
val name: String
}

View File

@ -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
}
}
}

View File

@ -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 = mutableListOf<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()
}

View File

@ -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
}
}

View File

@ -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) {

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}