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()