From 54ddd11e48d12e028f979d0a2a728029452d6be0 Mon Sep 17 00:00:00 2001 From: Matthias Urhahn <matthias.urhahn@sap.com> Date: Thu, 7 Jan 2021 10:25:38 +0100 Subject: [PATCH] Add debug log feature for non-tester builds (EXPOSUREAPP-4451) (#2029) * Initial draft for user accessible debug logs in production. TODO: Sharing, Tests, Cleanup FileLoggerTree? * Ready strings for translation. * Log file compression for sharing (zip). * Add log sharing * Change how DebugLogger is initialized to make sure we can record issues that happen very early in the apps lifecycle. * Add missing toolbar back arrow navigation. * Fix initial delay for ui state emissions. * Censor registration token. * Adjust warning regarding sensitive data in debug logs. * Use property injection instead of component getters. * Hide option until greenlit. * Add test for RegistrationTokenCensor * Unit tests. * Make unit test without triggerfile more specific. * LINTs * Fix missing injection provider in release mode. * Fix regtoken censor if condition. * Fix typos. * Remove empty manifest specific to deviceForTesters build. * Wait until log job is canceled before deleting log files. Otherwise a race condition could lead to file creation after job cancellation. * Replace runBlockingTest with runBlocking we don't need scheduler control and runBlockingTest is sometimes unreliable. * Refactor FileSharing to use compat builder from androidx. * Handle exceptions on debug log start() * Print device infos when log is started. * Text changes requested by UA Co-authored-by: ralfgehrer <mail@ralfgehrer.com> --- .../src/deviceForTesters/AndroidManifest.xml | 21 --- .../res/xml/provider_paths.xml | 4 - Corona-Warn-App/src/main/AndroidManifest.xml | 14 +- .../coronawarnapp/CoronaWarnApplication.kt | 2 + .../bugreporting/BugReportingSharedModule.kt | 22 +++ .../bugreporting/censors/BugCensor.kt | 11 ++ .../censors/RegistrationTokenCensor.kt | 23 +++ .../bugreporting/debuglog/DebugLogTree.kt | 34 ++++ .../bugreporting/debuglog/DebugLogger.kt | 166 +++++++++++++++++ .../bugreporting/debuglog/DebugLoggerBase.kt | 12 ++ .../bugreporting/debuglog/DebugLoggerScope.kt | 23 +++ .../bugreporting/debuglog/LogLine.kt | 28 +++ .../debuglog/ui/DebugLogFragment.kt | 62 +++++++ .../debuglog/ui/DebugLogFragmentModule.kt | 20 +++ .../debuglog/ui/DebugLogViewModel.kt | 115 ++++++++++++ .../ui/information/InformationFragment.kt | 25 ++- .../information/InformationFragmentModule.kt | 3 +- .../de/rki/coronawarnapp/util/CWADebug.kt | 18 +- .../coronawarnapp/util/compression/Zipper.kt | 44 +++++ .../util/di/ApplicationComponent.kt | 5 + .../coronawarnapp/util/sharing/FileSharing.kt | 66 +++++++ .../util/threads/NamedThreadFactory.kt | 17 ++ .../layout/bugreporting_debuglog_fragment.xml | 114 ++++++++++++ .../main/res/layout/fragment_information.xml | 26 ++- .../src/main/res/navigation/nav_graph.xml | 7 + .../src/main/res/values-de/strings.xml | 21 +++ .../src/main/res/values/strings.xml | 22 +++ .../src/main/res/xml/provider_paths.xml | 9 + .../censors/RegistrationTokenCensorTest.kt | 86 +++++++++ .../bugreporting/debuglog/DebugLoggerTest.kt | 170 ++++++++++++++++++ 30 files changed, 1143 insertions(+), 47 deletions(-) delete mode 100644 Corona-Warn-App/src/deviceForTesters/AndroidManifest.xml delete mode 100644 Corona-Warn-App/src/deviceForTesters/res/xml/provider_paths.xml create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/bugreporting/BugReportingSharedModule.kt create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/bugreporting/censors/BugCensor.kt create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/bugreporting/censors/RegistrationTokenCensor.kt create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/bugreporting/debuglog/DebugLogTree.kt create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/bugreporting/debuglog/DebugLogger.kt create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/bugreporting/debuglog/DebugLoggerBase.kt create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/bugreporting/debuglog/DebugLoggerScope.kt create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/bugreporting/debuglog/LogLine.kt create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/bugreporting/debuglog/ui/DebugLogFragment.kt create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/bugreporting/debuglog/ui/DebugLogFragmentModule.kt create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/bugreporting/debuglog/ui/DebugLogViewModel.kt create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/compression/Zipper.kt create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/sharing/FileSharing.kt create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/threads/NamedThreadFactory.kt create mode 100644 Corona-Warn-App/src/main/res/layout/bugreporting_debuglog_fragment.xml create mode 100644 Corona-Warn-App/src/main/res/xml/provider_paths.xml create mode 100644 Corona-Warn-App/src/test/java/de/rki/coronawarnapp/bugreporting/censors/RegistrationTokenCensorTest.kt create mode 100644 Corona-Warn-App/src/test/java/de/rki/coronawarnapp/bugreporting/debuglog/DebugLoggerTest.kt diff --git a/Corona-Warn-App/src/deviceForTesters/AndroidManifest.xml b/Corona-Warn-App/src/deviceForTesters/AndroidManifest.xml deleted file mode 100644 index ca1b20da3..000000000 --- a/Corona-Warn-App/src/deviceForTesters/AndroidManifest.xml +++ /dev/null @@ -1,21 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<manifest xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:tools="http://schemas.android.com/tools" - tools:ignore="LockedOrientationActivity" - package="de.rki.coronawarnapp"> - - <application> - - <provider - android:name="androidx.core.content.FileProvider" - android:authorities="${applicationId}.fileProvider" - android:exported="false" - android:grantUriPermissions="true"> - <meta-data - android:name="android.support.FILE_PROVIDER_PATHS" - android:resource="@xml/provider_paths"/> - </provider> - - </application> - -</manifest> \ No newline at end of file diff --git a/Corona-Warn-App/src/deviceForTesters/res/xml/provider_paths.xml b/Corona-Warn-App/src/deviceForTesters/res/xml/provider_paths.xml deleted file mode 100644 index b6522dfad..000000000 --- a/Corona-Warn-App/src/deviceForTesters/res/xml/provider_paths.xml +++ /dev/null @@ -1,4 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<paths xmlns:android="http://schemas.android.com/apk/res/android"> - <cache-path name="share" path="share/" /> -</paths> \ No newline at end of file diff --git a/Corona-Warn-App/src/main/AndroidManifest.xml b/Corona-Warn-App/src/main/AndroidManifest.xml index 3c07d693f..e058ffe95 100644 --- a/Corona-Warn-App/src/main/AndroidManifest.xml +++ b/Corona-Warn-App/src/main/AndroidManifest.xml @@ -80,9 +80,19 @@ android:name=".contactdiary.ui.ContactDiaryActivity" android:exported="false" android:screenOrientation="portrait" - android:launchMode= "singleTop" + android:launchMode="singleTop" android:theme="@style/AppTheme.ContactDiary" - android:windowSoftInputMode="adjustResize"/> + android:windowSoftInputMode="adjustResize" /> + + <provider + android:name="androidx.core.content.FileProvider" + android:authorities="${applicationId}.fileProvider" + android:exported="false" + android:grantUriPermissions="true"> + <meta-data + android:name="android.support.FILE_PROVIDER_PATHS" + android:resource="@xml/provider_paths" /> + </provider> </application> diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/CoronaWarnApplication.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/CoronaWarnApplication.kt index 4fbdaee7f..4f4b34c52 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/CoronaWarnApplication.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/CoronaWarnApplication.kt @@ -66,6 +66,8 @@ class CoronaWarnApplication : Application(), HasAndroidInjector { Timber.v("onCreate(): Initializing Dagger") AppInjector.init(this) + CWADebug.initAfterInjection(component) + Timber.plant(rollingLogHistory) Timber.v("onCreate(): WorkManager setup done: $workManager") diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/bugreporting/BugReportingSharedModule.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/bugreporting/BugReportingSharedModule.kt new file mode 100644 index 000000000..99c18ab32 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/bugreporting/BugReportingSharedModule.kt @@ -0,0 +1,22 @@ +package de.rki.coronawarnapp.bugreporting + +import dagger.Module +import dagger.Provides +import de.rki.coronawarnapp.bugreporting.censors.BugCensor +import de.rki.coronawarnapp.bugreporting.censors.RegistrationTokenCensor +import de.rki.coronawarnapp.bugreporting.debuglog.DebugLogger +import javax.inject.Singleton + +@Module +class BugReportingSharedModule { + + @Singleton + @Provides + fun debugLogger() = DebugLogger + + @Singleton + @Provides + fun censors( + registrationTokenCensor: RegistrationTokenCensor + ): List<BugCensor> = listOf(registrationTokenCensor) +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/bugreporting/censors/BugCensor.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/bugreporting/censors/BugCensor.kt new file mode 100644 index 000000000..e9c2c69ea --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/bugreporting/censors/BugCensor.kt @@ -0,0 +1,11 @@ +package de.rki.coronawarnapp.bugreporting.censors + +import de.rki.coronawarnapp.bugreporting.debuglog.LogLine + +interface BugCensor { + + /** + * If there is something to censor a new log line is returned, otherwise returns null + */ + fun checkLog(entry: LogLine): LogLine? +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/bugreporting/censors/RegistrationTokenCensor.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/bugreporting/censors/RegistrationTokenCensor.kt new file mode 100644 index 000000000..5832e2f6f --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/bugreporting/censors/RegistrationTokenCensor.kt @@ -0,0 +1,23 @@ +package de.rki.coronawarnapp.bugreporting.censors + +import dagger.Reusable +import de.rki.coronawarnapp.bugreporting.debuglog.LogLine +import de.rki.coronawarnapp.storage.LocalData +import de.rki.coronawarnapp.util.CWADebug +import javax.inject.Inject +import kotlin.math.min + +@Reusable +class RegistrationTokenCensor @Inject constructor() : BugCensor { + override fun checkLog(entry: LogLine): LogLine? { + val token = LocalData.registrationToken() ?: return null + if (!entry.message.contains(token)) return null + + val replacement = if (CWADebug.isDeviceForTestersBuild) { + token + } else { + token.substring(0, min(4, token.length)) + "###-####-####-####-############" + } + return entry.copy(message = entry.message.replace(token, replacement)) + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/bugreporting/debuglog/DebugLogTree.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/bugreporting/debuglog/DebugLogTree.kt new file mode 100644 index 000000000..68dc7f451 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/bugreporting/debuglog/DebugLogTree.kt @@ -0,0 +1,34 @@ +package de.rki.coronawarnapp.bugreporting.debuglog + +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import timber.log.Timber + +class DebugLogTree : Timber.DebugTree() { + + private val logLinesPub = MutableSharedFlow<LogLine>( + replay = 128, + extraBufferCapacity = 1024, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + val logLines: Flow<LogLine> = logLinesPub + + init { + Timber.tag(TAG).d("init()") + } + + override fun log(priority: Int, tag: String?, message: String, t: Throwable?) { + LogLine( + timestamp = System.currentTimeMillis(), + priority = priority, + tag = tag, + message = message, + throwable = t + ).also { logLinesPub.tryEmit(it) } + } + + companion object { + private const val TAG = "DebugLogTree" + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/bugreporting/debuglog/DebugLogger.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/bugreporting/debuglog/DebugLogger.kt new file mode 100644 index 000000000..2cdd042ea --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/bugreporting/debuglog/DebugLogger.kt @@ -0,0 +1,166 @@ +package de.rki.coronawarnapp.bugreporting.debuglog + +import android.annotation.SuppressLint +import android.app.Application +import android.content.Context +import android.util.Log +import de.rki.coronawarnapp.util.di.ApplicationComponent +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.yield +import timber.log.Timber +import java.io.File + +@SuppressLint("LogNotTimber") +@Suppress("BlockingMethodInNonBlockingContext") +object DebugLogger : DebugLoggerBase() { + + private val scope = DebugLoggerScope + private lateinit var context: Context + + private val debugDir by lazy { + File(context.cacheDir, "debuglog").also { + if (!it.exists()) it.mkdir() + } + } + + private val triggerFile by lazy { File(debugDir, "debug.trigger") } + + internal val runningLog by lazy { File(debugDir, "debug.log") } + val sharedDirectory by lazy { File(debugDir, "shared") } + + private val mutex = Mutex() + + private var logJob: Job? = null + private var logTree: DebugLogTree? = null + private var isDaggerReady = false + + fun init(application: Application) { + context = application + + try { + if (triggerFile.exists()) { + Timber.tag(TAG).i("Trigger file exists, starting debug log.") + runBlocking { start() } + } + } catch (e: Exception) { + // This is called from Application.onCreate() never crash here. + Timber.tag(TAG).e(e, "DebugLogger init(%s) failed.", application) + } + } + + /** + * To censor unique data, we need to actually know what to censor. + * So we buffer log statements until Dagger is ready + */ + fun setInjectionIsReady(component: ApplicationComponent) { + Timber.tag(TAG).i("setInjectionIsReady()") + component.inject(this) + isDaggerReady = true + } + + val isLogging: Boolean + get() = logJob?.isActive == true + + suspend fun start(): Unit = mutex.withLock { + Timber.tag(TAG).d("start()") + + if (isLogging) { + Timber.tag(TAG).w("Ignoring start(), already running.") + return@withLock + } + + logJob?.cancel() + logTree?.let { Timber.uproot(it) } + + DebugLogTree().apply { + Timber.plant(this) + logTree = this + + if (!runningLog.exists()) { + runningLog.parentFile?.mkdirs() + if (runningLog.createNewFile()) { + Timber.tag(TAG).i("Log file didn't exist and was created.") + } + } + + logJob = scope.launch { + try { + logLines.collect { rawLine -> + while (!isDaggerReady) { + yield() + } + val censoredLine = bugCensors.get().mapNotNull { it.checkLog(rawLine) }.firstOrNull() + appendLogLine(censoredLine ?: rawLine) + } + } catch (e: CancellationException) { + Timber.tag(TAG).i("Logging was canceled.") + } catch (e: Exception) { + Log.e(TAG, "Failed to call appendLogLine(...)", e) + } + } + } + + if (!triggerFile.exists()) { + Timber.tag(TAG).i("Trigger file created.") + triggerFile.createNewFile() + } + } + + suspend fun stop() = mutex.withLock { + Timber.tag(TAG).i("stop()") + + if (triggerFile.exists() && triggerFile.delete()) { + Timber.tag(TAG).d("Trigger file deleted.") + } + + logTree?.let { + Timber.tag(TAG).d("LogTree uprooted.") + Timber.uproot(it) + } + logTree = null + + logJob?.let { + Timber.tag(TAG).d("LogJob canceled.") + it.cancel() + it.join() + } + logJob = null + + if (runningLog.exists() && runningLog.delete()) { + Timber.tag(TAG).d("Log file was deleted.") + } + + clearSharedFiles() + } + + private fun appendLogLine(line: LogLine) { + val formattedLine = line.format(context) + runningLog.appendText(formattedLine, Charsets.UTF_8) + } + + fun getLogSize(): Long = runningLog.length() + + fun getShareSize(): Long = sharedDirectory.listFiles() + ?.fold(0L) { prev, file -> prev + file.length() } + ?: 0L + + fun clearSharedFiles() { + if (!sharedDirectory.exists()) return + + sharedDirectory.listFiles()?.forEach { + if (it.delete()) { + Timber.tag(TAG).d("Deleted shared file: %s", it) + } else { + Timber.tag(TAG).w("Failed to delete shared file: %s", it) + } + } + } + + private const val TAG = "DebugLogger" +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/bugreporting/debuglog/DebugLoggerBase.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/bugreporting/debuglog/DebugLoggerBase.kt new file mode 100644 index 000000000..ee056fbd5 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/bugreporting/debuglog/DebugLoggerBase.kt @@ -0,0 +1,12 @@ +package de.rki.coronawarnapp.bugreporting.debuglog + +import de.rki.coronawarnapp.bugreporting.censors.BugCensor +import javax.inject.Inject + +/** + * Workaround for dagger injection into kotlin objects + */ +@Suppress("UnnecessaryAbstractClass") +abstract class DebugLoggerBase { + @Inject internal lateinit var bugCensors: dagger.Lazy<List<BugCensor>> +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/bugreporting/debuglog/DebugLoggerScope.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/bugreporting/debuglog/DebugLoggerScope.kt new file mode 100644 index 000000000..4943b1f11 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/bugreporting/debuglog/DebugLoggerScope.kt @@ -0,0 +1,23 @@ +package de.rki.coronawarnapp.bugreporting.debuglog + +import de.rki.coronawarnapp.util.threads.NamedThreadFactory +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.asCoroutineDispatcher +import java.util.concurrent.Executors +import javax.inject.Qualifier +import javax.inject.Singleton +import kotlin.coroutines.CoroutineContext + +@Singleton +object DebugLoggerScope : CoroutineScope { + val dispatcher = Executors.newSingleThreadExecutor( + NamedThreadFactory("DebugLogger") + ).asCoroutineDispatcher() + override val coroutineContext: CoroutineContext = SupervisorJob() + dispatcher +} + +@Qualifier +@MustBeDocumented +@Retention(AnnotationRetention.RUNTIME) +annotation class AppScope diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/bugreporting/debuglog/LogLine.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/bugreporting/debuglog/LogLine.kt new file mode 100644 index 000000000..bbc527eee --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/bugreporting/debuglog/LogLine.kt @@ -0,0 +1,28 @@ +package de.rki.coronawarnapp.bugreporting.debuglog + +import android.content.Context +import android.util.Log +import org.joda.time.Instant + +data class LogLine( + val timestamp: Long, + val priority: Int, + val tag: String?, + val message: String, + val throwable: Throwable? +) { + + fun format(context: Context): String { + val time = Instant.ofEpochMilli(timestamp) + return "$time ${priorityLabel(priority)}/$tag: $message\n" + } + + private fun priorityLabel(priority: Int): String = when (priority) { + Log.ERROR -> "E" + Log.WARN -> "W" + Log.INFO -> "I" + Log.DEBUG -> "D" + Log.VERBOSE -> "V" + else -> priority.toString() + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/bugreporting/debuglog/ui/DebugLogFragment.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/bugreporting/debuglog/ui/DebugLogFragment.kt new file mode 100644 index 000000000..def69cfa0 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/bugreporting/debuglog/ui/DebugLogFragment.kt @@ -0,0 +1,62 @@ +package de.rki.coronawarnapp.bugreporting.debuglog.ui + +import android.os.Bundle +import android.text.format.Formatter +import android.view.View +import android.widget.Toast +import androidx.core.view.isGone +import androidx.fragment.app.Fragment +import de.rki.coronawarnapp.R +import de.rki.coronawarnapp.databinding.BugreportingDebuglogFragmentBinding +import de.rki.coronawarnapp.util.di.AutoInject +import de.rki.coronawarnapp.util.ui.observe2 +import de.rki.coronawarnapp.util.ui.popBackStack +import de.rki.coronawarnapp.util.ui.viewBindingLazy +import de.rki.coronawarnapp.util.viewmodel.CWAViewModelFactoryProvider +import de.rki.coronawarnapp.util.viewmodel.cwaViewModels +import javax.inject.Inject + +class DebugLogFragment : Fragment(R.layout.bugreporting_debuglog_fragment), AutoInject { + + @Inject lateinit var viewModelFactory: CWAViewModelFactoryProvider.Factory + private val vm: DebugLogViewModel by cwaViewModels { viewModelFactory } + private val binding: BugreportingDebuglogFragmentBinding by viewBindingLazy() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + vm.state.observe2(this) { + binding.apply { + debuglogActivityIndicator.isGone = !it.isRecording + debuglogStatusPrimary.text = getString( + if (it.isRecording) R.string.debugging_debuglog_status_recording + else R.string.debugging_debuglog_status_not_recording + ) + debuglogStatusSecondary.text = getString( + R.string.debugging_debuglog_status_additional_infos, + Formatter.formatFileSize(context, it.currentSize) + ) + toggleRecording.text = getString( + if (it.isRecording) R.string.debugging_debuglog_action_stop_recording + else R.string.debugging_debuglog_action_start_recording + ) + shareRecording.isEnabled = it.currentSize > 0L && !it.sharingInProgress + toggleRecording.isEnabled = !it.sharingInProgress + } + } + + vm.errorEvent.observe2(this) { + Toast.makeText(requireContext(), it.toString(), Toast.LENGTH_LONG).show() + } + + vm.shareEvent.observe2(this) { + startActivity(it.get(requireActivity())) + } + + binding.apply { + toggleRecording.setOnClickListener { vm.toggleRecording() } + shareRecording.setOnClickListener { vm.shareRecording() } + toolbar.setNavigationOnClickListener { popBackStack() } + } + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/bugreporting/debuglog/ui/DebugLogFragmentModule.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/bugreporting/debuglog/ui/DebugLogFragmentModule.kt new file mode 100644 index 000000000..11725335a --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/bugreporting/debuglog/ui/DebugLogFragmentModule.kt @@ -0,0 +1,20 @@ +package de.rki.coronawarnapp.bugreporting.debuglog.ui + +import dagger.Binds +import dagger.Module +import dagger.android.ContributesAndroidInjector +import dagger.multibindings.IntoMap +import de.rki.coronawarnapp.util.viewmodel.CWAViewModel +import de.rki.coronawarnapp.util.viewmodel.CWAViewModelFactory +import de.rki.coronawarnapp.util.viewmodel.CWAViewModelKey + +@Module +abstract class DebugLogFragmentModule { + @Binds + @IntoMap + @CWAViewModelKey(DebugLogViewModel::class) + abstract fun onboardingNotificationsVM(factory: DebugLogViewModel.Factory): CWAViewModelFactory<out CWAViewModel> + + @ContributesAndroidInjector + abstract fun debuglogFragment(): DebugLogFragment +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/bugreporting/debuglog/ui/DebugLogViewModel.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/bugreporting/debuglog/ui/DebugLogViewModel.kt new file mode 100644 index 000000000..027a0793c --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/bugreporting/debuglog/ui/DebugLogViewModel.kt @@ -0,0 +1,115 @@ +package de.rki.coronawarnapp.bugreporting.debuglog.ui + +import androidx.lifecycle.LiveData +import androidx.lifecycle.asLiveData +import com.squareup.inject.assisted.AssistedInject +import de.rki.coronawarnapp.R +import de.rki.coronawarnapp.bugreporting.debuglog.DebugLogger +import de.rki.coronawarnapp.nearby.ENFClient +import de.rki.coronawarnapp.util.CWADebug +import de.rki.coronawarnapp.util.TimeStamper +import de.rki.coronawarnapp.util.compression.Zipper +import de.rki.coronawarnapp.util.coroutine.DispatcherProvider +import de.rki.coronawarnapp.util.sharing.FileSharing +import de.rki.coronawarnapp.util.ui.SingleLiveEvent +import de.rki.coronawarnapp.util.viewmodel.CWAViewModel +import de.rki.coronawarnapp.util.viewmodel.SimpleCWAViewModelFactory +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flow +import org.joda.time.format.DateTimeFormat +import timber.log.Timber +import java.io.File + +class DebugLogViewModel @AssistedInject constructor( + private val debugLogger: DebugLogger, + dispatcherProvider: DispatcherProvider, + private val timeStamper: TimeStamper, + private val fileSharing: FileSharing, + private val enfClient: ENFClient +) : CWAViewModel(dispatcherProvider = dispatcherProvider) { + private val ticker = flow { + while (true) { + emit(Unit) + delay(500) + } + } + private val manualTick = MutableStateFlow(Unit) + private val sharingInProgress = MutableStateFlow(false) + val state: LiveData<State> = combine(ticker, manualTick, sharingInProgress) { _, _, sharingInProgress -> + State( + isRecording = debugLogger.isLogging, + currentSize = debugLogger.getLogSize() + debugLogger.getShareSize(), + sharingInProgress = sharingInProgress + ) + }.asLiveData(context = dispatcherProvider.Default) + + val errorEvent = SingleLiveEvent<Throwable>() + val shareEvent = SingleLiveEvent<FileSharing.ShareIntentProvider>() + + fun toggleRecording() = launch { + try { + if (debugLogger.isLogging) { + debugLogger.stop() + } else { + debugLogger.start() + printExtendedLogInfos() + } + } catch (e: Exception) { + errorEvent.postValue(e) + } finally { + manualTick.value = Unit + } + } + + private suspend fun printExtendedLogInfos() { + CWADebug.logDeviceInfos() + try { + val enfVersion = enfClient.getENFClientVersion() + Timber.tag("ENFClient").i("ENF Version: %d", enfVersion) + } catch (e: Exception) { + Timber.tag("ENFClient").e(e, "Failed to get ENF version for debug log.") + } + } + + fun shareRecording() { + sharingInProgress.value = true + launch { + try { + debugLogger.clearSharedFiles() + + val now = timeStamper.nowUTC + val formatter = DateTimeFormat.forPattern("yyyy-MM-dd HH:mm:ss.SSS") + val formattedFileName = "CWA Log ${now.toString(formatter)}" + val zipFile = File(debugLogger.sharedDirectory, "$formattedFileName.zip") + + Zipper(zipFile).zip( + listOf(Zipper.Entry(name = "$formattedFileName.txt", path = debugLogger.runningLog)) + ) + + val intentProvider = fileSharing.getIntentProvider( + path = zipFile, + title = zipFile.name, + chooserTitle = R.string.debugging_debuglog_sharing_dialog_title + ) + + shareEvent.postValue(intentProvider) + } catch (e: Exception) { + Timber.e(e, "Sharing debug log failed.") + errorEvent.postValue(e) + } finally { + sharingInProgress.value = false + } + } + } + + data class State( + val isRecording: Boolean, + val sharingInProgress: Boolean = false, + val currentSize: Long = 0 + ) + + @AssistedInject.Factory + interface Factory : SimpleCWAViewModelFactory<DebugLogViewModel> +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/information/InformationFragment.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/information/InformationFragment.kt index caac006b3..198e37eae 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/information/InformationFragment.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/information/InformationFragment.kt @@ -5,15 +5,16 @@ import android.os.Bundle import android.view.View import android.view.accessibility.AccessibilityEvent import android.view.accessibility.AccessibilityNodeInfo +import androidx.core.view.isGone import androidx.fragment.app.Fragment -import androidx.navigation.fragment.findNavController import com.google.android.gms.nearby.exposurenotification.ExposureNotificationClient import de.rki.coronawarnapp.R import de.rki.coronawarnapp.databinding.FragmentInformationBinding -import de.rki.coronawarnapp.ui.doNavigate import de.rki.coronawarnapp.ui.main.MainActivity +import de.rki.coronawarnapp.util.CWADebug import de.rki.coronawarnapp.util.ExternalActionHelper import de.rki.coronawarnapp.util.di.AutoInject +import de.rki.coronawarnapp.util.ui.doNavigate import de.rki.coronawarnapp.util.ui.observe2 import de.rki.coronawarnapp.util.ui.setGone import de.rki.coronawarnapp.util.ui.viewBindingLazy @@ -55,6 +56,9 @@ class InformationFragment : Fragment(R.layout.fragment_information), AutoInject setButtonOnClickListener() setAccessibilityDelegate() + + // TODO Hidden until further clarification regarding release schedule is available + binding.informationDebuglog.mainRow.isGone = !CWADebug.isDeviceForTestersBuild } override fun onResume() { @@ -76,22 +80,22 @@ class InformationFragment : Fragment(R.layout.fragment_information), AutoInject private fun setButtonOnClickListener() { binding.informationAbout.mainRow.setOnClickListener { - findNavController().doNavigate( + doNavigate( InformationFragmentDirections.actionInformationFragmentToInformationAboutFragment() ) } binding.informationPrivacy.mainRow.setOnClickListener { - findNavController().doNavigate( + doNavigate( InformationFragmentDirections.actionInformationFragmentToInformationPrivacyFragment() ) } binding.informationTerms.mainRow.setOnClickListener { - findNavController().doNavigate( + doNavigate( InformationFragmentDirections.actionInformationFragmentToInformationTermsFragment() ) } binding.informationContact.mainRow.setOnClickListener { - findNavController().doNavigate( + doNavigate( InformationFragmentDirections.actionInformationFragmentToInformationContactFragment() ) } @@ -99,15 +103,20 @@ class InformationFragment : Fragment(R.layout.fragment_information), AutoInject ExternalActionHelper.openUrl(this, requireContext().getString(R.string.main_about_link)) } binding.informationLegal.mainRow.setOnClickListener { - findNavController().doNavigate( + doNavigate( InformationFragmentDirections.actionInformationFragmentToInformationLegalFragment() ) } binding.informationTechnical.mainRow.setOnClickListener { - findNavController().doNavigate( + doNavigate( InformationFragmentDirections.actionInformationFragmentToInformationTechnicalFragment() ) } + binding.informationDebuglog.mainRow.setOnClickListener { + doNavigate( + InformationFragmentDirections.actionInformationFragmentToDebuglogFragment() + ) + } binding.informationHeader.headerButtonBack.buttonIcon.setOnClickListener { (activity as MainActivity).goBack() } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/information/InformationFragmentModule.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/information/InformationFragmentModule.kt index 7b7473491..e52ddb992 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/information/InformationFragmentModule.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/information/InformationFragmentModule.kt @@ -4,11 +4,12 @@ import dagger.Binds import dagger.Module import dagger.android.ContributesAndroidInjector import dagger.multibindings.IntoMap +import de.rki.coronawarnapp.bugreporting.debuglog.ui.DebugLogFragmentModule import de.rki.coronawarnapp.util.viewmodel.CWAViewModel import de.rki.coronawarnapp.util.viewmodel.CWAViewModelFactory import de.rki.coronawarnapp.util.viewmodel.CWAViewModelKey -@Module +@Module(includes = [DebugLogFragmentModule::class]) abstract class InformationFragmentModule { @Binds @IntoMap diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/CWADebug.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/CWADebug.kt index 9b474acf2..4b959e3f8 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/CWADebug.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/CWADebug.kt @@ -3,7 +3,9 @@ package de.rki.coronawarnapp.util import android.app.Application import android.os.Build import de.rki.coronawarnapp.BuildConfig +import de.rki.coronawarnapp.bugreporting.debuglog.DebugLogger import de.rki.coronawarnapp.util.debug.FileLogger +import de.rki.coronawarnapp.util.di.ApplicationComponent import timber.log.Timber object CWADebug { @@ -19,9 +21,13 @@ object CWADebug { fileLogger = FileLogger(application) } - Timber.i("CWA version: %s (%s)", BuildConfig.VERSION_CODE, BuildConfig.GIT_COMMIT_SHORT_HASH) - Timber.i("CWA flavor: %s (%s)", BuildConfig.FLAVOR, BuildConfig.BUILD_TYPE) - Timber.i("Build.FINGERPRINT: %s", Build.FINGERPRINT) + DebugLogger.init(application) + + logDeviceInfos() + } + + fun initAfterInjection(component: ApplicationComponent) { + DebugLogger.setInjectionIsReady(component) } val isDebugBuildOrMode: Boolean @@ -45,4 +51,10 @@ object CWADebug { false } } + + fun logDeviceInfos() { + Timber.i("CWA version: %s (%s)", BuildConfig.VERSION_CODE, BuildConfig.GIT_COMMIT_SHORT_HASH) + Timber.i("CWA flavor: %s (%s)", BuildConfig.FLAVOR, BuildConfig.BUILD_TYPE) + Timber.i("Build.FINGERPRINT: %s", Build.FINGERPRINT) + } } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/compression/Zipper.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/compression/Zipper.kt new file mode 100644 index 000000000..2c7924c2b --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/compression/Zipper.kt @@ -0,0 +1,44 @@ +package de.rki.coronawarnapp.util.compression + +import timber.log.Timber +import java.io.File +import java.io.IOException +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +class Zipper(private val zipPath: File) { + + fun zip(toZip: List<Entry>) { + if (zipPath.exists()) throw IOException("$zipPath already exists") + + Timber.tag(TAG).d("Creating ZIP file: %s", zipPath) + zipPath.parentFile?.mkdirs() + zipPath.createNewFile() + + if (!zipPath.exists()) throw IOException("Could not create $zipPath") + + ZipOutputStream(zipPath.outputStream().buffered()).use { output -> + for (i in toZip.indices) { + Timber.tag(TAG).v("Compressing ${toZip[i]} into $zipPath") + + val item = toZip[i] + Timber.tag(TAG).v("Reading %s (size=%d)", item.path, item.path.length()) + item.path.inputStream().buffered().use { input -> + output.putNextEntry(ZipEntry(item.name)) + input.copyTo(output) + } + } + } + + Timber.tag(TAG).i("ZipFile finished: %s", zipPath) + } + + data class Entry( + val path: File, + val name: String = path.name + ) + + companion object { + private const val TAG = "ZipFile" + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/di/ApplicationComponent.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/di/ApplicationComponent.kt index ba3270685..793ba8478 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/di/ApplicationComponent.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/di/ApplicationComponent.kt @@ -9,6 +9,8 @@ import de.rki.coronawarnapp.appconfig.AppConfigModule import de.rki.coronawarnapp.appconfig.AppConfigProvider import de.rki.coronawarnapp.bugreporting.BugReporter import de.rki.coronawarnapp.bugreporting.BugReportingModule +import de.rki.coronawarnapp.bugreporting.BugReportingSharedModule +import de.rki.coronawarnapp.bugreporting.debuglog.DebugLogger import de.rki.coronawarnapp.contactdiary.ContactDiaryRootModule import de.rki.coronawarnapp.diagnosiskeys.DiagnosisKeysModule import de.rki.coronawarnapp.diagnosiskeys.DownloadDiagnosisKeysTaskModule @@ -64,6 +66,7 @@ import javax.inject.Singleton TaskModule::class, DeviceForTestersModule::class, BugReportingModule::class, + BugReportingSharedModule::class, SerializationModule::class, WorkerBinder::class, ContactDiaryRootModule::class @@ -88,6 +91,8 @@ interface ApplicationComponent : AndroidInjector<CoronaWarnApplication> { val bugReporter: BugReporter + fun inject(logger: DebugLogger) + @Component.Factory interface Factory { fun create(@BindsInstance app: CoronaWarnApplication): ApplicationComponent diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/sharing/FileSharing.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/sharing/FileSharing.kt new file mode 100644 index 000000000..59dff05d6 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/sharing/FileSharing.kt @@ -0,0 +1,66 @@ +package de.rki.coronawarnapp.util.sharing + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.annotation.StringRes +import androidx.core.app.ShareCompat +import androidx.core.content.FileProvider +import dagger.Reusable +import de.rki.coronawarnapp.BuildConfig +import de.rki.coronawarnapp.util.di.AppContext +import timber.log.Timber +import java.io.File +import javax.inject.Inject + +@Reusable +class FileSharing @Inject constructor( + @AppContext private val context: Context +) { + + private fun getFileUri(path: File): Uri = FileProvider.getUriForFile( + context, + AUTHORITY, + path + ) + + fun getIntentProvider( + path: File, + title: String, + @StringRes chooserTitle: Int? = null + ): ShareIntentProvider = object : ShareIntentProvider { + override fun get(activity: Activity): Intent { + val builder = ShareCompat.IntentBuilder.from(activity).apply { + setType(determineMimeType(path)) + setStream(getFileUri(path)) + setSubject(title) + chooserTitle?.let { setChooserTitle(it) } + } + + val intent = if (chooserTitle != null) { + builder.createChooserIntent() + } else { + builder.intent + } + return intent.apply { + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + Timber.tag(TAG).d("Intent created %s", this) + } + } + } + + interface ShareIntentProvider { + fun get(activity: Activity): Intent + } + + private fun determineMimeType(path: File): String = when { + path.name.endsWith(".zip") -> "application/zip" + else -> throw UnsupportedOperationException("Unsupported MIME type: $path") + } + + companion object { + private const val AUTHORITY = "${BuildConfig.APPLICATION_ID}.fileProvider" + private const val TAG = "FileSharing" + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/threads/NamedThreadFactory.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/threads/NamedThreadFactory.kt new file mode 100644 index 000000000..c25d83b95 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/threads/NamedThreadFactory.kt @@ -0,0 +1,17 @@ +package de.rki.coronawarnapp.util.threads + +import java.util.Locale +import java.util.concurrent.ThreadFactory +import java.util.concurrent.atomic.AtomicLong + +class NamedThreadFactory(private val threadPrefix: String) : ThreadFactory { + private val threadIndex = AtomicLong(1) + + override fun newThread(runnable: Runnable): Thread = Thread(runnable).apply { + name = if (threadPrefix.contains("%d")) { + String.format(Locale.ROOT, threadPrefix, threadIndex.getAndIncrement()) + } else { + "$threadPrefix-${threadIndex.getAndIncrement()}" + } + } +} diff --git a/Corona-Warn-App/src/main/res/layout/bugreporting_debuglog_fragment.xml b/Corona-Warn-App/src/main/res/layout/bugreporting_debuglog_fragment.xml new file mode 100644 index 000000000..c33bf0eb5 --- /dev/null +++ b/Corona-Warn-App/src/main/res/layout/bugreporting_debuglog_fragment.xml @@ -0,0 +1,114 @@ +<?xml version="1.0" encoding="utf-8"?> + +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="vertical"> + + <Toolbar + android:id="@+id/toolbar" + style="@style/CWAToolbar" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:navigationIcon="@drawable/ic_back" + android:title="@string/debugging_debuglog_title" /> + + <ScrollView + android:layout_width="match_parent" + android:layout_height="match_parent"> + + <LinearLayout + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="vertical"> + <TextView + android:id="@+id/explanation" + style="@style/body1" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_margin="@dimen/spacing_normal" + android:text="@string/debugging_debuglog_intro_explanation" /> + + <TextView + android:id="@+id/sensitive_information" + style="@style/body1" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:background="@color/cwaGrayHighlight" + android:padding="@dimen/spacing_normal" + android:text="@string/debugging_debuglog_intro_warning" + android:textColor="@color/colorStableLight" /> + + <androidx.constraintlayout.widget.ConstraintLayout + style="@style/cardTracing" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginStart="@dimen/spacing_small" + android:layout_marginTop="@dimen/spacing_normal" + android:layout_marginEnd="@dimen/spacing_small" + android:layout_marginBottom="@dimen/spacing_tiny"> + + <ProgressBar + android:id="@+id/debuglog_activity_indicator" + android:layout_width="36dp" + android:layout_height="36dp" + android:visibility="gone" + app:layout_constraintBottom_toBottomOf="@+id/debuglog_status_secondary" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="@+id/debuglog_status_primary" + tools:visibility="visible" /> + + <TextView + android:id="@+id/debuglog_status_primary" + style="@style/body1" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_marginStart="16dp" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toEndOf="@+id/debuglog_activity_indicator" + app:layout_constraintTop_toTopOf="parent" + app:layout_goneMarginStart="0dp" + tools:text="@string/debugging_debuglog_status_not_recording" /> + + <TextView + android:id="@+id/debuglog_status_secondary" + style="@style/TextAppearance.AppCompat.Caption" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_marginStart="16dp" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toEndOf="@+id/debuglog_activity_indicator" + app:layout_constraintTop_toBottomOf="@id/debuglog_status_primary" + app:layout_goneMarginStart="0dp" + tools:text="@string/debugging_debuglog_status_additional_infos" /> + + <Button + android:id="@+id/share_recording" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_marginTop="16dp" + android:layout_marginEnd="8dp" + android:enabled="false" + android:text="@string/debugging_debuglog_action_share_log" + app:layout_constraintEnd_toStartOf="@+id/toggle_recording" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/debuglog_status_secondary" /> + <Button + android:id="@+id/toggle_recording" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_marginStart="8dp" + android:layout_marginTop="16dp" + android:text="@string/debugging_debuglog_action_start_recording" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toEndOf="@id/share_recording" + app:layout_constraintTop_toBottomOf="@id/debuglog_status_secondary" /> + + + </androidx.constraintlayout.widget.ConstraintLayout> + </LinearLayout> + + </ScrollView> +</LinearLayout> \ No newline at end of file diff --git a/Corona-Warn-App/src/main/res/layout/fragment_information.xml b/Corona-Warn-App/src/main/res/layout/fragment_information.xml index 09abab386..783c68a5e 100644 --- a/Corona-Warn-App/src/main/res/layout/fragment_information.xml +++ b/Corona-Warn-App/src/main/res/layout/fragment_information.xml @@ -106,29 +106,39 @@ app:layout_constraintTop_toBottomOf="@+id/information_contact" app:subtitle="@{@string/information_legal_title}" /> + <include + android:id="@+id/information_debuglog" + layout="@layout/include_row" + android:layout_width="match_parent" + android:layout_height="wrap_content" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@+id/information_contact" + app:subtitle="@{@string/debugging_debuglog_title}" /> + <TextView android:id="@+id/information_version" style="@style/body2Medium" android:layout_width="match_parent" android:layout_height="wrap_content" + android:layout_marginStart="@dimen/guideline_body_title_padding" android:layout_marginTop="@dimen/spacing_small" android:focusable="true" - tools:text="v1.8.0-RC1" - android:layout_marginStart="@dimen/guideline_body_title_padding" /> + tools:text="v1.8.0-RC1" /> <TextView android:id="@+id/information_enf_version" style="@style/body2Medium" - android:visibility="gone" - tools:visibility="visible" android:layout_width="match_parent" - android:paddingTop="@dimen/spacing_tiny" - android:paddingBottom="@dimen/spacing_tiny" android:layout_height="wrap_content" - android:focusable="true" + android:layout_marginStart="@dimen/guideline_body_title_padding" android:background="?selectableItemBackground" + android:focusable="true" + android:paddingTop="@dimen/spacing_tiny" + android:paddingBottom="@dimen/spacing_tiny" + android:visibility="gone" tools:text="16000000" - android:layout_marginStart="@dimen/guideline_body_title_padding" /> + tools:visibility="visible" /> </LinearLayout> diff --git a/Corona-Warn-App/src/main/res/navigation/nav_graph.xml b/Corona-Warn-App/src/main/res/navigation/nav_graph.xml index 92042f0b6..63f88702b 100644 --- a/Corona-Warn-App/src/main/res/navigation/nav_graph.xml +++ b/Corona-Warn-App/src/main/res/navigation/nav_graph.xml @@ -168,6 +168,9 @@ <action android:id="@+id/action_informationFragment_to_informationTechnicalFragment" app:destination="@id/informationTechnicalFragment" /> + <action + android:id="@+id/action_informationFragment_to_debuglogFragment" + app:destination="@id/debuglogFragment" /> </fragment> <fragment @@ -479,4 +482,8 @@ android:name="de.rki.coronawarnapp.ui.submission.testresult.invalid.SubmissionTestResultInvalidFragment" android:label="SubmissionTestResultInvalidFragment" tools:layout="@layout/fragment_submission_test_result_invalid" /> + <fragment + android:id="@+id/debuglogFragment" + android:name="de.rki.coronawarnapp.bugreporting.debuglog.ui.DebugLogFragment" + android:label="DebuglogFragment" /> </navigation> diff --git a/Corona-Warn-App/src/main/res/values-de/strings.xml b/Corona-Warn-App/src/main/res/values-de/strings.xml index 8d4bccf40..fcd6ecfe4 100644 --- a/Corona-Warn-App/src/main/res/values-de/strings.xml +++ b/Corona-Warn-App/src/main/res/values-de/strings.xml @@ -733,6 +733,27 @@ <!-- XACT: describes illustration --> <string name="information_legal_illustration_description">"Eine Hand hält ein Smartphone mit viel Text, daneben ist ein Paragraphenzeichen als Symbol für das Impressum."</string> + <!-- XHED: Headline for debug log screen --> + <string name="debugging_debuglog_title">"Fehlerbericht"</string> + <!-- YTXT: Description for the debug option to record log files --> + <string name="debugging_debuglog_intro_explanation">"Durch diese Option wird das App-Verhalten in einer Textdatei protokolliert. Ist die Option aktiviert bevor ein Fehler auftritt, können Sie diesen Bericht zur Verfügung stellen, um die Entwickler bei der Problemlösung zu unterstützen.\nEine dauerhafte Aktivierung dieser Option führt zu hohem Speicherbedarf. Der Fehlerbericht wird durch Deaktivieren der Option gelöscht."</string> + <!-- YTXT: Warning regarding downsides of recording a log file --> + <string name="debugging_debuglog_intro_warning">"Bitte beachten Sie, dass in Fehlerberichten Informationen über Risikoberechnung und Testergebnis enthalten sein können. Aus diesem Grund sollten Sie Fehlerberichte nicht öffentlich teilen."</string> + <!-- XBUT: Button text to start the log recording --> + <string name="debugging_debuglog_action_start_recording">"Starten"</string> + <!-- XBUT: Button text to stop the log recording --> + <string name="debugging_debuglog_action_stop_recording">"Löschen"</string> + <!-- XBUT: Button text to share the log recording --> + <string name="debugging_debuglog_action_share_log">"Teilen"</string> + <!-- YTXT: Status text if a debug log is being recorded --> + <string name="debugging_debuglog_status_recording">"Aufzeichnung läuft"</string> + <!-- YTXT: Status text if a debug log is not being recorded --> + <string name="debugging_debuglog_status_not_recording">"Inaktiv"</string> + <!-- YTXT: Describtion for current logging status text if a debug log is not being recorded --> + <string name="debugging_debuglog_status_additional_infos">"Derzeitige Größe: %1$s (unkomprimiert)"</string> + <!-- XHED: Title for native sharing dialog --> + <string name="debugging_debuglog_sharing_dialog_title">"CWA Fehlerbericht teilen"</string> + <!-- #################################### Interoperability ###################################### --> diff --git a/Corona-Warn-App/src/main/res/values/strings.xml b/Corona-Warn-App/src/main/res/values/strings.xml index 4a26af31f..85b8f9eef 100644 --- a/Corona-Warn-App/src/main/res/values/strings.xml +++ b/Corona-Warn-App/src/main/res/values/strings.xml @@ -739,6 +739,28 @@ <!-- XACT: describes illustration --> <string name="information_legal_illustration_description">"A hand holds a smartphone displaying a large body of text on the screen. Next to the text is a section symbol representing the imprint."</string> + + <!-- XHED: Headline for debug log screen --> + <string name="debugging_debuglog_title" /> + <!-- YTXT: Description for the debug option to record log files --> + <string name="debugging_debuglog_intro_explanation"></string> + <!-- YTXT: Warning regarding downsides of recording a log file --> + <string name="debugging_debuglog_intro_warning" /> + <!-- XBUT: Button text to start the log recording --> + <string name="debugging_debuglog_action_start_recording" /> + <!-- XBUT: Button text to stop the log recording --> + <string name="debugging_debuglog_action_stop_recording" /> + <!-- XBUT: Button text to share the log recording --> + <string name="debugging_debuglog_action_share_log" /> + <!-- YTXT: Status text if a debug log is being recorded --> + <string name="debugging_debuglog_status_recording" /> + <!-- YTXT: Status text if a debug log is not being recorded --> + <string name="debugging_debuglog_status_not_recording" /> + <!-- YTXT: Describtion for current logging status text if a debug log is not being recorded --> + <string name="debugging_debuglog_status_additional_infos">"%1$s"</string> + <!-- XHED: Title for native sharing dialog --> + <string name="debugging_debuglog_sharing_dialog_title" /> + <!-- #################################### Interoperability ###################################### --> diff --git a/Corona-Warn-App/src/main/res/xml/provider_paths.xml b/Corona-Warn-App/src/main/res/xml/provider_paths.xml new file mode 100644 index 000000000..cc1e648e7 --- /dev/null +++ b/Corona-Warn-App/src/main/res/xml/provider_paths.xml @@ -0,0 +1,9 @@ +<?xml version="1.0" encoding="utf-8"?> +<paths> + <cache-path + name="share" + path="share/" /> + <cache-path + name="shared_logs" + path="debuglog/shared/" /> +</paths> \ No newline at end of file diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/bugreporting/censors/RegistrationTokenCensorTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/bugreporting/censors/RegistrationTokenCensorTest.kt new file mode 100644 index 000000000..8c13580ef --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/bugreporting/censors/RegistrationTokenCensorTest.kt @@ -0,0 +1,86 @@ +package de.rki.coronawarnapp.bugreporting.censors + +import de.rki.coronawarnapp.bugreporting.debuglog.LogLine +import de.rki.coronawarnapp.storage.LocalData +import de.rki.coronawarnapp.util.CWADebug +import io.kotest.matchers.shouldBe +import io.mockk.MockKAnnotations +import io.mockk.clearAllMocks +import io.mockk.every +import io.mockk.mockkObject +import io.mockk.verify +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import testhelpers.BaseTest + +class RegistrationTokenCensorTest : BaseTest() { + + private val testToken = "63b4d3ff-e0de-4bd4-90c1-17c2bb683a2f" + + @BeforeEach + fun setup() { + MockKAnnotations.init(this) + + mockkObject(CWADebug) + every { CWADebug.isDeviceForTestersBuild } returns false + + mockkObject(LocalData) + every { LocalData.registrationToken() } returns testToken + } + + @AfterEach + fun teardown() { + clearAllMocks() + } + + private fun createInstance() = RegistrationTokenCensor() + + @Test + fun `censoring replaces the logline message`() { + val instance = createInstance() + val filterMe = LogLine( + timestamp = 1, + priority = 3, + message = "I'm a shy registration token: $testToken", + tag = "I'm a tag", + throwable = null + ) + instance.checkLog(filterMe) shouldBe filterMe.copy( + message = "I'm a shy registration token: 63b4###-####-####-####-############" + ) + + verify { LocalData.registrationToken() } + } + + @Test + fun `censoring returns null if thereis no match`() { + val instance = createInstance() + val filterMeNot = LogLine( + timestamp = 1, + priority = 3, + message = "I'm not a registration token ;)", + tag = "I'm a tag", + throwable = null + ) + instance.checkLog(filterMeNot) shouldBe null + } + + @Test + fun `token is not censored on tester builds`() { + every { CWADebug.isDeviceForTestersBuild } returns true + val instance = createInstance() + val filterMe = LogLine( + timestamp = 1, + priority = 3, + message = "I'm a shy registration token: $testToken", + tag = "I'm a tag", + throwable = null + ) + instance.checkLog(filterMe) shouldBe filterMe.copy( + message = "I'm a shy registration token: 63b4d3ff-e0de-4bd4-90c1-17c2bb683a2f" + ) + + verify { LocalData.registrationToken() } + } +} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/bugreporting/debuglog/DebugLoggerTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/bugreporting/debuglog/DebugLoggerTest.kt new file mode 100644 index 000000000..99678b827 --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/bugreporting/debuglog/DebugLoggerTest.kt @@ -0,0 +1,170 @@ +package de.rki.coronawarnapp.bugreporting.debuglog + +import android.app.Application +import dagger.Lazy +import de.rki.coronawarnapp.bugreporting.censors.RegistrationTokenCensor +import de.rki.coronawarnapp.util.di.ApplicationComponent +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.mockk.MockKAnnotations +import io.mockk.clearAllMocks +import io.mockk.every +import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import testhelpers.BaseIOTest +import testhelpers.logging.JUnitTree +import timber.log.Timber +import java.io.File +import kotlin.random.Random + +class DebugLoggerTest : BaseIOTest() { + + @MockK lateinit var application: Application + @MockK lateinit var component: ApplicationComponent + @MockK lateinit var registrationTokenCensor: RegistrationTokenCensor + + private val testDir = File(IO_TEST_BASEDIR, this::class.simpleName!!) + private val cacheDir = File(testDir, "cache") + private val debugLogDir = File(cacheDir, "debuglog") + private val sharedDir = File(debugLogDir, "shared") + private val runningLog = File(debugLogDir, "debug.log") + private val triggerFile = File(debugLogDir, "debug.trigger") + + @BeforeEach + fun setup() { + MockKAnnotations.init(this) + testDir.mkdirs() + testDir.exists() shouldBe true + + every { application.cacheDir } returns cacheDir + every { component.inject(any<DebugLogger>()) } answers { + val logger = arg<DebugLogger>(0) + logger.bugCensors = Lazy { listOf(registrationTokenCensor) } + } + } + + @AfterEach + fun teardown() { + runBlocking { DebugLogger.stop() } + testDir.deleteRecursively() + clearAllMocks() + Timber.uprootAll() + } + + private fun createInstance() = DebugLogger + + @Test + fun `init does nothing if there is no trigger file`() { + createInstance().apply { + init(application) + isLogging shouldBe false + } + runningLog.exists() shouldBe false + Timber.forest().apply { + size shouldBe 1 + (first() is JUnitTree) shouldBe true + } + } + + @Test + fun `init calls start if there is a trigger file`() { + triggerFile.parentFile?.mkdirs() + triggerFile.createNewFile() + createInstance().apply { + init(application) + isLogging shouldBe true + } + runningLog.exists() shouldBe true + } + + @Test + fun `start plants a tree and starts a logging coroutine`() { + val instance = createInstance().apply { + init(application) + isLogging shouldBe false + } + + Timber.forest().none { it is DebugLogTree } shouldBe true + + runBlocking { + instance.start() + Timber.forest().single { it is DebugLogTree } shouldNotBe null + } + } + + @Test + fun `multiple start have no effect`() { + val instance = createInstance().apply { + init(application) + isLogging shouldBe false + } + + Timber.forest().none { it is DebugLogTree } shouldBe true + + File(sharedDir, "1").apply { + parentFile?.mkdirs() + appendBytes(Random.nextBytes(10)) + } + + runBlocking { + instance.start() + instance.start() + instance.start() + + Timber.forest().single { it is DebugLogTree } shouldNotBe null + sharedDir.listFiles()!!.size shouldBe 1 + + instance.stop() + instance.stop() + + Timber.forest().none { it is DebugLogTree } shouldBe true + DebugLogger.isLogging shouldBe false + sharedDir.listFiles()!!.size shouldBe 0 + } + } + + @Test + fun `stop cancels the coroutine and uproots the tree and deletes any logs`() { + val instance = createInstance().apply { + init(application) + isLogging shouldBe false + } + + Timber.forest().none { it is DebugLogTree } shouldBe true + + runBlocking { + instance.start() + Timber.forest().single { it is DebugLogTree } shouldNotBe null + + instance.stop() + Timber.forest().none { it is DebugLogTree } shouldBe true + DebugLogger.isLogging shouldBe false + + runningLog.exists() shouldBe false + } + } + + @Test + fun `log size returns current logfile size`() { + runningLog.parentFile?.mkdirs() + runningLog.appendBytes(Random.nextBytes(22)) + createInstance().apply { + init(application) + getLogSize() shouldBe 22 + } + } + + @Test + fun `shared size aggregates shared folder size`() { + sharedDir.mkdirs() + File(sharedDir, "1").apply { appendBytes(Random.nextBytes(10)) } + File(sharedDir, "2").apply { appendBytes(Random.nextBytes(15)) } + createInstance().apply { + init(application) + getShareSize() shouldBe 25 + } + } +} -- GitLab