diff --git a/.idea/compiler.xml b/.idea/compiler.xml
index 61a9130..fb7f4a8 100644
--- a/.idea/compiler.xml
+++ b/.idea/compiler.xml
@@ -1,6 +1,6 @@
-
+
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
index d5d35ec..860da66 100644
--- a/.idea/misc.xml
+++ b/.idea/misc.xml
@@ -1,6 +1,6 @@
-
+
diff --git a/app/build.gradle b/app/build.gradle
index e35bae3..7fb2e97 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -1,7 +1,6 @@
plugins {
id 'com.android.application'
id 'kotlin-android'
- id 'org.jetbrains.kotlin.kapt'
}
android {
@@ -13,7 +12,7 @@ android {
minSdkVersion 23
targetSdkVersion 30
versionCode 1
- versionName "1.0"
+ versionName "0.3"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
@@ -30,7 +29,6 @@ android {
}
kotlinOptions {
jvmTarget = '1.8'
- freeCompilerArgs = ['-Xjvm-default=enable']
}
lintOptions {
abortOnError false
@@ -43,19 +41,16 @@ dependencies {
implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version"
implementation "io.reactivex.rxjava2:rxandroid:$rxandroid_version"
implementation "io.reactivex.rxjava2:rxkotlin:$rxkotlin_version"
- implementation 'androidx.core:core-ktx:1.5.0'
+ implementation 'androidx.core:core-ktx:1.6.0'
implementation 'androidx.appcompat:appcompat:1.3.0'
- implementation 'com.google.android.material:material:1.3.0'
+ implementation 'com.google.android.material:material:1.4.0'
implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
- implementation "ch.acra:acra-core:$acra_version"
- implementation("ch.acra:acra-toast:$acra_version")
- implementation("ch.acra:acra-dialog:$acra_version")
-
- compileOnly("com.google.auto.service:auto-service-annotations:1.0")
- kapt("com.google.auto.service:auto-service:1.0")
+ implementation 'cat.ereza:customactivityoncrash:2.3.0'
testImplementation 'junit:junit:4.13.2'
- androidTestImplementation 'androidx.test.ext:junit:1.1.2'
- androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0'
+ testImplementation 'org.mockito:mockito-core:3.11.2'
+ testImplementation "org.robolectric:robolectric:4.5.1"
+ androidTestImplementation 'androidx.test.ext:junit:1.1.3'
+ androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 91d2805..e8adf14 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -20,6 +20,10 @@
android:enabled="true"
android:exported="false" />
+
+
@@ -35,7 +39,7 @@
android:excludeFromRecents="true"
android:finishOnTaskLaunch="true"
android:launchMode="singleInstance"
- android:process=":acra" />
+ android:process=":reporting" />
diff --git a/app/src/main/java/ru/n1ks/f1dashboard/DashboardApplication.kt b/app/src/main/java/ru/n1ks/f1dashboard/DashboardApplication.kt
index ff0a2bd..89f3931 100644
--- a/app/src/main/java/ru/n1ks/f1dashboard/DashboardApplication.kt
+++ b/app/src/main/java/ru/n1ks/f1dashboard/DashboardApplication.kt
@@ -1,26 +1,13 @@
package ru.n1ks.f1dashboard
import android.app.Application
-import android.content.Context
-import org.acra.config.dialog
-import org.acra.data.StringFormat
-import org.acra.ktx.initAcra
-import ru.n1ks.f1dashboard.reporting.CrashReportActivity
+import ru.n1ks.f1dashboard.reporting.initReporting
class DashboardApplication : Application() {
- override fun attachBaseContext(base: Context?) {
- super.attachBaseContext(base)
+ override fun onCreate() {
+ super.onCreate()
- initAcra {
- //core configuration:
- buildConfigClass = BuildConfig::class.java
- reportFormat = StringFormat.JSON
-
- dialog {
- //allows other customization
- reportDialogClass = CrashReportActivity::class.java
- }
- }
+ initReporting(this)
}
}
\ No newline at end of file
diff --git a/app/src/main/java/ru/n1ks/f1dashboard/ListenerService.kt b/app/src/main/java/ru/n1ks/f1dashboard/ListenerService.kt
index d5d2dac..a116806 100644
--- a/app/src/main/java/ru/n1ks/f1dashboard/ListenerService.kt
+++ b/app/src/main/java/ru/n1ks/f1dashboard/ListenerService.kt
@@ -3,85 +3,81 @@ package ru.n1ks.f1dashboard
import android.app.*
import android.content.Context
import android.content.Intent
-import android.content.ServiceConnection
-import android.os.IBinder
import android.util.Log
import io.reactivex.BackpressureStrategy
import io.reactivex.Flowable
import io.reactivex.schedulers.Schedulers
-import ru.n1ks.f1dashboard.model.TelemetryData
-import ru.n1ks.f1dashboard.model.TelemetryPacket
-import ru.n1ks.f1dashboard.model.TelemetryPacketDeserializer
+import ru.n1ks.f1dashboard.Properties.Companion.loadProperties
+import ru.n1ks.f1dashboard.capture.LiveCaptureWorker
+import ru.n1ks.f1dashboard.capture.Recorder
+import ru.n1ks.f1dashboard.reporting.PacketTail
+import java.io.File
import java.net.*
-import java.nio.ByteBuffer
import java.util.*
import java.util.concurrent.atomic.AtomicLong
-
-class ListenerService : Service() {
+class ListenerService : TelemetryProviderService(), Recorder {
companion object {
-
- private const val TAG = "ListenerService"
-
private const val UDPBufferLength = 2048
private const val DroppedReportInterval = 10000
-
- fun bindService(context: Context, properties: Properties, connection: ServiceConnection) {
- val intent = Intent(context, ListenerService::class.java)
- intent.apply {
- putExtra(Properties.Port, properties.port)
- }
- context.bindService(intent, connection, Context.BIND_AUTO_CREATE)
- }
-
- fun unbindService(context: Context, connection: ServiceConnection) =
- context.unbindService(connection)
}
- inner class Binder : android.os.Binder() {
-
- fun flow() = this@ListenerService.messageFlow!!
- }
-
- private val binder = Binder()
private var socket: DatagramSocket? = null
- private var messageFlow: Flowable>? = null
+ private var messageFlow: Flowable? = null
- override fun onBind(intent: Intent): IBinder {
- Log.d(TAG, "start")
- val port = intent.getIntExtra(Properties.Port, -1)
+ @Volatile
+ private var liveCaptureWorker: LiveCaptureWorker? = null
+ override fun start(intent: Intent) {
+ val port = getSharedPreferences(Properties.Name, Context.MODE_PRIVATE).loadProperties().port
initServer(port)
-
- return binder
}
- override fun onUnbind(intent: Intent?): Boolean {
- Log.d(TAG, "stopping")
-
+ override fun stop() {
closeSocket()
-
+
+ synchronized(this) {
+ if (liveCaptureWorker != null) {
+ stopRecording()
+ }
+ }
+
socket = null
messageFlow = null
-
- return false
}
- override fun onDestroy() {
- Log.d(TAG, "destroy")
- super.onDestroy()
+ override fun startRecording() {
+ synchronized(this) {
+ if (liveCaptureWorker != null) {
+ startRecording()
+ }
+ val captureFile = File(this.filesDir, Recorder.LastestCaptureFilename)
+ liveCaptureWorker = LiveCaptureWorker(captureFile)
+ Log.d(TAG, "start capturing to file: " + captureFile.absolutePath)
+ }
+ }
- closeSocket()
- socket = null
- messageFlow = null
+ override fun stopRecording(): Long {
+ synchronized(this) {
+ val frameCount = liveCaptureWorker?.let {
+ Log.d(TAG, "stop capturing")
+ val frameCount = it.frameCount
+ it.close()
+ frameCount
+ } ?: 0
+ liveCaptureWorker = null
+ return frameCount
+ }
}
+ override fun flow(): Flowable = messageFlow ?: throw IllegalStateException("service not initialized")
+
private fun initServer(port: Int) {
socket = DatagramSocket(port)
val droppedLastTimestamp = AtomicLong()
val droppedCounter = AtomicLong()
- messageFlow = Flowable.create(
+ messageFlow = Flowable.create(
{
droppedLastTimestamp.set(System.currentTimeMillis())
while (!it.isCancelled) {
@@ -96,7 +92,8 @@ class ListenerService : Service() {
it.onError(e)
break
}
- it.onNext(packet)
+ packet.data
+ it.onNext(packet.data.copyOf(packet.length))
}
it.onComplete()
},
@@ -108,7 +105,10 @@ class ListenerService : Service() {
synchronized(droppedLastTimestamp) {
val period = System.currentTimeMillis() - droppedLastTimestamp.get()
if (period > DroppedReportInterval) {
- Log.i(TAG, "dropped ${droppedCounter.incrementAndGet()} in last $period ms")
+ Log.i(
+ TAG,
+ "dropped ${droppedCounter.incrementAndGet()} in last $period ms"
+ )
droppedLastTimestamp.set(System.currentTimeMillis())
droppedCounter.set(0)
return@onBackpressureDrop
@@ -117,8 +117,17 @@ class ListenerService : Service() {
}
droppedCounter.incrementAndGet()
}
- .doOnTerminate { closeSocket() }
- .map { TelemetryPacketDeserializer.map(ByteBuffer.wrap(it.data)) }
+ .doFinally { closeSocket() }
+ .doOnNext {
+ PacketTail.onPacket(it)
+ if (liveCaptureWorker != null) {
+ synchronized(this) {
+ if (liveCaptureWorker != null) {
+ liveCaptureWorker!!.onPacket(it)
+ }
+ }
+ }
+ }
}
private fun closeSocket() {
diff --git a/app/src/main/java/ru/n1ks/f1dashboard/MainActivity.kt b/app/src/main/java/ru/n1ks/f1dashboard/MainActivity.kt
index e4fd112..f45f8d3 100644
--- a/app/src/main/java/ru/n1ks/f1dashboard/MainActivity.kt
+++ b/app/src/main/java/ru/n1ks/f1dashboard/MainActivity.kt
@@ -3,7 +3,7 @@ package ru.n1ks.f1dashboard
import android.annotation.SuppressLint
import android.content.ComponentName
import android.content.Context
-import android.content.ServiceConnection
+import android.net.Uri
import android.net.wifi.WifiManager
import android.os.Bundle
import android.os.IBinder
@@ -11,48 +11,122 @@ import android.text.format.Formatter
import android.util.Log
import android.view.View
import android.view.WindowManager
+import android.widget.TextView
+import android.widget.Toast
+import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
+import androidx.core.content.ContextCompat
+import io.reactivex.Single
import io.reactivex.android.schedulers.AndroidSchedulers
-import io.reactivex.rxkotlin.toSingle
+import io.reactivex.disposables.Disposable
import ru.n1ks.f1dashboard.Properties.Companion.loadProperties
+import ru.n1ks.f1dashboard.capture.Recorder
import ru.n1ks.f1dashboard.livedata.LiveData
import ru.n1ks.f1dashboard.livedata.LiveDataFields
+import ru.n1ks.f1dashboard.model.TelemetryPacketDeserializer
+import java.io.File
+import java.io.FileInputStream
+import java.text.SimpleDateFormat
+import java.util.*
import java.util.concurrent.TimeUnit
class MainActivity : AppCompatActivity() {
companion object {
+ private const val TAG = "MainActivity"
+ }
- const val TAG = "MainActivity"
+ private enum class State {
+ None, ListenOnly, ListenRecording, ReplayCapture, ReplayCrashReport
}
- private lateinit var serviceConnection: ServiceConnection
- private val properties: Lazy = lazy { getSharedPreferences(Properties.Name, Context.MODE_PRIVATE).loadProperties() }
+ private lateinit var debugFrameCountTextView: TextView
+
+ private lateinit var serviceConnection: TelemetryProviderService.Connection
+
+ private lateinit var liveData: LiveData
+
+ private var state = State.None
+
+ private lateinit var currentTimeTimer: Timer
+
+ private val saveCaptureFile =
+ registerForActivityResult(ActivityResultContracts.CreateDocument()) {
+ if (it == null) {
+ AlertDialog.Builder(this)
+ .setMessage(getString(R.string.dialog_capture_save_cancel_confirm))
+ .setNegativeButton(getString(R.string.dialog_capture_save_cancel_yes)) { _, _ -> moveCaptureFile(null) }
+ .setPositiveButton(getString(R.string.dialog_capture_save_cancel_no)) { _, _ -> captureSaveDialog() }
+ .setCancelable(false)
+ .create().show()
+ } else {
+ moveCaptureFile(it)
+ }
+ }
+ private val openCaptureFile = registerForActivityResult(ActivityResultContracts.GetContent()) {
+ if (it != null) replayFromCapture(it)
+ }
+ private val openReportFile = registerForActivityResult(ActivityResultContracts.GetContent()) {
+ if (it != null) replayFromCrashReport(it)
+ }
- @SuppressLint("CheckResult")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
- findViewById(R.id.debugFrameCount).setOnClickListener { showEndpoint() }
+ currentTimeTimer = Timer("Clock",true)
+ val dateTimeFormat = SimpleDateFormat("HH:mm", Locale.getDefault())
+ val systemTimeTextView = findViewById(R.id.systemTimeValue)
+ currentTimeTimer.scheduleAtFixedRate(object : TimerTask() {
+
+ override fun run() {
+ val currentDate = dateTimeFormat.format(Date())
+ runOnUiThread { systemTimeTextView.text = currentDate }
+ }
+ }, 0, 1000)
+
+ findViewById(R.id.drsCaption).setOnLongClickListener { recreate(); true }
+ findViewById(R.id.sessionTimeValue).setOnClickListener { showEndpoint() }
+
+ debugFrameCountTextView = findViewById(R.id.debugFrameCount).apply {
+ setOnClickListener { toggleReplay() }
+ setOnLongClickListener { toggleCapture(); true }
+ }
+
+ liveData = LiveData(this, LiveDataFields)
- val liveData = LiveData(this, LiveDataFields)
+ serviceConnection = object : TelemetryProviderService.Connection {
- serviceConnection = object : ServiceConnection {
+ private var connected: Boolean = false
+ private var service: TelemetryProviderService? = null
+ private var flowDisposable: Disposable? = null
- @SuppressLint("CheckResult")
override fun onServiceConnected(componentName: ComponentName?, binder: IBinder?) {
Log.d(TAG, "service $componentName connected")
- (binder as ListenerService.Binder).flow()
+ service = (binder as TelemetryProviderService.Binder).service()
+ flowDisposable = service!!.flow()
+ .map { TelemetryPacketDeserializer.map(it) }
.observeOn(AndroidSchedulers.mainThread())
.subscribe { packet -> liveData.onUpdate(packet) }
+ connected = true
}
override fun onServiceDisconnected(componentName: ComponentName?) {
+ onUnbind()
+ }
+
+ override fun onUnbind() {
+ flowDisposable?.dispose()
Log.d(TAG, "service $componentName disconnected")
+ service = null
+ connected = false
}
+
+ override fun isConnected() = connected
+
+ override fun service(): TelemetryProviderService? = service
}
showEndpoint()
@@ -61,11 +135,26 @@ class MainActivity : AppCompatActivity() {
override fun onStart() {
super.onStart()
- ListenerService.bindService(this, properties.value, serviceConnection)
+ TelemetryProviderService.bindService(this, ListenerService::class, serviceConnection)
+ state = State.ListenOnly
}
override fun onStop() {
- ListenerService.unbindService(this, serviceConnection)
+ when (state) {
+ State.ListenRecording -> stopCapture()
+ State.ReplayCapture -> stopReplay()
+ State.ReplayCrashReport -> stopReplay()
+ else -> {
+ }
+ }
+
+ if (serviceConnection.isConnected()) {
+ TelemetryProviderService.unbindService(this, serviceConnection)
+ }
+
+ state = State.None
+
+ currentTimeTimer.cancel()
super.onStop()
}
@@ -73,14 +162,151 @@ class MainActivity : AppCompatActivity() {
@SuppressLint("CheckResult")
private fun showEndpoint() {
val wifiManager = applicationContext.getSystemService(WIFI_SERVICE) as WifiManager
- @Suppress("DEPRECATION") val ipAddress = Formatter.formatIpAddress(wifiManager.connectionInfo.ipAddress)
+ @Suppress("DEPRECATION") val ipAddress =
+ Formatter.formatIpAddress(wifiManager.connectionInfo.ipAddress)
val dialog = AlertDialog.Builder(this)
- .setMessage("Endpoint: $ipAddress:${properties.value.port}")
+ .setMessage(
+ "Endpoint: $ipAddress:${
+ getSharedPreferences(
+ Properties.Name,
+ Context.MODE_PRIVATE
+ ).loadProperties().port
+ }"
+ )
.create()
- dialog.toSingle()
+ Single.just(dialog)
.delay(5, TimeUnit.SECONDS)
.subscribe { it -> if (it.isShowing) it.dismiss() }
dialog.show()
}
+ private fun toggleReplay() {
+ when (state) {
+ State.ListenOnly -> replaySelectDialog()
+ State.ListenRecording -> {
+ stopCapture()
+ replaySelectDialog()
+ }
+ State.ReplayCapture -> stopReplay()
+ State.ReplayCrashReport -> stopReplay()
+ State.None -> {
+ }
+ }
+ }
+
+ private fun replaySelectDialog() {
+ AlertDialog.Builder(this)
+ .setTitle(getString(R.string.dialog_replay_title))
+ .setMessage(getString(R.string.dialog_replay_message))
+ .setPositiveButton(getString(R.string.dialog_replay_capture_file)) { _, _ -> openCaptureFile.launch("*/*") }
+ .setNegativeButton(getString(R.string.dialog_replay_report_file)) { _, _ -> openReportFile.launch("application/json") }
+ .create()
+ .apply { setCanceledOnTouchOutside(false); show() }
+ }
+
+ private fun replayFromCapture(uri: Uri) {
+ TelemetryProviderService.unbindService(this, serviceConnection)
+
+ debugFrameCountTextView.background = ContextCompat.getDrawable(this, R.color.replaying)
+
+ TelemetryProviderService.bindService(
+ this,
+ ReplayService::class,
+ serviceConnection,
+ ReplayService.SourceType to ReplayService.SourceTypeCapture,
+ ReplayService.SourcePath to uri.toString()
+ )
+ state = State.ReplayCapture
+ }
+
+ private fun replayFromCrashReport(uri: Uri) {
+ TelemetryProviderService.unbindService(this, serviceConnection)
+
+ debugFrameCountTextView.background = ContextCompat.getDrawable(this, R.color.replaying)
+
+ TelemetryProviderService.bindService(
+ this,
+ ReplayService::class,
+ serviceConnection,
+ ReplayService.SourceType to ReplayService.SourceTypeReport,
+ ReplayService.SourcePath to uri.toString()
+ )
+ state = State.ReplayCrashReport
+ }
+
+ private fun stopReplay() {
+ debugFrameCountTextView.background = null
+ TelemetryProviderService.unbindService(this, serviceConnection)
+ TelemetryProviderService.bindService(this, ListenerService::class, serviceConnection)
+ state = State.ListenOnly
+ }
+
+ private fun toggleCapture() {
+ when (state) {
+ State.ListenOnly -> startCapture()
+ State.ListenRecording -> stopCapture()
+ State.ReplayCapture -> {
+ }
+ State.ReplayCrashReport -> {
+ }
+ State.None -> {
+ }
+ }
+ }
+
+ private fun startCapture() {
+ val service = serviceConnection.service()
+ if (service is Recorder) {
+ service.startRecording()
+ debugFrameCountTextView.background = ContextCompat.getDrawable(this, R.color.recoring)
+ state = State.ListenRecording
+ } else {
+ Toast.makeText(this, "Can't start recording", Toast.LENGTH_SHORT).show()
+ }
+
+ }
+
+ private fun stopCapture() {
+ val service = serviceConnection.service()
+ if (service is Recorder) {
+ val frameCount = service.stopRecording()
+ Toast.makeText(this@MainActivity, "Captured $frameCount frames", Toast.LENGTH_SHORT)
+ .show()
+ captureSaveDialog()
+ } else {
+ Toast.makeText(this, "Capture wasn't enabled", Toast.LENGTH_SHORT).show()
+ }
+ debugFrameCountTextView.background = null
+ state = State.ListenOnly
+ }
+
+ private fun captureSaveDialog() {
+ saveCaptureFile.launch("")
+ }
+
+ private fun moveCaptureFile(uri: Uri?) {
+ fileList().find { it == Recorder.LastestCaptureFilename }.also { fileName ->
+ if (fileName == null) {
+ Toast.makeText(this, "No capture found", Toast.LENGTH_SHORT).show()
+ return@also
+ }
+
+ val captureFile = File(this.filesDir, fileName)
+
+ if (uri != null) {
+ contentResolver.openOutputStream(uri).use { to ->
+ if (to == null) {
+ Toast.makeText(this, "Can't create target file", Toast.LENGTH_SHORT)
+ .show()
+ return@also
+ }
+ FileInputStream(captureFile).use { from ->
+ from.copyTo(to)
+ }
+ }
+ Toast.makeText(this, "Saved", Toast.LENGTH_SHORT).show()
+ }
+ captureFile.delete()
+ }
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/ru/n1ks/f1dashboard/ReplayService.kt b/app/src/main/java/ru/n1ks/f1dashboard/ReplayService.kt
new file mode 100644
index 0000000..0900b66
--- /dev/null
+++ b/app/src/main/java/ru/n1ks/f1dashboard/ReplayService.kt
@@ -0,0 +1,109 @@
+package ru.n1ks.f1dashboard
+
+import android.content.Intent
+import android.net.Uri
+import android.os.SystemClock
+import android.util.Log
+import io.reactivex.BackpressureStrategy
+import io.reactivex.Flowable
+import io.reactivex.rxkotlin.toFlowable
+import io.reactivex.schedulers.Schedulers
+import ru.n1ks.f1dashboard.capture.LiveCaptureFrame
+import ru.n1ks.f1dashboard.reporting.ByteArraysJSONUtils
+import ru.n1ks.f1dashboard.reporting.PacketTail
+import java.io.BufferedInputStream
+import java.io.InputStream
+import java.util.zip.GZIPInputStream
+
+class ReplayService : TelemetryProviderService() {
+
+ companion object {
+ const val SourceType = "source_type"
+ const val SourceTypeCapture = "capture"
+ const val SourceTypeReport = "report"
+ const val SourcePath = "source_path"
+ const val NoDelays = "no_delays"
+ }
+
+ private var inputStream: InputStream? = null
+ private var messageFlow: Flowable? = null
+
+ override fun start(intent: Intent) {
+ val sourceType = intent.getStringExtra(SourceType) ?: throw IllegalArgumentException("$SourceType extra not found")
+ when (sourceType) {
+ SourceTypeCapture -> {
+ val sourcePath = intent.getStringExtra(SourcePath)
+ ?: throw IllegalArgumentException("$SourcePath extra not found")
+ val replayDelays = !intent.getBooleanExtra(NoDelays, false)
+ startFromCapture(sourcePath, replayDelays)
+ }
+ SourceTypeReport -> {
+ val sourcePath = intent.getStringExtra(SourcePath)
+ ?: throw IllegalArgumentException("$SourcePath extra not found")
+ val emulateDelays = !intent.getBooleanExtra(NoDelays, false)
+ startFromReport(sourcePath, emulateDelays)
+ }
+ else -> throw IllegalArgumentException("$SourceType = $sourceType is invalid")
+ }
+ }
+
+ override fun stop() {
+ try {
+ inputStream?.close()
+ inputStream = null
+ } catch (e: Exception) {}
+ messageFlow = null
+ }
+
+ override fun flow(): Flowable =
+ messageFlow ?: throw IllegalStateException("service not initialized")
+
+ private fun startFromCapture(sourcePath: String, replayDelays: Boolean) {
+ val captureUri = Uri.parse(sourcePath)
+ Log.d(TAG, "start replaying, replay delays = $replayDelays")
+ var prevTS = 0L
+ var counter = 0L
+ val inputStream = contentResolver.openInputStream(captureUri) ?: throw IllegalArgumentException("can't open uri $captureUri")
+ this.inputStream = inputStream
+ messageFlow = LiveCaptureFrame.flow(inputStream)
+ .subscribeOn(Schedulers.newThread())
+ .doFinally {
+ Log.d(TAG, "replay read $counter frames")
+ }
+ .doOnNext {
+ counter++
+ if (replayDelays) {
+ val targetTS = it.timestamp - prevTS + SystemClock.uptimeMillis()
+ while (SystemClock.uptimeMillis() < targetTS) {
+ Thread.yield()
+ }
+ prevTS = it.timestamp
+ }
+ }
+ .map { it.data }
+ .doOnNext { PacketTail.onPacket(it) }
+ }
+
+ private fun startFromReport(reportPath: String, emulateDelays: Boolean) {
+ val reportUri = Uri.parse(reportPath)
+ val packets = contentResolver.openInputStream(reportUri)?.use {
+ ByteArraysJSONUtils.fromJSON(it.readBytes().decodeToString())
+ } ?: throw IllegalArgumentException("can't open uri $reportUri")
+ var counter = 0L
+ messageFlow = packets.toFlowable()
+ .subscribeOn(Schedulers.newThread())
+ .doFinally {
+ Log.d(TAG, "read $counter frames")
+ }
+ .doOnNext {
+ counter++
+ if (emulateDelays) {
+ val targetTS = SystemClock.uptimeMillis() + 500
+ while (SystemClock.uptimeMillis() < targetTS) {
+ Thread.yield()
+ }
+ }
+ }
+ .doOnNext { PacketTail.onPacket(it) }
+ }
+}
diff --git a/app/src/main/java/ru/n1ks/f1dashboard/TelemetryProviderService.kt b/app/src/main/java/ru/n1ks/f1dashboard/TelemetryProviderService.kt
new file mode 100644
index 0000000..48b72ab
--- /dev/null
+++ b/app/src/main/java/ru/n1ks/f1dashboard/TelemetryProviderService.kt
@@ -0,0 +1,72 @@
+package ru.n1ks.f1dashboard
+
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.content.ServiceConnection
+import android.os.IBinder
+import android.util.Log
+import io.reactivex.Flowable
+import kotlin.reflect.KClass
+
+abstract class TelemetryProviderService : Service() {
+
+ companion object {
+
+ fun bindService(context: Context, serviceClass: KClass, connection: Connection, vararg args: Pair = emptyArray()) {
+ val intent = Intent(context, serviceClass.java)
+ if (!args.isNullOrEmpty()) {
+ args.forEach { intent.putExtra(it.first, it.second) }
+ }
+ context.bindService(intent, connection, Context.BIND_AUTO_CREATE)
+ }
+
+ fun unbindService(context: Context, connection: Connection) {
+ context.unbindService(connection)
+ connection.onUnbind()
+ }
+ }
+
+ interface Connection : ServiceConnection {
+
+ fun isConnected(): Boolean
+
+ fun onUnbind()
+
+ fun service(): TelemetryProviderService?
+ }
+
+ inner class Binder : android.os.Binder() {
+
+ fun service(): TelemetryProviderService = this@TelemetryProviderService
+ }
+
+ @Suppress("PropertyName")
+ protected val TAG: String = this.javaClass.simpleName
+
+ private val binder = Binder()
+
+ final override fun onBind(intent: Intent): IBinder {
+ Log.d(TAG, "start")
+ start(intent)
+ return binder
+ }
+
+ final override fun onUnbind(intent: Intent?): Boolean {
+ Log.d(TAG, "stopping")
+ stop()
+ return false
+ }
+
+ final override fun onDestroy() {
+ Log.d(TAG, "destroy")
+ stop()
+ super.onDestroy()
+ }
+
+ abstract fun flow(): Flowable
+
+ protected abstract fun start(intent: Intent)
+
+ protected abstract fun stop()
+}
\ No newline at end of file
diff --git a/app/src/main/java/ru/n1ks/f1dashboard/capture/LiveCaptureFrame.kt b/app/src/main/java/ru/n1ks/f1dashboard/capture/LiveCaptureFrame.kt
new file mode 100644
index 0000000..c011e68
--- /dev/null
+++ b/app/src/main/java/ru/n1ks/f1dashboard/capture/LiveCaptureFrame.kt
@@ -0,0 +1,104 @@
+package ru.n1ks.f1dashboard.capture
+
+import android.content.Context
+import android.net.Uri
+import android.util.Log
+import io.reactivex.BackpressureStrategy
+import io.reactivex.Flowable
+import io.reactivex.schedulers.Schedulers
+import java.io.BufferedInputStream
+import java.io.File
+import java.io.InputStream
+import java.io.OutputStream
+import java.nio.ByteBuffer
+import java.nio.ByteOrder
+import java.util.zip.GZIPInputStream
+
+class LiveCaptureFrame(
+ val timestamp: Long,
+ val data: ByteArray
+) {
+
+ companion object {
+
+ private const val TAG = "LiveCaptureFrame"
+
+ fun flow(captureInputStream: InputStream): Flowable {
+ val inputStream = GZIPInputStream(BufferedInputStream(captureInputStream))
+ Log.d(TAG, "open capture file ${captureInputStream.hashCode()}")
+ return Flowable.create(
+ {
+ try {
+ var frame = readFrom(inputStream)
+ while (frame != null && !it.isCancelled) {
+ it.onNext(frame)
+ frame = readFrom(inputStream)
+ }
+ } catch (e: Exception) {
+ Log.d(TAG, "can't read from stream: ${e.message}")
+ } finally {
+ it.onComplete()
+ }
+ }, BackpressureStrategy.BUFFER
+ )
+ .doFinally { Log.d(TAG, "close capture file ${captureInputStream.hashCode()}"); captureInputStream.close() }
+ }
+
+ private fun readFrom(inputStream: InputStream): LiveCaptureFrame? {
+ val buffer = ByteBuffer.allocate(Long.SIZE_BYTES + Int.SIZE_BYTES).apply {
+ order(ByteOrder.LITTLE_ENDIAN)
+ if (!readFromStream(inputStream)) {
+ return null
+ }
+ }
+ val timestamp = buffer.long
+ val dataSize = buffer.int
+ val data = ByteBuffer.allocate(dataSize).apply {
+ order(ByteOrder.LITTLE_ENDIAN)
+ if (!readFromStream(inputStream))
+ return null
+ }.array()
+ return LiveCaptureFrame(timestamp, data)
+ }
+
+ private fun ByteBuffer.readFromStream(inputStream: InputStream): Boolean {
+ var pos = 0
+ var remain = capacity()
+ var read = 0
+ do {
+ pos += read
+ remain -= read
+ read = inputStream.read(array(), pos, remain)
+ if (read == -1) {
+ return false
+ }
+ } while (read != remain)
+ return true
+ }
+ }
+
+ fun writeTo(outputStream: OutputStream) {
+ ByteBuffer.allocate(Long.SIZE_BYTES + Int.SIZE_BYTES).apply {
+ order(ByteOrder.LITTLE_ENDIAN)
+ putLong(timestamp)
+ putInt(data.size)
+ outputStream.write(array())
+ }
+ outputStream.write(data)
+ }
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (javaClass != other?.javaClass) return false
+
+ other as LiveCaptureFrame
+
+ if (timestamp != other.timestamp) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ return timestamp.hashCode()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/ru/n1ks/f1dashboard/capture/LiveCaptureWorker.kt b/app/src/main/java/ru/n1ks/f1dashboard/capture/LiveCaptureWorker.kt
new file mode 100644
index 0000000..52354ed
--- /dev/null
+++ b/app/src/main/java/ru/n1ks/f1dashboard/capture/LiveCaptureWorker.kt
@@ -0,0 +1,52 @@
+package ru.n1ks.f1dashboard.capture
+
+import android.os.SystemClock
+import android.util.Log
+import io.reactivex.BackpressureStrategy
+import io.reactivex.Emitter
+import io.reactivex.Flowable
+import io.reactivex.disposables.Disposable
+import io.reactivex.schedulers.Schedulers
+import java.io.File
+import java.io.FileOutputStream
+import java.util.zip.GZIPOutputStream
+
+class LiveCaptureWorker(
+ private val file: File
+) : AutoCloseable {
+
+ companion object {
+ private const val TAG = "LiveCaptureWorker"
+ }
+
+ var frameCount: Long = 0
+ private set
+ private val outputStream = GZIPOutputStream(FileOutputStream(file))
+ private val startTimestamp = SystemClock.uptimeMillis()
+ private lateinit var flowEmitter: Emitter
+ private val flow = Flowable.create({
+ flowEmitter = it
+ }, BackpressureStrategy.BUFFER)
+ private val flowDisposable: Disposable
+
+ init {
+ Log.d(TAG, "open file " + file.path)
+ flowDisposable = flow
+ .map {LiveCaptureFrame(SystemClock.uptimeMillis() - startTimestamp, it) }
+ .observeOn(Schedulers.newThread())
+ .doFinally {
+ Log.d(TAG, "close file " + file.path)
+ outputStream.apply { finish(); flush(); close() }
+ }
+ .subscribe { it.writeTo(outputStream); outputStream.flush() }
+ }
+
+ fun onPacket(packet: ByteArray) {
+ frameCount++
+ flowEmitter.onNext(packet)
+ }
+
+ override fun close() {
+ flowDisposable.dispose()
+ }
+}
diff --git a/app/src/main/java/ru/n1ks/f1dashboard/capture/Recorder.kt b/app/src/main/java/ru/n1ks/f1dashboard/capture/Recorder.kt
new file mode 100644
index 0000000..dc664c5
--- /dev/null
+++ b/app/src/main/java/ru/n1ks/f1dashboard/capture/Recorder.kt
@@ -0,0 +1,12 @@
+package ru.n1ks.f1dashboard.capture
+
+interface Recorder {
+
+ companion object {
+ const val LastestCaptureFilename = "latest.cap"
+ }
+
+ fun startRecording()
+
+ fun stopRecording(): Long
+}
\ No newline at end of file
diff --git a/app/src/main/java/ru/n1ks/f1dashboard/livedata/live_data.kt b/app/src/main/java/ru/n1ks/f1dashboard/livedata/live_data.kt
index 586b600..8b23049 100644
--- a/app/src/main/java/ru/n1ks/f1dashboard/livedata/live_data.kt
+++ b/app/src/main/java/ru/n1ks/f1dashboard/livedata/live_data.kt
@@ -6,8 +6,8 @@ import android.graphics.drawable.Drawable
import android.view.View
import android.widget.TextView
import androidx.core.content.ContextCompat
+import io.reactivex.Single
import io.reactivex.android.schedulers.AndroidSchedulers
-import io.reactivex.rxkotlin.toSingle
import ru.n1ks.f1dashboard.*
import ru.n1ks.f1dashboard.model.*
import java.text.DecimalFormat
@@ -46,7 +46,7 @@ data class Competitor(
val positionString: String
get() = position.toString().padEnd(2, ' ')
- val isTyresNew: Boolean
+ val areTyresNew: Boolean
get() = tyreAge < 3
val tyreTypeValue: String
@@ -324,7 +324,7 @@ val LiveDataFields = listOf>(
bbField.background = getDrawable(R.color.warn)
val tag = System.nanoTime()
bbField.tag = tag
- bbField.toSingle()
+ Single.just(bbField)
.delay(1000, TimeUnit.MILLISECONDS)
.observeOn(AndroidSchedulers.mainThread())
.subscribe { field ->
@@ -347,7 +347,7 @@ val LiveDataFields = listOf>(
diffField.background = getDrawable(R.color.warn)
val tag = System.nanoTime()
diffField.tag = tag
- diffField.toSingle()
+ Single.just(diffField)
.delay(1000, TimeUnit.MILLISECONDS)
.observeOn(AndroidSchedulers.mainThread())
.subscribe { field ->
@@ -641,7 +641,7 @@ val LiveDataFields = listOf>(
aheadTyreField.text = context.ahead.tyreTypeValue
aheadTyreField.setTextColor(getColor(context.ahead.tyreTyreColor))
aheadTyreField.background =
- if (context.ahead.isTyresNew) getDrawable(R.color.tyreNew) else null
+ if (context.ahead.areTyresNew) getDrawable(R.color.tyreNew) else null
} else {
aheadDriverField.text = "XX"
aheadTimeField.text = "X:XX.XXX"
@@ -676,7 +676,7 @@ val LiveDataFields = listOf>(
ahead2TyreField.text = context.ahead2.tyreTypeValue
ahead2TyreField.setTextColor(getColor(context.ahead2.tyreTyreColor))
ahead2TyreField.background =
- if (context.ahead2.isTyresNew) getDrawable(R.color.tyreNew) else null
+ if (context.ahead2.areTyresNew) getDrawable(R.color.tyreNew) else null
} else {
ahead2DriverField.text = "XX"
ahead2TimeField.text = "X:XX.XXX"
@@ -705,7 +705,7 @@ val LiveDataFields = listOf>(
playerTyreField.text = context.player.tyreTypeValue
playerTyreField.setTextColor(getColor(context.player.tyreTyreColor))
playerTyreField.background =
- if (context.player.isTyresNew) getDrawable(R.color.tyreNew) else null
+ if (context.player.areTyresNew) getDrawable(R.color.tyreNew) else null
} else {
playerBestTimeField.text = "X:XX.XXX"
playerLastTimeField.text = "X:XX.XXX"
@@ -742,7 +742,7 @@ val LiveDataFields = listOf>(
behindTyreFiled.text = context.behind.tyreTypeValue
behindTyreFiled.setTextColor(getColor(context.behind.tyreTyreColor))
behindTyreFiled.background =
- if (context.behind.isTyresNew) getDrawable(R.color.tyreNew) else null
+ if (context.behind.areTyresNew) getDrawable(R.color.tyreNew) else null
} else {
behindDriverField.text = "XX"
@@ -781,8 +781,7 @@ val LiveDataFields = listOf>(
behind2TyreFiled.text = context.behind2.tyreTypeValue
behind2TyreFiled.setTextColor(getColor(context.behind2.tyreTyreColor))
behind2TyreFiled.background =
- if (context.behind2.isTyresNew) getDrawable(R.color.tyreNew) else null
-
+ if (context.behind2.areTyresNew) getDrawable(R.color.tyreNew) else null
} else {
behind2DriverField.text = "XX"
behind2TimeField.text = "X:XX.XXX"
@@ -1050,7 +1049,7 @@ val LiveDataFields = listOf>(
return@LiveDataField data
},
{
- val sessionTimeField = findViewById(R.id.sessionTimeValue) as TextView
+ val sessionTimeField = findViewById(R.id.sessionTimeValue)
if (it > 0)
sessionTimeField.text = "${it / 60}:${secondsFormat.format(it % 60)}"
else
@@ -1063,7 +1062,7 @@ val LiveDataFields = listOf>(
{ data, _ -> data + 1 },
{
if (it % 100 == 0) {
- val debugField = findViewById(R.id.debugFrameCount) as TextView
+ val debugField = findViewById(R.id.debugFrameCount)
debugField.text = it.toString()
}
}
diff --git a/app/src/main/java/ru/n1ks/f1dashboard/model/mapper.kt b/app/src/main/java/ru/n1ks/f1dashboard/model/mapper.kt
index ee618b4..750ec4c 100644
--- a/app/src/main/java/ru/n1ks/f1dashboard/model/mapper.kt
+++ b/app/src/main/java/ru/n1ks/f1dashboard/model/mapper.kt
@@ -7,9 +7,9 @@ object TelemetryPacketDeserializer {
private const val CarCount = 22
- fun map(buffer: ByteBuffer): TelemetryPacket {
+ fun map(packet: ByteArray): TelemetryPacket {
+ val buffer = ByteBuffer.wrap(packet)
buffer.order(ByteOrder.LITTLE_ENDIAN)
- buffer.position(0)
val header = mapHeader(buffer)
return when (header.packetTypeId) {
PackageType.CarTelemetryType.id -> {
diff --git a/app/src/main/java/ru/n1ks/f1dashboard/reporting/CrashDataStoreHelper.kt b/app/src/main/java/ru/n1ks/f1dashboard/reporting/CrashDataStoreHelper.kt
new file mode 100644
index 0000000..24c2d4f
--- /dev/null
+++ b/app/src/main/java/ru/n1ks/f1dashboard/reporting/CrashDataStoreHelper.kt
@@ -0,0 +1,43 @@
+package ru.n1ks.f1dashboard.reporting
+
+import android.content.Context
+import org.json.JSONObject
+import java.io.*
+
+class CrashDataStoreHelper(private val context: Context) {
+
+ companion object {
+ private const val filename = "last_crash_data"
+ }
+
+ fun store(records: Map) {
+ val file = File(context.filesDir, filename)
+ if (file.exists()) {
+ file.delete()
+ }
+ BufferedOutputStream(FileOutputStream(file)).use { outputStream ->
+ JSONObject(records).apply {
+ outputStream.write(toString().encodeToByteArray())
+ }
+ }
+ }
+
+ fun load(): Map {
+ val file = File(context.filesDir, filename)
+ if (!file.exists()) {
+ return emptyMap()
+ }
+ BufferedInputStream(FileInputStream(file)).use { inputStream ->
+ JSONObject(inputStream.readBytes().decodeToString()).apply {
+ return keys().asSequence().map { it to getString(it) }.toMap()
+ }
+ }
+ }
+
+ fun delete() {
+ val file = File(context.filesDir, filename)
+ if (file.exists()) {
+ file.delete()
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/ru/n1ks/f1dashboard/reporting/CrashReportActivity.kt b/app/src/main/java/ru/n1ks/f1dashboard/reporting/CrashReportActivity.kt
index fbc0316..ff424b2 100644
--- a/app/src/main/java/ru/n1ks/f1dashboard/reporting/CrashReportActivity.kt
+++ b/app/src/main/java/ru/n1ks/f1dashboard/reporting/CrashReportActivity.kt
@@ -4,17 +4,17 @@ import android.app.AlertDialog
import android.content.Intent
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
-import org.acra.dialog.CrashReportDialogHelper
+import cat.ereza.customactivityoncrash.CustomActivityOnCrash
import ru.n1ks.f1dashboard.R
class CrashReportActivity : AppCompatActivity() {
- private lateinit var helper: CrashReportDialogHelper
+ private lateinit var crashDataStoreHelper: CrashDataStoreHelper
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
try {
- helper = CrashReportDialogHelper(this, intent)
+ crashDataStoreHelper = CrashDataStoreHelper(this)
buildAndShowDialog()
} catch (e: IllegalArgumentException) {
finish()
@@ -26,26 +26,26 @@ class CrashReportActivity : AppCompatActivity() {
.setTitle(R.string.crashReportTitle)
.setMessage(R.string.crashReportText)
.setPositiveButton(R.string.crashReportSendCaption) { _, _ ->
+ val errorDetails =
+ CustomActivityOnCrash.getAllErrorDetailsFromIntent(this, intent) +
+ (crashDataStoreHelper.load()[ReportingKeys.LastPacketsData]?.let { "\nLast packets:\n$it" } ?: "")
startActivity(
Intent.createChooser(
Intent().apply {
action = Intent.ACTION_SEND
- putExtra(Intent.EXTRA_TEXT, helper.reportData.toJSON())
+ putExtra(Intent.EXTRA_TEXT, errorDetails)
type = "text/plain"
},
getString(R.string.crashReportShareCaption)
)
)
- helper.cancelReports()
+ crashDataStoreHelper.delete()
finish()
}
.setNegativeButton(R.string.crashReportCancelCaption) { _, _ ->
- helper.cancelReports()
+ crashDataStoreHelper.delete()
finish()
}
- .create().apply {
- setCanceledOnTouchOutside(false)
- show()
- }
+ .create().apply { setCanceledOnTouchOutside(false); show() }
}
}
\ No newline at end of file
diff --git a/app/src/main/java/ru/n1ks/f1dashboard/reporting/collectors.kt b/app/src/main/java/ru/n1ks/f1dashboard/reporting/collectors.kt
new file mode 100644
index 0000000..73fecdc
--- /dev/null
+++ b/app/src/main/java/ru/n1ks/f1dashboard/reporting/collectors.kt
@@ -0,0 +1,56 @@
+package ru.n1ks.f1dashboard.reporting
+
+import android.util.Base64
+import org.json.JSONArray
+
+internal object ReportingKeys {
+ const val LastPacketsData = "last_packets"
+}
+
+object PacketTail {
+
+ private const val tailSize = 10
+ private val tail = Array(tailSize) { null }
+ private var tailIdx = 0
+
+ fun onPacket(packet: ByteArray) {
+ synchronized(this) {
+ tail[tailIdx++] = packet
+ if (tailIdx == tailSize)
+ tailIdx = 0
+ }
+ }
+
+ fun tail(): Array {
+ synchronized(this) {
+ val res = ArrayList(0)
+ var idx = tailIdx + 1
+ for (i in 0 until tailSize) {
+ if (idx == tailSize)
+ idx = 0
+ val packet = tail[idx++]
+ if (packet != null) {
+ res.add(packet)
+ }
+ }
+ return res.toArray(Array(0) { ByteArray(0) })
+ }
+ }
+}
+
+object ByteArraysJSONUtils {
+
+ fun toJSON(byteArrays: Array): String {
+ val jsonArray = JSONArray()
+ byteArrays.forEach { jsonArray.put(Base64.encode(it, Base64.NO_WRAP or Base64.DEFAULT).decodeToString()) }
+ return jsonArray.toString()
+ }
+
+ fun fromJSON(json: String): Array {
+ val jsonArray = JSONArray(json)
+ return generateSequence(0) { it + 1 }
+ .take(jsonArray.length())
+ .map { Base64.decode(jsonArray.getString(it), Base64.NO_WRAP or Base64.DEFAULT) }
+ .toList().toTypedArray()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/ru/n1ks/f1dashboard/reporting/init.kt b/app/src/main/java/ru/n1ks/f1dashboard/reporting/init.kt
new file mode 100644
index 0000000..e3aa073
--- /dev/null
+++ b/app/src/main/java/ru/n1ks/f1dashboard/reporting/init.kt
@@ -0,0 +1,28 @@
+package ru.n1ks.f1dashboard.reporting
+
+import android.content.Context
+import android.util.Base64
+import android.util.Log
+import cat.ereza.customactivityoncrash.config.CaocConfig
+import org.json.JSONArray
+
+private const val TAG = "ErrorReporting"
+
+fun initReporting(context: Context) {
+ CaocConfig.Builder.create()
+ .errorActivity(CrashReportActivity::class.java)
+ .apply()
+
+ val currentDefaultUncaughtExceptionHandler = Thread.getDefaultUncaughtExceptionHandler()
+ Thread.setDefaultUncaughtExceptionHandler { t,e ->
+ try {
+ val lastPackets = PacketTail.tail()
+ val jsonArray = JSONArray()
+ lastPackets.forEach { jsonArray.put(Base64.encode(it, Base64.NO_WRAP or Base64.DEFAULT).decodeToString()) }
+ CrashDataStoreHelper(context).store(mapOf(ReportingKeys.LastPacketsData to jsonArray.toString()))
+ } catch (e: Throwable) {
+ Log.e(TAG, "error on collecting last packets", e)
+ }
+ currentDefaultUncaughtExceptionHandler?.uncaughtException(t, e)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
index d974889..65fb8eb 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -762,8 +762,19 @@
android:textColor="@color/white"
android:textSize="@dimen/fontSize"
android:typeface="normal" />
+
+
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
index ac1cdd7..e6ffdfa 100644
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -7,6 +7,7 @@
#FF018786
#FF000000
#FFFFFFFF
+ #4C4C4C
#AA84FF
#818181
#8F3416
@@ -47,5 +48,7 @@
#F9EE25
#FFFFFF
#FF8F00
- #5CFFCDD2
+ #AE00BCD4
+ #8F3416
+ #467127
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index a48da55..e3a9b34 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -14,4 +14,11 @@
drs
BB
diff
+ Do you really want not to save the last capture?
+ Yes, delete it
+ No, save it
+ Replay
+ Source of replay
+ Capture file
+ Crash report JSON file
\ No newline at end of file
diff --git a/app/src/test/java/ru/n1ks/f1dashboard/ExampleUnitTest.kt b/app/src/test/java/ru/n1ks/f1dashboard/ExampleUnitTest.kt
deleted file mode 100644
index 6c1d653..0000000
--- a/app/src/test/java/ru/n1ks/f1dashboard/ExampleUnitTest.kt
+++ /dev/null
@@ -1,17 +0,0 @@
-package ru.n1ks.f1dashboard
-
-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)
- }
-}
\ No newline at end of file
diff --git a/app/src/test/java/ru/n1ks/f1dashboard/ReplayServiceTest.kt b/app/src/test/java/ru/n1ks/f1dashboard/ReplayServiceTest.kt
new file mode 100644
index 0000000..3d32c64
--- /dev/null
+++ b/app/src/test/java/ru/n1ks/f1dashboard/ReplayServiceTest.kt
@@ -0,0 +1,136 @@
+package ru.n1ks.f1dashboard
+
+import android.content.Intent
+import android.net.Uri
+import io.reactivex.schedulers.Schedulers
+import org.junit.Assert.*
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.Robolectric
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.Shadows
+import org.robolectric.annotation.Config
+import ru.n1ks.f1dashboard.model.*
+import java.io.File
+import java.io.FileInputStream
+import java.io.IOException
+import java.util.concurrent.CountDownLatch
+import kotlin.reflect.KClass
+
+@RunWith(RobolectricTestRunner::class)
+@Config(manifest = Config.NONE)
+class ReplayServiceTest {
+
+ private lateinit var replayService: ReplayService
+
+ @Before
+ fun setup() {
+ replayService = Robolectric.setupService(ReplayService::class.java)
+ }
+
+ @Test
+ fun replayCaptureTest() {
+ try {
+ val captureFIS = startServiceForCaptureFile()
+
+ var count = 0
+ val typeCount = HashMap, Int>()
+ val waitLatch = CountDownLatch(1)
+ val disposable = replayService.flow()
+ .observeOn(Schedulers.single())
+ .doFinally { waitLatch.countDown() }
+ .doOnNext { count++ }
+ .map { TelemetryPacketDeserializer.map(it) }
+ .subscribe { assertNotNull(it); typeCount.compute(it.data::class) { _, v -> v?.plus(1) ?: 1 } }
+ waitLatch.await()
+
+ disposable.dispose()
+
+ assertThrows("Stream closed", IOException::class.java) { captureFIS.available() }
+ assertEquals(3571, count)
+ assertEquals(55, typeCount[CarSetupDataPacket::class])
+ assertEquals(864, typeCount[CarStatusDataPacket::class])
+ assertEquals(864, typeCount[CarTelemetryDataPacket::class])
+ assertEquals( 5, typeCount[ParticipantDataPacket::class])
+ assertEquals(864, typeCount[LapDataPacket::class])
+ assertEquals(864, typeCount[EmptyData::class])
+ } finally {
+ replayService.onUnbind(null)
+ }
+ }
+
+
+ @Test
+ fun replayCaptureFileCloseOnStopTest() {
+ try {
+ val captureFIS = startServiceForCaptureFile()
+ replayService.onUnbind(null)
+ assertThrows("Stream closed", IOException::class.java) { captureFIS.available() }
+ } finally {
+ replayService.onUnbind(null)
+ }
+ }
+
+ @Test
+ fun replayReportTest() {
+ val reportFile = File(javaClass.classLoader?.getResource("replay/report.json")!!.toURI())
+ val reportUri = Uri.fromFile(reportFile)
+ val reportFIS = FileInputStream(reportFile)
+ assertTrue(reportFIS.available() > 0)
+
+ try {
+ Shadows.shadowOf(replayService.contentResolver)
+ .registerInputStream(reportUri, reportFIS)
+
+ replayService.onBind(Intent().apply {
+ putExtra(ReplayService.SourcePath, reportUri.toString())
+ putExtra(ReplayService.NoDelays, true)
+ putExtra(ReplayService.SourceType, ReplayService.SourceTypeReport)
+ })
+
+ assertNotNull(replayService.flow())
+
+ var count = 0
+ val typeCount = HashMap, Int>()
+ val waitLatch = CountDownLatch(1)
+ val disposable = replayService.flow()
+ .observeOn(Schedulers.single())
+ .doFinally { waitLatch.countDown() }
+ .doOnNext { count++ }
+ .map { TelemetryPacketDeserializer.map(it) }
+ .subscribe { assertNotNull(it); typeCount.compute(it.data::class) { _, v -> v?.plus(1) ?: 1 } }
+ waitLatch.await()
+
+ disposable.dispose()
+
+ assertThrows("Stream closed", IOException::class.java) { reportFIS.available() }
+ assertEquals(10, count)
+ assertEquals(2, typeCount[CarStatusDataPacket::class])
+ assertEquals(2, typeCount[CarTelemetryDataPacket::class])
+ assertEquals(3, typeCount[LapDataPacket::class])
+ assertEquals(3, typeCount[EmptyData::class])
+ } finally {
+ replayService.onUnbind(null)
+ }
+ }
+
+ private fun startServiceForCaptureFile(): FileInputStream {
+ val captureFile = File(javaClass.classLoader?.getResource("replay/test.cap")!!.toURI())
+ val captureUri = Uri.fromFile(captureFile)
+ val captureFIS = FileInputStream(captureFile)
+ assertTrue(captureFIS.available() > 0)
+
+ Shadows.shadowOf(replayService.contentResolver).registerInputStream(captureUri, captureFIS)
+
+ replayService.onBind(Intent().apply {
+ putExtra(ReplayService.SourcePath, captureUri.toString())
+ putExtra(ReplayService.NoDelays, true)
+ putExtra(ReplayService.SourceType, ReplayService.SourceTypeCapture)
+ })
+
+ assertNotNull(replayService.flow())
+
+ return captureFIS
+ }
+}
\ No newline at end of file
diff --git a/app/src/test/resources/replay/report.json b/app/src/test/resources/replay/report.json
new file mode 100644
index 0000000..7c519fb
--- /dev/null
+++ b/app/src/test/resources/replay/report.json
@@ -0,0 +1 @@
+["5AcBEgEA5ZT1LObzaqnXlqxArgAAABP\/q\/9Kw7ELGEDl6r1Dj\/ITwgipR77J1gvC\/KKD\/xWo7Ffs\/\/uigr4oOaVlVD+UEhi9dv4UwAAwGrsUyyQ6Otc\/w+LQGEB\/IMBDW8gQwnX1GL614ATCtqGA\/3KpjlaD\/7ahr0gsOrh6Oz\/3EwG+fg8UwAAQn7nVDHs70X06w6VmHEA4S8RDMzAEwoZzqL7DLQnCMacU\/9ajK1xt\/zGnUrilPLGDQT\/k2248APwXwACw5boX35M7gXQtw9M6I0Da4MhD218Qwnzjib7XbwLCFKFJ\/yWq3FXC\/xShsN6PvhiNRT8\/S0m+O5cTwADCPLu0Zfk6A8Iow8spJkCOJc5DkkYJwgeRZb6O0AHCAKNe\/xCo8VfW\/wCjW7HpOWrTOj\/yKhw84gEVwABYMrv7wKg6Nyscw2tNKUAsvdBDLAACwmrgdL7tWu7BqKFU\/4Kpf1an\/6ihO7XmvB4piT8aF+Y9EAUUwAAqBrvLPTI7u\/AYw0U5LkBn1tVDixvywW9\/Pr6C+uPByaJs\/0qotleo\/8mihhGAu\/BhgD82GVa82tkUwAC4vbrEHzE7UP0Ow62JL0A07dZDIzLywaLEKb68\/93BpKF7\/4ape1an\/6ShtkDivCeCcj+2Rrc8jgIUwADol7qsmjM7yVkMw\/zIM0CYqttDdlDtwfVxSr7fceDBAaNn\/w+o8leO\/wGjToCSOqv7bz\/k2ck8mAIVwACEg7ooTWQ7PTsAw+oNN0B5dOFD5kTqwRam1L1zc9zByaKo\/0qotldv\/8qi77HbO8\/cUz\/Lp5i8zdkUwADsjjq2sJE7BhTtwl2AOUAd3uJDJLzywTJ6hL4E3tLBYp8C\/w+s8lO0\/2GfHgaKPRCQbD4LX0G99E8SwAB9jbu2sxk7ZvLmwqzSO0D6BOdDtzTlwT2jXL4V6OLBDqUe\/\/GlD1oXAA2lh5KDPOxczT2cO4Y7nnsWwAD7sbu7\/D266eDVwngEQEDhQehDRzXzwaiDZL6FCtXB058e\/42rc1RRANKfATZdPagrFrx5T7W9kqUSwAAY4Lt5IiO7WwHQwpPHQUD8p+xDSczowelWIr7LbeTB16Ra\/yim11mhANakr0KCvoF90r77NQQ+XlQWwACP6Lsq\/aG7aXO6woUDRUBvjO5D1YTrwYbuUL66ktfBlaFX\/5apaVZRAJShf4fGvML2bT\/Rni49\/fYTwACZtLsevCO7Xke0wteLSUAsb\/NDUJvhweJxRL78htfBdaNc\/5Wna1hmAHWjJBLsOa98Xz8OsDq9mlYVwADbvbtv1U27t7KgwpXzS0BSqPRDy37jwaB\/YL6bGtDBj6FA\/52pY1aTAI+hqQ\/KvNTTXj+CONE6mvITwAAE8btMLZO7WyKbwphjT0B0V\/lDTmTbwYNgU77zcc\/BX6NM\/6ynVFgbAF6jCn4MP9CZRj+gDeG8bUYVwAA7lbtPLlm64HCIwuz0UECuTPpDdBbdwWUpD75aCsvBuqF+\/26pklZ1ALmhOjVxvHwhPj+dukW8SBIUwACwr7t8Wmu7eWMDwzZKNED3z9xDLxT5wag4T77H\/tbBGZ9g\/2Ksn1NZ\/xqfyjHwPCFCUj9UqVc8zRgSwABQxbnAHKc7AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAsz\/GQJ8cxUB+Im1Aa\/2AQGW1HEA\/nlxAdhFIQFS2gD+jp0PCaxh0wFsbakHIDNbBjJglQuuhJUKWbSRCRHckQmp12jt0xdo7MF7mubd35LlF8bG7ABhoutWEJEJafWC5OCO5O61lVzuAVou+2xghvR5fMz2sAwC6","5AcBEgEG5ZT1LObzaqnXlqxArgAAABP\/twAPR0g\/AAAAgAAAAAAABA0sACEeAB4AHgAeAGxtTExYWFdXWgAAAKxBAACsQQAAuEEAALhBAAAAALAA1Xg\/P9mfoboAAAAAAAR+KgAAKQApAC0ALQBlZU1NV1dXV1oAAACsQQAArEEAALhBAAC4QQAAAACrAGkaIz9IkuS6AAAAAAAEXCkAADEAMQA3ADcAb29NTFlZV1daAAAArEEAAKxBAAC4QQAAuEEAAAAArwBDrDE\/JSSOPAAAAAAABBsqAAAeAB4AHgAeAHFxTExYWVdXWgAAAKxBAACsQQAAuEEAALhBAAAAAKoAk0UiPwAAAAAAAAAAAAQDKQAAKQApACwALABubkxMWFhXV1oAAACsQQAArEEAALhBAAC4QQAAAACeAJp7Sz9IkuQ6AAAAAAADWC4AYmUAZQBYAFgAa2tMTFhYV1daAAAArEEAAKxBAAC4QQAAuEEAAAAAlQDd8Tc\/SJJkOwAAAAAAA\/MrAB1NAE0AVABUAG5uTExYWFdXWgAAAKxBAACsQQAAuEEAALhBAAAAAJMAxTQxP9mfITsAAAAAAANmKwAOQQBBAEsASwBwb0xMWVhXV1oAAACsQQAArEEAALhBAAC4QQAAAACSALZ6LD8AAAAAAAAAAAADIisAB0QARABPAE8AcXBMTFlZV1daAAAArEEAAKxBAAC4QQAAuEEAAAAAkAChnh0\/AAAAgAAAAAAAA2oqAABlAGUAXQBdAG1tTU1YWFdXWgAAAKxBAACsQQAAuEEAALhBAAAAAJAA2AGgPn2FvbsAAAAAAAMZKgAAPAA8AEYARgBvb0xMWFhXV1oAAACsQQAArEEAALhBAAC4QQAAAACRAIBYcz5IkuS6AAAAAAADKCoAADUANQA8ADwAb25MTFhYV1daAAAArEEAAKxBAAC4QQAAuEEAAAAAkQD7N2c+DN5pvAAAAAAAAzwqAAA1ADUAPQA9AHBwTExYWFdXWgAAAKxBAACsQQAAuEEAALhBAAAAAJIAAAAAAAAAAIAAAAAAAANpKgAANgA2AD4APQBvbkxMWFhXV1oAAACsQQAArEEAALhBAAC4QQAAAACPAH1UKT\/ZnyE7AAAAAAADIyoAAFUAVQBYAFgAY2RMTFZXV1daAAAArEEAAKxBAAC4QQAAuEEAAAAAjABlTyI\/AAAAgAAAAAAAAy8pAABMAEsAUwBTAGtrTExYWFdXWgAAAKxBAACsQQAAuEEAALhBAAAAAIoAM24cP9mfITsAAAAAAAOuKAAAPgA+AEcASABwcExMWVlXV1oAAACsQQAArEEAALhBAAC4QQAAAACHANYfFT9Hi6u9AAAAAAAD0CcAAC8ALwA0ADQAb3BMTFhYV1daAAAArEEAAKxBAAC4QQAAuEEAAAAAhwBESRQ\/SJLkOgAAAAAAA4onAABFAEUAUABQAHBxTExYWFdXWgAAAKxBAACsQQAAuEEAALhBAAAAAJQAWB8dP0iS5LoAAAAAAANdKwANPAA8AEYARgBwcUxMWVlXV2EAAACsQQAArEEAALhBAAC4QQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD\/\/wA=","5AcBEgEH5ZT1LObzaqnXlqxArgAAABP\/AAECPAB1fNtCAADcQvq\/W0LIMswQCQAAAAEBAAAREAABAQAAAAAAAAAAAHm7bUoCAAAAAPQtp0aPsfRHAAECPAA2qNtCAADcQqztW0LIMswQCQAAAAAAAAAREAAAAAAAAAAAAAAAAAHCckoBAAAAAGk8mUb9lyBHAAECPAAypttCAADcQpzpW0LIMswQCQAAAAEBAAAREAABAQAAAAAAAAAAAGVtcEoC8RkGRZLm5UZ4lrJHAAECPABUn9tCAADcQhDfW0LIMqsNCQAAAAEBAAAREAABAQAAAAAAAAAAAKUqb0oCAAAAAPvKjUYxP8BHAAECPABkittCAADcQh7GW0LIMswQCQAAAAEBAAAREAABAQAAAAAAAAAAAMzwb0oCAAAAAFj6cEaWaaFHAAECPADfgNtCAADcQkq5W0LIMswQCQAAAAAAAAAREAAAAAAAAAAAAAAAAKReckoCAAAAAPmDsUbHBzZHAAECPACtf9tCAADcQi22W0LIMtcOCQAAAAEBAAAREAABAQAAAAAAAAAAACRdc0oCoheoRHaInUazH9lGAAECPABHgNtCAADcQiq1W0LIMswQCQAAAAEBAAAREAABAQAAAAAAAAAAAH6Hc0oCAAAAAFOgsEYNYMpGAAECPAAthdtCAADcQuC4W0LIMswQCQAAAAEBAAAREAABAQAAAAAAAAAAAMehc0oCAAAAAIkslUY+oLRGAAECPAAehNtCAADcQn20W0LIMtcOCQAAAAABAAAREAAAAQAAAAAAAAAAAKbcc0oCAAAAACZcOEbbkXdGAAECPABrpttCAADcQkDYW0LIMswQCQAAAAEBAAAREAABAQAAAAAAAAAAAArzc0oCLdPGROvMA0aeuB1GAAECPAAGp9tCAADcQl7XW0LIMtcOCQAAAAEBAAAREAABAQAAAAAAAAAAACH3c0oCAAAAAJJhjUVSxnFEAAECPAAFpdtCAADcQqrTW0LIMswQCQAAAAEBAAAREAABAQAAAAAAAAAAAAAkdEoBsPzJRBt0lEbi7I9GAAECPABToNtCAADcQu\/MW0LIMqsNCQAAAAEBAAAREAABAQAAAAAAAAAAANnDc0oCe7rKRF2mn0acDtJGAAECPACss9tCAADcQhbgW0LIMqsNCQAAAAAAAAAREAAAAAAAAAAAAAAAAHPPc0oCAAAAAA8RXkYUU4pGAAECPADGoNtCAADcQrHJW0LIMswQCQAAAAAAAAAREAAAAAAAAAAAAAAAALzzc0oCAAAAAF10REbQG1JGAAECPACtrdtCAADcQh3WW0LIMswQCQAAAAEBAAAREAABAQAAAAAAAAAAALcJdEoCAAAAAGvcNkZ64CpGAAECPACpf9tCAADcQi6iW0LIMswQCQAAAAEBAAAREAABAQAAAAAAAAAAACsadEoCAAAAAN+IIEbvcf9FAAECPABhrdtCAADcQpnSW0LIMqsNCQAAAAEBAAAREAABAQAAAAAAAAAAAAAkdEoBAAAAAJaD9kUW3VxFAAECPACYlttCAADcQl3KW0LIMtcOCQAAAAEBAAAREAABAQAAAAAAAAAAAAl8c0oCAAAAAPQSVUYZUrdGAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","5AcBEgEC5ZT1LObzaqnDgK1ArwAAABP\/AAAAAGdwq0AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABLFvxCSxb8QgAAAIABAQAAAAABAQIAAAAAZ3CrQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGzh5UJs4eVCAAAAgAIBAAAAAAIBAgAAAABncKtAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABHzSQgR80kIAAACAAwEAAAAAAwECAAAAAGdwq0AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABIFbNCSBWzQgAAAIAEAQAAAAAEAQIAAAAAZ3CrQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAE+NnUJPjZ1CAAAAgAUBAAAAAAUBAgAAAABncKtAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGh+EQhofhEIAAACABgEAAAAABgECAAAAAGdwq0AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA6MWJCOjFiQgAAAIAHAQAAAAAHAQIAAAAAZ3CrQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABqaP0Iamj9CAAAAgAgBAAAAAAgBAgAAAABncKtAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPX4dQj1+HUIAAACACQEAAAAACQECAAAAAGdwq0AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACPwrRBj8K0QQAAAIALAQAAAAALAQIAAAAAZ3CrQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFcAW0FXAFtBAAAAgAwBAAAAAAwBAgAAAABncKtAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAya2QAMmtkAAAACADQEAAAAADQECAAAAAGdwq0AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA2AjAANgIwAAAAIAOAQACAAAOAQIAAAAAZ3CrQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAASJsEAEibBAAAAgA8BAAIAAA8BAgAAAABncKtAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACmwQAApsEAAACAEAEAAgAAEAECAAAAAGdwq0AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAiO7BAIjuwQAAAIARAQACAAARAQIAAAAAZ3CrQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAmGsKAJhrCAAAAgBIBAAIAABIBAgAAAABncKtAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgGg8woBoPMIAAACAEwEAAgAAEwECAAAAAGdwq0AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAg1zCAINcwgAAAIAUAQACAAAUAQIAAAAAZ3CrQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAG\/7+kFv+\/pBAAAAgAoBAAAAAAoBAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=","5AcBEgEA5ZT1LObzaqnDgK1ArwAAABP\/sQ5Mw3uuF0DQar1DYKIUwphhTr5WegzC+6KF\/xWo61f4\/\/ui9+BZPOTvVj\/KDzC8JP4UwABEKLsAv4M5U+BAwwyDGEDXpr9D9FgRwlRMOL5PawXCt6F8\/3GpkFaW\/7ihApOEvFJlRz+PDLC9sRAUwABoSrpem1U7+G87w\/nLG0CWzcND0csEwtW8qr7mxwnCLqcW\/9ijKVxq\/y+nbRqUPDoRRD9S2I28N\/oXwACo2boTyZY7xXwuw3CzIkBHachDc9AQwuRXnL6GQwPCOqEq\/\/upBla1\/zqhtMGQvnN3Sj9jms29crMTwADMWLuYRxY7c70pwxHJJUCqrs1DG+AJwsBeP77+YwLCAaN2\/w+o8VfV\/wGjwCrgOsbHPj+2GCk+ZQIVwACqDLtiy646Yhkdw2HjKED9T9BDImACwp2XXr4fJO\/BrKFc\/32pg1ar\/6yhNarnvKsG0L0c1ns9IwgUwACI\/rpmLCs7u84Zw2vgLUDgbdVD2cDzwSgFRr4Xh+XB0KJr\/0OovVem\/9CiSyPSO9ehgT8gGaS8pN4UwAD4urqBujQ7WtsPw\/w9L0Bsh9ZDtbzzwUXfIb71fd\/BqKGC\/4Gpf1ap\/6mhTtPKvJy3dT9yxAI9swUUwADAhrowTi87XjMNw6ZtM0C1Q9tDaNvuwckoRb4F5+HBAaNr\/w+o8leR\/wGj0WXBOX0icz++KME8kAIVwAAgfLrej1879hEBw\/3cNkB0D+FDPanrwchW070oud3ByKKr\/0uotVds\/8iiTyeRPNqFWD\/2\/Jk80NgUwADgnDqGKZQ7JtDuwsoLOUC0feJDaU\/zwXPNcr6LL9PBV58b\/xqs5lOy\/1eff5l5Pe3AnD6ycOU9IEgSwAAOc7sY3R07cpXowndqO0BGneZD\/mPlwcwybb6EF+PBDKUg\/\/OlDVoPAAulr4RGuT9H\/D0OfHi9QnoWwACrqrudvPi5i53XwvKRP0CJ4OdDrGTzwY0Nib6dC9XBwZ\/+\/qKrX1RAAMCfpFhlPcCGkj1uWym+KJgSwABy7bvDrwG7P6rRwpGEQUCRP+xDGFHowQOnCr4Sx+TB5KRZ\/xum5FmmAOOkPfCGvi\/3TT2wKl89q10WwACE67svDKa7QCO8wuClRECYKe5DQwbtwRKVSr7ECNnBmaFW\/5KpblZcAJmhGr7WvD4ncD84TKw8HvoTwABFvLv3KTi7CeW1wiAvSUBhDPNDFQ3jwZ2rUL6L4djBdKNV\/5analhcAHSjgn9dOlrmYj8ytBG9\/VUVwACWu7uqYDi70FOiwrmMS0DqSPRDTOvkwR4dYr5qetHBk6FB\/5ipZ1aSAJOhmQXQvNz6Yj+w6626pPUTwAAj8Ls5RJK74bScwr8AT0B5+PhD4efcwa34XL4\/ctDBDKNE\/wSo\/FdjAAuj2MWVvYmnTT\/sMOa8OAoVwAACzbsv\/ka7EAaKwoC1UECj7\/lD60rewfsWCr72N8zBvqF0\/2qpllaBAL2hWTjWvMCTQD8fVqI7IBUUwAAKv7sOLIG7v0cEw3DsM0B3bdxDf4L6wZiZS77mLdjBFp9e\/2WsnFNf\/xefy4gsPBJRVz9o6os8hhYSwAAIDrp6G6E7AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA69vGQG+Ux0DWWXdANjeGQGb6iz4Bmis\/GyAwQCPCN0B5WZjCc33Cwo6eUcHDq4JCJZYmQtKdJkIGXCVC12IlQj1g5DtfXeU7zZTtuXp36LlVONC7wATMO4ByJUL18Qo8ZXp9O2Jr6Tuc5Zs+x6N\/vf6VCT4AAACA","5AcBEgEG5ZT1LObzaqnDgK1ArwAAABP\/uABwmko\/2Z+hugAAAAAABEEsACYeAB4AHgAeAGxtTExYWFdXWgAAAKxBAACsQQAAuEEAALhBAAAAALEAQ9JBPwAAAIAAAAAAAASnKgAAKQApAC0ALQBlZU1NV1dXV1oAAACsQQAArEEAALhBAAC4QQAAAACsANxnJT9IkuS6AAAAAAAEjSkAADEAMQA3ADcAb29NTFlZV1daAAAArEEAAKxBAAC4QQAAuEEAAAAArwCl\/jM\/S+6CPAAAAAAABEoqAAAeAB4AHgAeAHFxTExYWVdXWgAAAKxBAACsQQAAuEEAALhBAAAAAKoA9JgkPwAAAAAAAAAAAAQvKQAAKQApACwALABubkxMWFhXV1oAAACsQQAArEEAALhBAAC4QQAAAACfALbVTT9IkuQ6AAAAAAADkC4AZGUAZQBYAFgAa2tMTFhYV1daAAAArEEAAKxBAAC4QQAAuEEAAAAAlgCVRzo\/AAAAAAAAAAAAA0EsACZNAE0AVABUAG5uTExYWFdXWgAAAKxBAACsQQAAuEEAALhBAAAAAJQAkYEzP4j4CzsAAAAAAAOvKwAWQQBBAEsASwBwb0xMWVhXV1oAAACsQQAArEEAALhBAAC4QQAAAACTAILHLj8AAAAAAAAAAAADbCsAD0QARABPAE8AcXBMTFlZV1daAAAArEEAAKxBAAC4QQAAuEEAAAAAkQCt8x8\/2Z+hugAAAAAAA6oqAABlAGUAXQBdAGxtTU1YWFdXWgAAAKxBAACsQQAAuEEAALhBAAAAAJAA3ZizPtqfobsAAAAAAAM1KgAAPAA8AEYARgBvb0xMWFhXV1oAAACsQQAArEEAALhBAAC4QQAAAACRAA+Vfz4AAACAAAAAAAADMyoAADUANQA8ADwAb25MTFhYV1daAAAArEEAAKxBAAC4QQAAuEEAAAAAkQBXJ3I+ttEnvAAAAAAAA0YqAAA1ADUAPQA9AHBwTExYWFdXWgAAAKxBAACsQQAAuEEAALhBAAAAAJIAHeFyPlRDOjwAAAAAAAOcKgAANgA2AD4APQBvbkxMWFhXV1oAAACsQQAArEEAALhBAAC4QQAAAACQAN+mKz\/ZnyE7AAAAAAADbioAAFUAVQBYAFgAY2RMTFZXV1daAAAArEEAAKxBAAC4QQAAuEEAAAAAjQBxoyQ\/AAAAgAAAAAAAA3QpAABMAEsAUwBTAGtrTExYWFdXWgAAAKxBAACsQQAAuEEAALhBAAAAAIsAP8MeP9mfITsAAAAAAAPzKAAAPgA+AEcASABwcExMWVlXV1oAAACsQQAArEEAALhBAAC4QQAAAACIAOhvFz8az9W7AAAAAAADDSgAAC8ALwA0ADQAb29MTFhYV1daAAAArEEAAKxBAAC4QQAAuEEAAAAAhwBgoxY\/2Z8hOwAAAAAAA8UnAABFAEUAUABQAHBxTExYWVdXWgAAAKxBAACsQQAAuEEAALhBAAAAAJQA2nQiPwAAAIAAAAAAAAOhKwAUPAA8AEYARgBwcUxMWVlXV2EAAACsQQAArEEAALhBAAC4QQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD\/\/wA=","5AcBEgEH5ZT1LObzaqnDgK1ArwAAABP\/AAECPADve9tCAADcQrC\/W0LIMswQCQAAAAEBAAAREAABAQAAAAAAAAAAAHWSbUoCAAAAAPmVqUYAbPpHAAECPAC2p9tCAADcQmXtW0LIMswQCQAAAAAAAAAREAAAAAAAAAAAAAAAAHW6ckoBAAAAAEAJnEaU4SNHAAECPAC6pdtCAADcQl3pW0LIMswQCQAAAAEBAAAREAABAQAAAAAAAAAAACJQcEoC8RkGRZz26UbwQrdHAAECPADYnttCAADcQs3eW0LIMqsNCQAAAAEBAAAREAABAQAAAAAAAAAAAMcIb0oCAAAAAG01kUaGVcVHAAECPADuidtCAADcQuDFW0LIMswQCQAAAAEBAAAREAABAQAAAAAAAAAAAMnTb0oCAAAAAJcreUYmEKZHAAECPACbgNtCAADcQj65W0LIMswQCQAAAAAAAAAREAAAAAAAAAAAAAAAAIZAckoCAAAAACYluEa530BHAAECPAAtf9tCAADcQtu1W0LIMtcOCQAAAAEBAAAREAABAQAAAAAAAAAAACZFc0oCoheoRNqqoEYwQehGAAECPADJf9tCAADcQty0W0LIMswQCQAAAAEBAAAREAABAQAAAAAAAAAAAPhyc0oCAAAAAGQQtEYBE9hGAAECPACvhNtCAADcQpC4W0LIMswQCQAAAAEBAAAREAABAQAAAAAAAAAAADePc0oCAAAAALTSmEYHj8FGAAECPACng9tCAADcQjK0W0LIMtcOCQAAAAABAAAREAAAAQAAAAAAAAAAAJDPc0oCAAAAAEj7QEb6ooZGAAECPAAZpttCAADcQiDYW0LIMswQCQAAAAEBAAAREAABAQAAAAAAAAAAAFz2c0oCLdPGRKbZEkbgcilGAAECPADGpttCAADcQlLXW0LIMtcOCQAAAAEBAAAREAABAQAAAAAAAAAAALv\/c0oCAAAAABfLr0WWt71EAAECPADHpNtCAADcQp\/TW0LIMswQCQAAAAEBAAAREAABAQAAAAAAAAAAAAAkdEoBsPzJRBDQmEZi9ZBGAAECPAAeoNtCAADcQu\/MW0LIMqsNCQAAAAEBAAAREAABAQAAAAAAAAAAAMDOc0oCe7rKRL7KqEZYv9VGAAECPAAys9tCAADcQsnfW0LIMqsNCQAAAAAAAAAREAAAAAAAAAAAAAAAAK3Ac0oCAAAAAJqkZUZBf5VGAAECPABQoNtCAADcQmfJW0LIMswQCQAAAAAAAAAREAAAAAAAAAAAAAAAAGnpc0oCAAAAAJalTEa8n2RGAAECPAA3rdtCAADcQtPVW0LIMswQCQAAAAEBAAAREAABAQAAAAAAAAAAAPEBdEoCAAAAAHyGP0ZmUDtGAAECPAA5f9tCAADcQuihW0LIMswQCQAAAAEBAAAREAABAQAAAAAAAAAAAPkVdEoCAAAAAGelKUbwBw1GAAECPADyrNtCAADcQlXSW0LIMqsNCQAAAAEBAAAREAABAQAAAAAAAAAAAAAkdEoBAAAAAPiX\/0VacWlFAAECPAAglttCAADcQhTKW0LIMtcOCQAAAAEBAAAREAABAQAAAAAAAAAAABNsc0oCAAAAABuuXUZUmsNGAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","5AcBEgEC5ZT1LObzaqmnaq5AsAAAABP\/AAAAAFNarEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAaA\/9CGgP\/QgAAAIABAQAAAAABAQIAAAAAU1qsQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKOz6EKjs+hCAAAAgAIBAAAAAAIBAgAAAABTWqxAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAArTjVQq041UIAAACAAwEAAAAAAwECAAAAAFNarEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADT4LVC0+C1QgAAAIAEAQAAAAAEAQIAAAAAU1qsQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGhEoEJoRKBCAAAAgAUBAAAAAAUBAgAAAABTWqxAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAqKWGQqilhkIAAACABgEAAAAABgECAAAAAFNarEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH\/WZCh\/1mQgAAAIAHAQAAAAAHAQIAAAAAU1qsQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABRWREIUVkRCAAAAgAgBAAAAAAgBAgAAAABTWqxAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAApTMiQqUzIkIAAACACQEAAAAACQECAAAAAFNarEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAByBr5Bcga+QQAAAIALAQAAAAALAQIAAAAAU1qsQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAxkbUEMZG1BAAAAgAwBAAAAAAwBAgAAAABTWqxAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAzwjbQM8I20AAAACADQEAAAAADQECAAAAAFNarEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQHy\/AEB8vwAAAIAOAQACAAAOAQIAAAAAU1qsQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAByE8EAchPBAAAAgA8BAAIAAA8BAgAAAABTWqxAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMycwQDMnMEAAACAEAEAAgAAEAECAAAAAFNarEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAieXBAInlwQAAAIARAQACAAARAQIAAAAAU1qsQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAC1FcIAtRXCAAAAgBIBAAIAABIBAgAAAABTWqxAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA84wgAPOMIAAACAEwEAAgAAEwECAAAAAFNarEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMVjCADFYwgAAAIAUAQACAAAUAQIAAAAAU1qsQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGI4AkJiOAJCAAAAgAoBAAAAAAoBAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=","5AcBEgEA5ZT1LObzaqmnaq5AsAAAABP\/7h5NwxJQF0An6rxDJlEVwv9STb78IQ3C+6KM\/xWo61cGAPuitPF8OqKmWD9LYWM89\/0UwAAyMrtKE0S5dOpBw6MqGECvLL9DGusRwr5aRr5c+QXCuaGJ\/2+pkVaw\/7mhpzNRvPc6Nj9wZLm8thEUwADshrrMjCA7OmM8w00uG0BqT8NDGWkFwt7Orb7JXwrCK6cX\/9yjJVxd\/yunlr3fPPbsQD+ICxu80PcXwADosbq0yKM72YUvwz4cIkD78MdDaVERwuiOrb4GCQTCW6El\/9apK1ao\/1uhEmc6vnWZTT8Rndy9gswTwAAQTbuetzE79Lkqw9B8JUBEN81D3nIKwv61Er698QLCAaOc\/w6o8lfM\/wGjBDKHvLoKHT\/moxE+ygIVwAAMlbr\/FdA6uwcew8J+KECq4s9DV9gCwlIrXL6NHPDBsKFP\/3mpiFaw\/7ChLPoHvYjEsz+MyiA8UAsUwADEGLvj\/yE7R64aw3iELUCdBNVDBnf1wQkhTr4PLefB1KJu\/z6owlen\/9WiaBIWvBgkgT\/2ESG9\/OEUwAD0tLp3yjM7xroQw4v1LkD4INZDDUv1wZBNHL6DAOHBraGJ\/3yphFap\/62h4XntvNcgeD89EpI8yAgUwACwZ7r5gi47VQ4Ow0QUM0Aq3NpDDWrwwf3bQr6MX+PBAaNp\/w+o8leT\/wGjT19JOs5NdT9mEQc8jQIVwADYibpEJVs77ukBw5OtNkDcqeBD+Q7twccy0L0JB9\/Bx6Kn\/0yotFdj\/8eiN\/Y9O9pLWz9QaI+7ItgUwACQqzqt0507Y43wwmqmOEAhHeJD5QP0wW+GTL46oNPBTp9G\/yWs3FOk\/06fIB5yPdptxT5JZgU+3kASwABaILtcTTk70jjqwoz5OkB6NeZDWqPlwSPggL6mSuPBC6UY\/\/WlDFoBAAqlXYjdPHWiFz6+DZG9TXkWwACwprtGLVi4g1rZwtoMP0Axf+dDgKLzwXF0mL6FENXBsJ\/n\/rarS1Q5AK+fwDiIPaYsvj2k75+98YoSwABB+buAjeS6hFLTwq9EQUDr1utDAxLowZ7qE75eVeXB9qRZ\/wmm9lmfAPWkj2p9vnc3tz0yuIS9cmoWwAAz57uAu5+7zdW9wgpKREAZxu1DO47uweMCSL78gtrBnaFb\/42pc1ZhAJ2he7TAvBIJdD9okFg8Pf0TwADdu7smckO7UIW3wkHOSED8qPJD0YTkwQAWVr7xQdrBc6NR\/5enaVhYAHOjPKT8uIrkZj8ZvMS7NVUVwAD+u7uatTG7efejwg4lS0Dj6PNDN13mwWupY76W4dLBmKFB\/5SpbFaQAJehkiDLvJH0Zj\/0ptU6u\/gTwACs7ruxm5C7ZEmewlOaTkDamPhDG9Ddwa90Yr6pHtLB4KI+\/zKozVfDAN+iqm6OvhBCTz+WGRq89ukUwIDGCbzQhMO7aJ2LwhJzUEAQkvlDMIXfwWmoHL4uas3BwqF1\/2apmlaGAMGh6gO6vPXTRD\/GLba96RcUwAAXwbs0FYa7VS0Fw1OQM0BrCtxDDgX8wecLSL6fb9nBFJ9h\/2esmVNp\/xWfZEMVPDYsYj+giYw86RQSwABoMLr0j5c7AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJHnLQErByECOyXxAIn2HQMxtXUDqxXA\/pAVOP8zVCT6gNd9CtmsXQX1miMI8s7\/Cg50nQkanJ0JPVyZCSlwmQvDa6zs33O87DQv3ueFo87naG8e7oDH6O+VtJkKWSgg8UB04O+NpMzvv5bm7WtcXvUZMHb4AAACA","5AcBEgEC5ZT1LObzaqnXlqxArgAAABP\/AAAAANmFqkAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADSLPlC0iz5QgAAAIABAQAAAAABAQIAAAAA2YWqQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABgS40IYEuNCAAAAgAIBAAAAAAIBAgAAAADZhapAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAccLPQnHCz0IAAACAAwEAAAAAAwECAAAAANmFqkAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADnTLBC50ywQgAAAIAEAQAAAAAEAQIAAAAA2YWqQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACjZmkIo2ZpCAAAAgAUBAAAAAAUBAgAAAADZhapAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMJmBQjCZgUIAAACABgEAAAAABgECAAAAANmFqkAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB8bV1CfG1dQgAAAIAHAQAAAAAHAQIAAAAA2YWqQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMvlOkLL5TpCAAAAgAgBAAAAAAgBAgAAAADZhapAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAc9AYQnPQGEIAAACACQEAAAAACQECAAAAANmFqkAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAyjKtBMoyrQQAAAIALAQAAAAALAQIAAAAA2YWqQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJSmSEGUpkhBAAAAgAwBAAAAAAwBAgAAAADZhapAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAU0uRQFNLkUAAAACADQEAAAAADQECAAAAANmFqkAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAoFLAAKBSwAAAAIAOAQACAAAOAQIAAAAA2YWqQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACyOMEAsjjBAAAAgA8BAAIAAA8BAgAAAADZhapAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACSvwQAkr8EAAACAEAEAAgAAEAECAAAAANmFqkAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAePfBAHj3wQAAAIARAQACAAARAQIAAAAA2YWqQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAICQHsKAkB7CAAAAgBIBAAIAABIBAgAAAADZhapAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAALtAwgC7QMIAAACAEwEAAgAAEwECAAAAANmFqkAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAz2DCAM9gwgAAAIAUAQACAAAUAQIAAAAA2YWqQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKWT8UGlk\/FBAAAAgAoBAAAAAAoBAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="]
\ No newline at end of file
diff --git a/app/src/test/resources/replay/test.cap b/app/src/test/resources/replay/test.cap
new file mode 100644
index 0000000..524fc00
Binary files /dev/null and b/app/src/test/resources/replay/test.cap differ
diff --git a/build.gradle b/build.gradle
index ed2225e..3bd5ee3 100644
--- a/build.gradle
+++ b/build.gradle
@@ -1,9 +1,8 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
- ext.kotlin_version = "1.5.10"
- ext.rxkotlin_version = '2.1.0'
+ ext.kotlin_version = "1.5.20"
+ ext.rxkotlin_version = '2.4.0'
ext.rxandroid_version = '2.1.1'
- ext.acra_version = '5.8.2'
repositories {
google()