Implement sending logs to developer (#190)

* Save logs to a file

* Send logs via email

* Enable network logs in release builds

* Remove useless chooser title

* Append to logs file and ignore I/O errors

* Ensure email and password are not logged

* Ensure base URL is never logged

* Add logs disclaimer
This commit is contained in:
Kirill Kamakin
2023-12-10 12:49:03 +01:00
committed by GitHub
parent f6f44c7592
commit 36a72b63de
29 changed files with 500 additions and 157 deletions

View File

@@ -1,46 +1,56 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:name="gq.kirmanak.mealient.App"
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/full_backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher"
android:supportsRtl="true"
android:theme="@style/Theme.App.Starting"
android:localeConfig="@xml/locales_config"
tools:ignore="UnusedAttribute">
<activity
android:name=".ui.activity.MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustPan">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<application
android:name="gq.kirmanak.mealient.App"
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/full_backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:localeConfig="@xml/locales_config"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher"
android:supportsRtl="true"
android:theme="@style/Theme.App.Starting"
tools:ignore="UnusedAttribute">
<activity
android:name=".ui.activity.MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustPan">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".ui.share.ShareRecipeActivity"
android:exported="true"
android:theme="@style/AppTheme">
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
<meta-data
android:name="android.service.chooser.chooser_target_service"
android:value="androidx.sharetarget.ChooserTargetServiceCompat" />
</activity>
</application>
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".ui.share.ShareRecipeActivity"
android:exported="true"
android:theme="@style/AppTheme">
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
<meta-data
android:name="android.service.chooser.chooser_target_service"
android:value="androidx.sharetarget.ChooserTargetServiceCompat" />
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_provider_paths" />
</provider>
</application>
</manifest>

View File

@@ -15,17 +15,20 @@ class AuthRepoImpl @Inject constructor(
private val authDataSource: AuthDataSource,
private val logger: Logger,
private val signOutHandler: SignOutHandler,
private val credentialsLogRedactor: CredentialsLogRedactor,
) : AuthRepo, AuthenticationProvider {
override val isAuthorizedFlow: Flow<Boolean>
get() = authStorage.authTokenFlow.map { it != null }
override suspend fun authenticate(email: String, password: String) {
logger.v { "authenticate() called with: email = $email, password = $password" }
logger.v { "authenticate() called" }
credentialsLogRedactor.set(email, password)
val token = authDataSource.authenticate(email, password)
authStorage.setAuthToken(token)
val apiToken = authDataSource.createApiToken(API_TOKEN_NAME)
authStorage.setAuthToken(apiToken)
credentialsLogRedactor.clear()
}
override suspend fun getAuthToken(): String? = authStorage.getAuthToken()

View File

@@ -0,0 +1,38 @@
package gq.kirmanak.mealient.data.auth.impl
import gq.kirmanak.mealient.logging.LogRedactor
import kotlinx.coroutines.flow.MutableStateFlow
import java.net.URLEncoder
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class CredentialsLogRedactor @Inject constructor() : LogRedactor {
private data class Credentials(
val login: String,
val password: String,
val urlEncodedLogin: String = URLEncoder.encode(login, Charsets.UTF_8.name()),
val urlEncodedPassword: String = URLEncoder.encode(password, Charsets.UTF_8.name()),
)
private val credentialsState = MutableStateFlow<Credentials?>(null)
fun set(login: String, password: String) {
credentialsState.value = Credentials(login, password)
}
fun clear() {
credentialsState.value = null
}
override fun redact(message: String): String {
val credentials = credentialsState.value ?: return message
return message
.replace(credentials.login, "<login>")
.replace(credentials.urlEncodedLogin, "<login>")
.replace(credentials.password, "<password>")
.replace(credentials.urlEncodedPassword, "<password>")
}
}

View File

@@ -0,0 +1,49 @@
package gq.kirmanak.mealient.data.baseurl.impl
import androidx.core.net.toUri
import gq.kirmanak.mealient.architecture.configuration.AppDispatchers
import gq.kirmanak.mealient.data.baseurl.ServerInfoStorage
import gq.kirmanak.mealient.logging.LogRedactor
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Provider
import javax.inject.Singleton
@Singleton
class BaseUrlLogRedactor @Inject constructor(
private val serverInfoStorageProvider: Provider<ServerInfoStorage>,
private val dispatchers: AppDispatchers,
) : LogRedactor {
private val hostState = MutableStateFlow<String?>(null)
init {
setInitialBaseUrl()
}
private fun setInitialBaseUrl() {
val scope = CoroutineScope(dispatchers.default + SupervisorJob())
scope.launch {
val serverInfoStorage = serverInfoStorageProvider.get()
hostState.compareAndSet(
expect = null,
update = serverInfoStorage.getBaseURL()?.extractHost(),
)
}
}
fun set(baseUrl: String) {
hostState.value = baseUrl.extractHost()
}
override fun redact(message: String): String {
val host = hostState.value ?: return message
return message.replace(host, "<host>")
}
}
private fun String.extractHost() = runCatching { toUri() }.getOrNull()?.host

View File

@@ -4,13 +4,16 @@ import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
import gq.kirmanak.mealient.data.auth.AuthDataSource
import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.data.auth.AuthStorage
import gq.kirmanak.mealient.data.auth.impl.AuthDataSourceImpl
import gq.kirmanak.mealient.data.auth.impl.AuthRepoImpl
import gq.kirmanak.mealient.data.auth.impl.AuthStorageImpl
import gq.kirmanak.mealient.data.auth.impl.CredentialsLogRedactor
import gq.kirmanak.mealient.datasource.AuthenticationProvider
import gq.kirmanak.mealient.logging.LogRedactor
import gq.kirmanak.mealient.shopping_lists.repo.ShoppingListsAuthRepo
@Module
@@ -31,4 +34,8 @@ interface AuthModule {
@Binds
fun bindShoppingListsAuthRepo(impl: AuthRepoImpl): ShoppingListsAuthRepo
@Binds
@IntoSet
fun bindCredentialsLogRedactor(impl: CredentialsLogRedactor): LogRedactor
}

View File

@@ -4,9 +4,12 @@ import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
import gq.kirmanak.mealient.data.baseurl.*
import gq.kirmanak.mealient.data.baseurl.impl.BaseUrlLogRedactor
import gq.kirmanak.mealient.data.baseurl.impl.ServerInfoStorageImpl
import gq.kirmanak.mealient.datasource.ServerUrlProvider
import gq.kirmanak.mealient.logging.LogRedactor
@Module
@InstallIn(SingletonComponent::class)
@@ -23,4 +26,8 @@ interface BaseURLModule {
@Binds
fun bindServerUrlProvider(serverInfoRepoImpl: ServerInfoRepoImpl): ServerUrlProvider
@Binds
@IntoSet
fun bindBaseUrlLogRedactor(impl: BaseUrlLogRedactor): LogRedactor
}

View File

@@ -73,7 +73,6 @@ fun EditText.checkIfInputIsEmpty(
): String? {
val input = if (trim) text?.trim() else text
val text = input?.toString().orEmpty()
logger.d { "Input text is \"$text\"" }
return text.ifEmpty {
inputLayout.error = resources.getString(stringId)
val textChangesLiveData = textChangesLiveData(logger)

View File

@@ -1,8 +1,11 @@
package gq.kirmanak.mealient.ui.activity
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.MenuItem
import androidx.activity.viewModels
import androidx.core.content.FileProvider
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.isVisible
import androidx.core.view.iterator
@@ -10,6 +13,7 @@ import androidx.drawerlayout.widget.DrawerLayout
import androidx.navigation.NavController
import androidx.navigation.NavDirections
import androidx.navigation.fragment.NavHostFragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint
import gq.kirmanak.mealient.NavGraphDirections.Companion.actionGlobalAddRecipeFragment
import gq.kirmanak.mealient.NavGraphDirections.Companion.actionGlobalAuthenticationFragment
@@ -20,10 +24,13 @@ import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.databinding.MainActivityBinding
import gq.kirmanak.mealient.extensions.collectWhenResumed
import gq.kirmanak.mealient.extensions.observeOnce
import gq.kirmanak.mealient.logging.getLogFile
import gq.kirmanak.mealient.ui.ActivityUiState
import gq.kirmanak.mealient.ui.BaseActivity
import gq.kirmanak.mealient.ui.CheckableMenuItem
private const val EMAIL_FOR_LOGS = "mealient@gmail.com"
@AndroidEntryPoint
class MainActivity : BaseActivity<MainActivityBinding>(
binder = MainActivityBinding::bind,
@@ -87,6 +94,12 @@ class MainActivity : BaseActivity<MainActivityBinding>(
viewModel.logout()
return true
}
R.id.email_logs -> {
emailLogs()
return true
}
else -> throw IllegalArgumentException("Unknown menu item id: ${menuItem.itemId}")
}
menuItem.isChecked = true
@@ -94,6 +107,39 @@ class MainActivity : BaseActivity<MainActivityBinding>(
return true
}
private fun emailLogs() {
MaterialAlertDialogBuilder(this)
.setMessage(R.string.activity_main_email_logs_confirmation_message)
.setTitle(R.string.activity_main_email_logs_confirmation_title)
.setPositiveButton(R.string.activity_main_email_logs_confirmation_positive) { _, _ -> doEmailLogs() }
.setNegativeButton(R.string.activity_main_email_logs_confirmation_negative, null)
.show()
}
private fun doEmailLogs() {
val logFileUri = try {
FileProvider.getUriForFile(this, "$packageName.provider", getLogFile())
} catch (e: Exception) {
return
}
val emailIntent = buildIntent(logFileUri)
val chooserIntent = Intent.createChooser(emailIntent, null)
startActivity(chooserIntent)
}
private fun buildIntent(logFileUri: Uri?): Intent {
val emailIntent = Intent(Intent.ACTION_SEND)
val to = arrayOf(EMAIL_FOR_LOGS)
emailIntent.setType("text/plain")
emailIntent.putExtra(Intent.EXTRA_EMAIL, to)
emailIntent.putExtra(Intent.EXTRA_STREAM, logFileUri)
emailIntent.putExtra(
Intent.EXTRA_SUBJECT,
getString(R.string.activity_main_email_logs_subject)
)
return emailIntent
}
private fun onUiStateChange(uiState: ActivityUiState) {
logger.v { "onUiStateChange() called with: uiState = $uiState" }
val checkedMenuItem = when (uiState.checkedMenuItem) {

View File

@@ -22,7 +22,7 @@ class AuthenticationViewModel @Inject constructor(
val uiState: LiveData<OperationUiState<Unit>> get() = _uiState
fun authenticate(email: String, password: String) {
logger.v { "authenticate() called with: email = $email, password = $password" }
logger.v { "authenticate() called" }
_uiState.value = OperationUiState.Progress()
viewModelScope.launch {
val result = runCatchingExceptCancel { authRepo.authenticate(email, password) }

View File

@@ -7,6 +7,7 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.data.baseurl.ServerInfoRepo
import gq.kirmanak.mealient.data.baseurl.impl.BaseUrlLogRedactor
import gq.kirmanak.mealient.data.recipes.RecipeRepo
import gq.kirmanak.mealient.datasource.CertificateCombinedException
import gq.kirmanak.mealient.datasource.NetworkError
@@ -27,6 +28,7 @@ class BaseURLViewModel @Inject constructor(
private val recipeRepo: RecipeRepo,
private val logger: Logger,
private val trustedCertificatesStore: TrustedCertificatesStore,
private val baseUrlLogRedactor: BaseUrlLogRedactor,
) : ViewModel() {
private val _uiState = MutableLiveData<OperationUiState<Unit>>(OperationUiState.Initial())
@@ -36,18 +38,20 @@ class BaseURLViewModel @Inject constructor(
val invalidCertificatesFlow = invalidCertificatesChannel.receiveAsFlow()
fun saveBaseUrl(baseURL: String) {
logger.v { "saveBaseUrl() called with: baseURL = $baseURL" }
logger.v { "saveBaseUrl() called" }
_uiState.value = OperationUiState.Progress()
viewModelScope.launch { checkBaseURL(baseURL) }
}
private suspend fun checkBaseURL(baseURL: String) {
logger.v { "checkBaseURL() called with: baseURL = $baseURL" }
logger.v { "checkBaseURL() called" }
val hasPrefix = listOf("http://", "https://").any { baseURL.startsWith(it) }
val urlWithPrefix = baseURL.takeIf { hasPrefix } ?: "https://%s".format(baseURL)
val url = urlWithPrefix.trimEnd { it == '/' }
baseUrlLogRedactor.set(baseUrl = url)
logger.d { "checkBaseURL: Created URL = \"$url\", with prefix = \"$urlWithPrefix\"" }
if (url == serverInfoRepo.getUrl()) {
logger.d { "checkBaseURL: new URL matches current" }

View File

@@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal"
android:autoMirrored="true">
<path
android:fillColor="@android:color/white"
android:pathData="M120,800L120,160L880,480L120,800ZM200,680L674,480L200,280L200,420L440,480L200,540L200,680ZM200,680L200,480L200,280L200,420L200,420L200,540L200,540L200,680Z" />
</vector>

View File

@@ -1,46 +1,45 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.drawerlayout.widget.DrawerLayout 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:id="@+id/drawer"
style="?drawerLayoutStyle"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.activity.MainActivity">
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/drawer"
style="?drawerLayoutStyle"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.activity.MainActivity">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<gq.kirmanak.mealient.ui.activity.ToolbarView
android:id="@+id/toolbar"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/margin_medium"
android:background="@drawable/bg_toolbar"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<gq.kirmanak.mealient.ui.activity.ToolbarView
android:id="@+id/toolbar"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/margin_medium"
android:background="@drawable/bg_toolbar"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<androidx.fragment.app.FragmentContainerView
android:id="@+id/nav_host"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginTop="@dimen/margin_small"
app:defaultNavHost="true"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/toolbar"
tools:layout="@layout/fragment_recipes_list" />
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.fragment.app.FragmentContainerView
android:id="@+id/nav_host"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginTop="@dimen/margin_small"
app:defaultNavHost="true"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/toolbar" />
</androidx.constraintlayout.widget.ConstraintLayout>
<com.google.android.material.navigation.NavigationView
android:id="@+id/navigation_view"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_gravity="start"
app:headerLayout="@layout/view_navigation_drawer_header"
app:menu="@menu/navigation_menu" />
<com.google.android.material.navigation.NavigationView
android:id="@+id/navigation_view"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_gravity="start"
app:headerLayout="@layout/view_navigation_drawer_header"
app:menu="@menu/navigation_menu" />
</androidx.drawerlayout.widget.DrawerLayout>

View File

@@ -1,38 +1,43 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/recipes_list"
android:checkable="true"
android:icon="@drawable/ic_list"
android:title="@string/menu_navigation_drawer_recipes_list" />
<item
android:id="@+id/recipes_list"
android:checkable="true"
android:icon="@drawable/ic_list"
android:title="@string/menu_navigation_drawer_recipes_list" />
<item
android:id="@+id/add_recipe"
android:checkable="true"
android:icon="@drawable/ic_add"
android:title="@string/menu_navigation_drawer_add_recipe" />
<item
android:id="@+id/add_recipe"
android:checkable="true"
android:icon="@drawable/ic_add"
android:title="@string/menu_navigation_drawer_add_recipe" />
<item
android:id="@+id/shopping_lists"
android:checkable="true"
android:icon="@drawable/ic_shopping_cart"
android:title="@string/menu_navigation_drawer_shopping_lists" />
<item
android:id="@+id/shopping_lists"
android:checkable="true"
android:icon="@drawable/ic_shopping_cart"
android:title="@string/menu_navigation_drawer_shopping_lists" />
<item
android:id="@+id/change_url"
android:checkable="true"
android:icon="@drawable/ic_change"
android:title="@string/menu_navigation_drawer_change_url" />
<item
android:id="@+id/change_url"
android:checkable="true"
android:icon="@drawable/ic_change"
android:title="@string/menu_navigation_drawer_change_url" />
<item
android:id="@+id/login"
android:checkable="true"
android:icon="@drawable/ic_login"
android:title="@string/menu_navigation_drawer_login" />
<item
android:id="@+id/login"
android:checkable="true"
android:icon="@drawable/ic_login"
android:title="@string/menu_navigation_drawer_login" />
<item
android:id="@+id/logout"
android:icon="@drawable/ic_logout"
android:title="@string/menu_navigation_drawer_logout" />
<item
android:id="@+id/logout"
android:icon="@drawable/ic_logout"
android:title="@string/menu_navigation_drawer_logout" />
<item
android:id="@+id/email_logs"
android:icon="@drawable/ic_send"
android:title="@string/menu_navigation_drawer_email_logs" />
</menu>

View File

@@ -73,4 +73,10 @@
<string name="fragment_recipes_favorite_added">Added %1$s to favorites</string>
<string name="fragment_recipes_favorite_removed">Removed %1$s from favorites</string>
<string name="menu_navigation_drawer_shopping_lists">Shopping lists</string>
<string name="menu_navigation_drawer_email_logs">Email logs</string>
<string name="activity_main_email_logs_subject">Mealient logs</string>
<string name="activity_main_email_logs_confirmation_message">The logs contain sensitive data such as API token, shopping lists, and recipes. API tokens can be revoked using web client. The file can be viewed and edited if you send it to yourself instead.</string>
<string name="activity_main_email_logs_confirmation_title">Sending sensitive data</string>
<string name="activity_main_email_logs_confirmation_positive">Choose how to send</string>
<string name="activity_main_email_logs_confirmation_negative">Cancel</string>
</resources>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<files-path
path="."
name="log.txt" />
</paths>