diff --git a/app/build.gradle b/app/build.gradle index b0cde5d..d6cde0d 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -62,4 +62,6 @@ dependencies { implementation 'androidx.appcompat:appcompat:1.4.1' implementation 'androidx.constraintlayout:constraintlayout:2.1.4' implementation 'com.google.android.material:material:1.6.1' + implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.4.0" + implementation "androidx.datastore:datastore-preferences:${versions.dataStore}" } \ No newline at end of file diff --git a/app/src/main/java/com/steamclock/steamclogsample/App.kt b/app/src/main/java/com/steamclock/steamclogsample/App.kt index 1e2f87b..2abe43e 100644 --- a/app/src/main/java/com/steamclock/steamclogsample/App.kt +++ b/app/src/main/java/com/steamclock/steamclogsample/App.kt @@ -1,7 +1,15 @@ package com.steamclock.steamclogsample import android.app.Application +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.preferencesDataStore import com.steamclock.steamclog.* +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map /** * steamclog @@ -10,13 +18,16 @@ import com.steamclock.steamclog.* class App : Application() { override fun onCreate() { super.onCreate() - clog.initWith(Config( - isDebug = BuildConfig.DEBUG, - fileWritePath = externalCacheDir, - autoRotateConfig = AutoRotateConfig(10L), // Short rotate so we can more easily test - filtering = appFiltering, - detailedLogsOnUserReports = true - )) + clog.initWith( + application = this, + config = Config( + isDebug = BuildConfig.DEBUG, + fileWritePath = externalCacheDir, + autoRotateConfig = AutoRotateConfig(10L), // Short rotate so we can more easily test + filtering = App.appFiltering, + detailedLogsOnUserReports = true + ) + ) } companion object { diff --git a/app/src/main/java/com/steamclock/steamclogsample/MainActivity.kt b/app/src/main/java/com/steamclock/steamclogsample/MainActivity.kt index f130154..1b03e56 100644 --- a/app/src/main/java/com/steamclock/steamclogsample/MainActivity.kt +++ b/app/src/main/java/com/steamclock/steamclogsample/MainActivity.kt @@ -1,5 +1,6 @@ package com.steamclock.steamclogsample +import android.app.Application import android.content.ClipData import android.content.ClipboardManager import android.content.Context @@ -8,14 +9,25 @@ import android.view.View import android.widget.AdapterView import android.widget.Toast import androidx.appcompat.app.AppCompatActivity +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import com.steamclock.steamclog.* import com.steamclock.steamclogsample.databinding.ActivityMainBinding import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import java.io.File import java.text.SimpleDateFormat -import java.util.Date - +import java.util.* class MainActivity : AppCompatActivity() { @@ -60,6 +72,14 @@ class MainActivity : AppCompatActivity() { binding.enableExtraConfigInfo.setOnCheckedChangeListener { _, checked -> clog.config.extraInfo = if (checked) { this::getExtraInfo } else null } + binding.forceInvalidPathCheck.setOnCheckedChangeListener { _, checked -> + val filePath = if (checked) File("Idontexist") else externalCacheDir + val updatedConfig = updateConfigFilePath(filePath) + clog.initWith( + application = application, + config = updatedConfig + ) + } binding.addUserId.setOnClickListener { clog.setUserId("1234") } @@ -106,13 +126,32 @@ class MainActivity : AppCompatActivity() { clog.warn("LogLevel changed to ${clog.config.logLevel.title}") } } + + // Testing app DataStore to make sure having a Steamclog datastore won't interfere with an + // app's local DataStore; this test won't be visible in app, devs can verify datastore read/write + // in console logs after the app is launched. + lifecycleScope.launch { + lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { + // Doesn't need to be lifecycle aware for this test + val timestamp = + AppDataStore(application).apply { + var testValueBefore = getTestValue.firstOrNull() + setTestValue("UpdatedValue @ ${Date().time}") + var testValueAfter = getTestValue.firstOrNull() + clog.debug("Testing app DataStore functionality") + clog.debug("Before: $testValueBefore, After: $testValueAfter") + } + } + } + } } + private fun showMessageIfCrashReportingNotEnabled() { if (clog.config.logLevel.remote == LogLevel.None) { Toast.makeText(applicationContext, "Set Log Level to Release or Release Advanced to enable crash reporting", Toast.LENGTH_LONG).show() - } } @@ -245,6 +284,21 @@ class MainActivity : AppCompatActivity() { Toast.makeText(applicationContext, "Copied to clipboard", Toast.LENGTH_LONG).show() } + private fun updateConfigFilePath(newFileWritePath: File?): Config { + // Only the fileWrite path is changed, everything else retains current config values. + return Config( + isDebug = clog.config.isDebug, + fileWritePath = newFileWritePath, + keepLogsForDays = clog.config.keepLogsForDays, + autoRotateConfig = clog.config.autoRotateConfig, + requireRedacted = clog.config.requireRedacted, + filtering = clog.config.filtering, + logLevel = clog.config.logLevel, + detailedLogsOnUserReports = clog.config.detailedLogsOnUserReports, + extraInfo = clog.config.extraInfo + ) + } + // Test logging objects class RedactableParent : Any(), Redactable { val safeProp = "name" @@ -272,4 +326,21 @@ class MainActivity : AppCompatActivity() { object TestButtonPressed: AnalyticEvent("test_button_pressed", mapOf()) object TestButtonPressedWithRedactable: AnalyticEvent("test_button_pressed", mapOf("redactableObject" to RedactableChild())) } +} + +/** + * Verifying that we can have a DataStore in the app "separate" from the Steamclog DataStore + */ +private class AppDataStore(private val application: Application) { + companion object { + private val Context.AppDataStore: DataStore by preferencesDataStore(name = "AppDataStore") + private val testKey = stringPreferencesKey("testKey") + } + val getTestValue: Flow + get() = application.AppDataStore.data.map { + it[testKey] ?: "DefaultText" + } + suspend fun setTestValue(value: String) { + application.AppDataStore.edit { it[testKey] = value } + } } \ 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 fa02b46..815b869 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -61,6 +61,12 @@ android:layout_width="match_parent" android:layout_height="wrap_content" /> + + diff --git a/build.gradle b/build.gradle index a6f53ed..b5fe1d7 100644 --- a/build.gradle +++ b/build.gradle @@ -5,7 +5,8 @@ buildscript { "compileSdk": 33, "kotlin": "1.8.10", "timber": "5.0.1", - "sentry": "6.23.0" + "sentry": "6.23.0", + "dataStore" : "1.0.0" ] repositories { diff --git a/steamclog/build.gradle b/steamclog/build.gradle index d8e6949..ad36acd 100644 --- a/steamclog/build.gradle +++ b/steamclog/build.gradle @@ -64,4 +64,5 @@ dependencies { implementation "com.jakewharton.timber:timber:${versions.timber}" // https://github.com/getsentry/sentry-java/releases implementation "io.sentry:sentry-android:${versions.sentry}" + implementation "androidx.datastore:datastore-preferences:${versions.dataStore}" } \ No newline at end of file diff --git a/steamclog/src/main/java/com/steamclock/steamclog/SClogDataStore.kt b/steamclog/src/main/java/com/steamclock/steamclog/SClogDataStore.kt new file mode 100644 index 0000000..2aadbed --- /dev/null +++ b/steamclog/src/main/java/com/steamclock/steamclog/SClogDataStore.kt @@ -0,0 +1,33 @@ +package com.steamclock.steamclog + +import android.app.Application +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.preferencesDataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +/** + * https://developer.android.com/topic/libraries/architecture/datastore + */ +class SClogDataStore(private val application: Application) { + companion object { + private val Context.SClogDataStore: DataStore by preferencesDataStore(name = "SClogDataStore") + private val hasReportedFilepathErrorKey = booleanPreferencesKey("has_logged_file_creation_failure") + } + + /** + * hasReportedFilepathError indicates if Steamclog has reported a Sentry error regarding + * it's inability to use the given filePath to store logs. + */ + val getHasReportedFilepathError: Flow + get() = application.SClogDataStore.data.map { + it[hasReportedFilepathErrorKey] ?: false + } + suspend fun setHasReportedFilepathError(value: Boolean) { + application.SClogDataStore.edit { it[hasReportedFilepathErrorKey] = value } + } +} \ No newline at end of file diff --git a/steamclog/src/main/java/com/steamclock/steamclog/Steamclog.kt b/steamclog/src/main/java/com/steamclock/steamclog/Steamclog.kt index 774c472..cce5de6 100644 --- a/steamclog/src/main/java/com/steamclock/steamclog/Steamclog.kt +++ b/steamclog/src/main/java/com/steamclock/steamclog/Steamclog.kt @@ -2,8 +2,13 @@ package com.steamclock.steamclog +import android.app.Application +import android.content.Context import io.sentry.Sentry import io.sentry.protocol.User +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull import org.jetbrains.annotations.NonNls import timber.log.Timber import java.io.File @@ -27,6 +32,7 @@ object SteamcLog { private var customDebugTree: ConsoleDestination private var sentryTree: SentryDestination private var externalLogFileTree: ExternalLogFileDestination + private var coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) //--------------------------------------------- // Public properties @@ -48,16 +54,57 @@ object SteamcLog { // Don't plant yet; fileWritePath required before we can start writing to ExternalLogFileDestination } - fun initWith(config: Config) { + fun initWith(application: Application, config: Config) { this.config = config - this.config.fileWritePath?.let { + + // Setup ExternalLogFileDestination + if (this.config.fileWritePath != null && this.config.fileWritePath!!.exists()) { updateTree(externalLogFileTree, true) - } ?: run { - logInternal(LogLevel.Warn, "fileWritePath given was null; cannot log to external file") + } else { + // We have seen issues where some devices are failing to support some of the default file + // paths (ie. externalCacheDir); the code below attempts to catch that case and report it + // proactively to Sentry as an error once per app install. + checkForLogAccessError(application, this.config.fileWritePath) } logInternal(LogLevel.Info, "Steamclog initialized:\n$this") } + /** + * We have seen issues where some devices are failing to support some of the default file + * paths (ie. externalCacheDir); the code below attempts to catch that case and report it + * proactively to Sentry as an error once per app install. + */ + private fun checkForLogAccessError(application: Application, fileWritePath: File?) { + coroutineScope.launch { + // We have seen issues where some devices are failing to support some of the default file + // paths (ie. externalCacheDir); the code below attempts to catch that case and report it + // proactively to Sentry as an error once per app install. + val sentryErrorTitle = "Steamclog could not create external logs" + logInternal(LogLevel.Info, "File path $fileWritePath is invalid") + + try { + // Attempt to log error only once to avoid overwhelming Sentry. + // runBlocking usage was recommended in the google docs as the way to synchronously + // call the datastore methods; we need to use this with caution + // https://developer.android.com/topic/libraries/architecture/datastore#synchronous + val dataStore = SClogDataStore(application) + val alreadyReportedFailure = dataStore.getHasReportedFilepathError.firstOrNull() + val isSentryEnabled = clog.config.logLevel.remote != LogLevel.None + + if (isSentryEnabled && alreadyReportedFailure == false) { + logInternal(LogLevel.Error, sentryErrorTitle) + dataStore.setHasReportedFilepathError(true) + } else { + logInternal(LogLevel.Warn, sentryErrorTitle) + } + } catch (e: Exception) { + logInternal(LogLevel.Info, "Could not read local DataStore") + logInternal(LogLevel.Info, e.message ?: "No error message given") + logInternal(LogLevel.Error, sentryErrorTitle) + } + } + } + //--------------------------------------------- // Public Logging calls //