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"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <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.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application <application
android:name="gq.kirmanak.mealient.App" android:name="gq.kirmanak.mealient.App"
android:allowBackup="false" android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/full_backup_rules" android:fullBackupContent="@xml/full_backup_rules"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config" android:localeConfig="@xml/locales_config"
android:roundIcon="@mipmap/ic_launcher" android:networkSecurityConfig="@xml/network_security_config"
android:supportsRtl="true" android:roundIcon="@mipmap/ic_launcher"
android:theme="@style/Theme.App.Starting" android:supportsRtl="true"
android:localeConfig="@xml/locales_config" android:theme="@style/Theme.App.Starting"
tools:ignore="UnusedAttribute"> tools:ignore="UnusedAttribute">
<activity <activity
android:name=".ui.activity.MainActivity" android:name=".ui.activity.MainActivity"
android:exported="true" android:exported="true"
android:windowSoftInputMode="adjustPan"> android:windowSoftInputMode="adjustPan">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<activity <activity
android:name=".ui.share.ShareRecipeActivity" android:name=".ui.share.ShareRecipeActivity"
android:exported="true" android:exported="true"
android:theme="@style/AppTheme"> android:theme="@style/AppTheme">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.SEND" /> <action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" /> <data android:mimeType="text/plain" />
</intent-filter> </intent-filter>
<meta-data <meta-data
android:name="android.service.chooser.chooser_target_service" android:name="android.service.chooser.chooser_target_service"
android:value="androidx.sharetarget.ChooserTargetServiceCompat" /> android:value="androidx.sharetarget.ChooserTargetServiceCompat" />
</activity> </activity>
</application>
<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> </manifest>

View File

@@ -15,17 +15,20 @@ class AuthRepoImpl @Inject constructor(
private val authDataSource: AuthDataSource, private val authDataSource: AuthDataSource,
private val logger: Logger, private val logger: Logger,
private val signOutHandler: SignOutHandler, private val signOutHandler: SignOutHandler,
private val credentialsLogRedactor: CredentialsLogRedactor,
) : AuthRepo, AuthenticationProvider { ) : AuthRepo, AuthenticationProvider {
override val isAuthorizedFlow: Flow<Boolean> override val isAuthorizedFlow: Flow<Boolean>
get() = authStorage.authTokenFlow.map { it != null } get() = authStorage.authTokenFlow.map { it != null }
override suspend fun authenticate(email: String, password: String) { 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) val token = authDataSource.authenticate(email, password)
authStorage.setAuthToken(token) authStorage.setAuthToken(token)
val apiToken = authDataSource.createApiToken(API_TOKEN_NAME) val apiToken = authDataSource.createApiToken(API_TOKEN_NAME)
authStorage.setAuthToken(apiToken) authStorage.setAuthToken(apiToken)
credentialsLogRedactor.clear()
} }
override suspend fun getAuthToken(): String? = authStorage.getAuthToken() 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.Module
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
import gq.kirmanak.mealient.data.auth.AuthDataSource import gq.kirmanak.mealient.data.auth.AuthDataSource
import gq.kirmanak.mealient.data.auth.AuthRepo import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.data.auth.AuthStorage import gq.kirmanak.mealient.data.auth.AuthStorage
import gq.kirmanak.mealient.data.auth.impl.AuthDataSourceImpl import gq.kirmanak.mealient.data.auth.impl.AuthDataSourceImpl
import gq.kirmanak.mealient.data.auth.impl.AuthRepoImpl import gq.kirmanak.mealient.data.auth.impl.AuthRepoImpl
import gq.kirmanak.mealient.data.auth.impl.AuthStorageImpl 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.datasource.AuthenticationProvider
import gq.kirmanak.mealient.logging.LogRedactor
import gq.kirmanak.mealient.shopping_lists.repo.ShoppingListsAuthRepo import gq.kirmanak.mealient.shopping_lists.repo.ShoppingListsAuthRepo
@Module @Module
@@ -31,4 +34,8 @@ interface AuthModule {
@Binds @Binds
fun bindShoppingListsAuthRepo(impl: AuthRepoImpl): ShoppingListsAuthRepo 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.Module
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
import gq.kirmanak.mealient.data.baseurl.* 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.data.baseurl.impl.ServerInfoStorageImpl
import gq.kirmanak.mealient.datasource.ServerUrlProvider import gq.kirmanak.mealient.datasource.ServerUrlProvider
import gq.kirmanak.mealient.logging.LogRedactor
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
@@ -23,4 +26,8 @@ interface BaseURLModule {
@Binds @Binds
fun bindServerUrlProvider(serverInfoRepoImpl: ServerInfoRepoImpl): ServerUrlProvider fun bindServerUrlProvider(serverInfoRepoImpl: ServerInfoRepoImpl): ServerUrlProvider
@Binds
@IntoSet
fun bindBaseUrlLogRedactor(impl: BaseUrlLogRedactor): LogRedactor
} }

View File

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

View File

@@ -1,8 +1,11 @@
package gq.kirmanak.mealient.ui.activity package gq.kirmanak.mealient.ui.activity
import android.content.Intent
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.view.MenuItem import android.view.MenuItem
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.core.content.FileProvider
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.view.iterator import androidx.core.view.iterator
@@ -10,6 +13,7 @@ import androidx.drawerlayout.widget.DrawerLayout
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.NavDirections import androidx.navigation.NavDirections
import androidx.navigation.fragment.NavHostFragment import androidx.navigation.fragment.NavHostFragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import gq.kirmanak.mealient.NavGraphDirections.Companion.actionGlobalAddRecipeFragment import gq.kirmanak.mealient.NavGraphDirections.Companion.actionGlobalAddRecipeFragment
import gq.kirmanak.mealient.NavGraphDirections.Companion.actionGlobalAuthenticationFragment 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.databinding.MainActivityBinding
import gq.kirmanak.mealient.extensions.collectWhenResumed import gq.kirmanak.mealient.extensions.collectWhenResumed
import gq.kirmanak.mealient.extensions.observeOnce import gq.kirmanak.mealient.extensions.observeOnce
import gq.kirmanak.mealient.logging.getLogFile
import gq.kirmanak.mealient.ui.ActivityUiState import gq.kirmanak.mealient.ui.ActivityUiState
import gq.kirmanak.mealient.ui.BaseActivity import gq.kirmanak.mealient.ui.BaseActivity
import gq.kirmanak.mealient.ui.CheckableMenuItem import gq.kirmanak.mealient.ui.CheckableMenuItem
private const val EMAIL_FOR_LOGS = "mealient@gmail.com"
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : BaseActivity<MainActivityBinding>( class MainActivity : BaseActivity<MainActivityBinding>(
binder = MainActivityBinding::bind, binder = MainActivityBinding::bind,
@@ -87,6 +94,12 @@ class MainActivity : BaseActivity<MainActivityBinding>(
viewModel.logout() viewModel.logout()
return true return true
} }
R.id.email_logs -> {
emailLogs()
return true
}
else -> throw IllegalArgumentException("Unknown menu item id: ${menuItem.itemId}") else -> throw IllegalArgumentException("Unknown menu item id: ${menuItem.itemId}")
} }
menuItem.isChecked = true menuItem.isChecked = true
@@ -94,6 +107,39 @@ class MainActivity : BaseActivity<MainActivityBinding>(
return true 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) { private fun onUiStateChange(uiState: ActivityUiState) {
logger.v { "onUiStateChange() called with: uiState = $uiState" } logger.v { "onUiStateChange() called with: uiState = $uiState" }
val checkedMenuItem = when (uiState.checkedMenuItem) { val checkedMenuItem = when (uiState.checkedMenuItem) {

View File

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

View File

@@ -7,6 +7,7 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import gq.kirmanak.mealient.data.auth.AuthRepo import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.data.baseurl.ServerInfoRepo 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.data.recipes.RecipeRepo
import gq.kirmanak.mealient.datasource.CertificateCombinedException import gq.kirmanak.mealient.datasource.CertificateCombinedException
import gq.kirmanak.mealient.datasource.NetworkError import gq.kirmanak.mealient.datasource.NetworkError
@@ -27,6 +28,7 @@ class BaseURLViewModel @Inject constructor(
private val recipeRepo: RecipeRepo, private val recipeRepo: RecipeRepo,
private val logger: Logger, private val logger: Logger,
private val trustedCertificatesStore: TrustedCertificatesStore, private val trustedCertificatesStore: TrustedCertificatesStore,
private val baseUrlLogRedactor: BaseUrlLogRedactor,
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableLiveData<OperationUiState<Unit>>(OperationUiState.Initial()) private val _uiState = MutableLiveData<OperationUiState<Unit>>(OperationUiState.Initial())
@@ -36,18 +38,20 @@ class BaseURLViewModel @Inject constructor(
val invalidCertificatesFlow = invalidCertificatesChannel.receiveAsFlow() val invalidCertificatesFlow = invalidCertificatesChannel.receiveAsFlow()
fun saveBaseUrl(baseURL: String) { fun saveBaseUrl(baseURL: String) {
logger.v { "saveBaseUrl() called with: baseURL = $baseURL" } logger.v { "saveBaseUrl() called" }
_uiState.value = OperationUiState.Progress() _uiState.value = OperationUiState.Progress()
viewModelScope.launch { checkBaseURL(baseURL) } viewModelScope.launch { checkBaseURL(baseURL) }
} }
private suspend fun checkBaseURL(baseURL: String) { 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 hasPrefix = listOf("http://", "https://").any { baseURL.startsWith(it) }
val urlWithPrefix = baseURL.takeIf { hasPrefix } ?: "https://%s".format(baseURL) val urlWithPrefix = baseURL.takeIf { hasPrefix } ?: "https://%s".format(baseURL)
val url = urlWithPrefix.trimEnd { it == '/' } val url = urlWithPrefix.trimEnd { it == '/' }
baseUrlLogRedactor.set(baseUrl = url)
logger.d { "checkBaseURL: Created URL = \"$url\", with prefix = \"$urlWithPrefix\"" } logger.d { "checkBaseURL: Created URL = \"$url\", with prefix = \"$urlWithPrefix\"" }
if (url == serverInfoRepo.getUrl()) { if (url == serverInfoRepo.getUrl()) {
logger.d { "checkBaseURL: new URL matches current" } 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"?> <?xml version="1.0" encoding="utf-8"?>
<androidx.drawerlayout.widget.DrawerLayout xmlns:android="http://schemas.android.com/apk/res/android" <androidx.drawerlayout.widget.DrawerLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/drawer" android:id="@+id/drawer"
style="?drawerLayoutStyle" style="?drawerLayoutStyle"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
tools:context=".ui.activity.MainActivity"> tools:context=".ui.activity.MainActivity">
<androidx.constraintlayout.widget.ConstraintLayout <androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent"> android:layout_height="match_parent">
<gq.kirmanak.mealient.ui.activity.ToolbarView <gq.kirmanak.mealient.ui.activity.ToolbarView
android:id="@+id/toolbar" android:id="@+id/toolbar"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/margin_medium" android:layout_marginHorizontal="@dimen/margin_medium"
android:background="@drawable/bg_toolbar" android:background="@drawable/bg_toolbar"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" /> app:layout_constraintTop_toTopOf="parent" />
<androidx.fragment.app.FragmentContainerView <androidx.fragment.app.FragmentContainerView
android:id="@+id/nav_host" android:id="@+id/nav_host"
android:name="androidx.navigation.fragment.NavHostFragment" android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="0dp" android:layout_height="0dp"
android:layout_marginTop="@dimen/margin_small" android:layout_marginTop="@dimen/margin_small"
app:defaultNavHost="true" app:defaultNavHost="true"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/toolbar" app:layout_constraintTop_toBottomOf="@id/toolbar" />
tools:layout="@layout/fragment_recipes_list" /> </androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
<com.google.android.material.navigation.NavigationView <com.google.android.material.navigation.NavigationView
android:id="@+id/navigation_view" android:id="@+id/navigation_view"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="match_parent" android:layout_height="match_parent"
android:layout_gravity="start" android:layout_gravity="start"
app:headerLayout="@layout/view_navigation_drawer_header" app:headerLayout="@layout/view_navigation_drawer_header"
app:menu="@menu/navigation_menu" /> app:menu="@menu/navigation_menu" />
</androidx.drawerlayout.widget.DrawerLayout> </androidx.drawerlayout.widget.DrawerLayout>

View File

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

View File

@@ -73,4 +73,10 @@
<string name="fragment_recipes_favorite_added">Added %1$s to favorites</string> <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="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_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> </resources>

View File

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

View File

@@ -16,6 +16,7 @@ import io.mockk.coVerify
import io.mockk.confirmVerified import io.mockk.confirmVerified
import io.mockk.every import io.mockk.every
import io.mockk.impl.annotations.MockK import io.mockk.impl.annotations.MockK
import io.mockk.impl.annotations.RelaxedMockK
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.toList import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@@ -33,12 +34,21 @@ class AuthRepoImplTest : BaseUnitTest() {
@MockK(relaxUnitFun = true) @MockK(relaxUnitFun = true)
lateinit var signOutHandler: SignOutHandler lateinit var signOutHandler: SignOutHandler
@RelaxedMockK
lateinit var credentialsLogRedactor: CredentialsLogRedactor
lateinit var subject: AuthRepo lateinit var subject: AuthRepo
@Before @Before
override fun setUp() { override fun setUp() {
super.setUp() super.setUp()
subject = AuthRepoImpl(storage, dataSource, logger, signOutHandler) subject = AuthRepoImpl(
authStorage = storage,
authDataSource = dataSource,
logger = logger,
signOutHandler = signOutHandler,
credentialsLogRedactor = credentialsLogRedactor,
)
} }
@Test @Test

View File

@@ -3,6 +3,7 @@ package gq.kirmanak.mealient.ui.baseurl
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import gq.kirmanak.mealient.data.auth.AuthRepo import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.data.baseurl.ServerInfoRepo 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.data.recipes.RecipeRepo
import gq.kirmanak.mealient.datasource.NetworkError import gq.kirmanak.mealient.datasource.NetworkError
import gq.kirmanak.mealient.datasource.TrustedCertificatesStore import gq.kirmanak.mealient.datasource.TrustedCertificatesStore
@@ -13,8 +14,11 @@ import io.mockk.coEvery
import io.mockk.coVerify import io.mockk.coVerify
import io.mockk.coVerifyOrder import io.mockk.coVerifyOrder
import io.mockk.impl.annotations.MockK import io.mockk.impl.annotations.MockK
import io.mockk.impl.annotations.RelaxedMockK
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.* import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
import java.io.IOException import java.io.IOException
@@ -35,6 +39,9 @@ class BaseURLViewModelTest : BaseUnitTest() {
@MockK(relaxUnitFun = true) @MockK(relaxUnitFun = true)
lateinit var trustedCertificatesStore: TrustedCertificatesStore lateinit var trustedCertificatesStore: TrustedCertificatesStore
@RelaxedMockK
lateinit var baseUrlLogRedactor: BaseUrlLogRedactor
lateinit var subject: BaseURLViewModel lateinit var subject: BaseURLViewModel
@Before @Before
@@ -46,6 +53,7 @@ class BaseURLViewModelTest : BaseUnitTest() {
recipeRepo = recipeRepo, recipeRepo = recipeRepo,
logger = logger, logger = logger,
trustedCertificatesStore = trustedCertificatesStore, trustedCertificatesStore = trustedCertificatesStore,
baseUrlLogRedactor = baseUrlLogRedactor,
) )
} }

View File

@@ -7,7 +7,7 @@ plugins {
android { android {
defaultConfig { defaultConfig {
buildConfigField("Boolean", "LOG_NETWORK", "false") buildConfigField("Boolean", "LOG_NETWORK", "true")
consumerProguardFiles("consumer-proguard-rules.pro") consumerProguardFiles("consumer-proguard-rules.pro")
} }
namespace = "gq.kirmanak.mealient.datasource" namespace = "gq.kirmanak.mealient.datasource"
@@ -31,7 +31,7 @@ dependencies {
implementation(platform(libs.okhttp3.bom)) implementation(platform(libs.okhttp3.bom))
implementation(libs.okhttp3.okhttp) implementation(libs.okhttp3.okhttp)
debugImplementation(libs.okhttp3.loggingInterceptor) implementation(libs.okhttp3.loggingInterceptor)
implementation(libs.ktor.core) implementation(libs.ktor.core)
implementation(libs.ktor.auth) implementation(libs.ktor.auth)

View File

@@ -10,25 +10,12 @@ import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet import dagger.multibindings.IntoSet
import gq.kirmanak.mealient.datasource.BuildConfig
import gq.kirmanak.mealient.logging.Logger
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.logging.HttpLoggingInterceptor
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
internal object DebugModule { internal object DebugModule {
@Provides
@IntoSet
fun provideLoggingInterceptor(logger: Logger): Interceptor {
val interceptor = HttpLoggingInterceptor { message -> logger.v(tag = "OkHttp") { message } }
interceptor.level = when {
BuildConfig.LOG_NETWORK -> HttpLoggingInterceptor.Level.BODY
else -> HttpLoggingInterceptor.Level.BASIC
}
return interceptor
}
@Provides @Provides
@IntoSet @IntoSet

View File

@@ -5,13 +5,17 @@ import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
import gq.kirmanak.mealient.datasource.impl.MealieDataSourceImpl import gq.kirmanak.mealient.datasource.impl.MealieDataSourceImpl
import gq.kirmanak.mealient.datasource.impl.MealieServiceKtor import gq.kirmanak.mealient.datasource.impl.MealieServiceKtor
import gq.kirmanak.mealient.datasource.impl.NetworkRequestWrapperImpl import gq.kirmanak.mealient.datasource.impl.NetworkRequestWrapperImpl
import gq.kirmanak.mealient.datasource.impl.OkHttpBuilderImpl import gq.kirmanak.mealient.datasource.impl.OkHttpBuilderImpl
import gq.kirmanak.mealient.datasource.impl.TrustedCertificatesStoreImpl import gq.kirmanak.mealient.datasource.impl.TrustedCertificatesStoreImpl
import gq.kirmanak.mealient.logging.Logger
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import okhttp3.Interceptor
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import javax.inject.Singleton import javax.inject.Singleton
@Module @Module
@@ -33,6 +37,17 @@ internal interface DataSourceModule {
fun provideOkHttp(okHttpBuilder: OkHttpBuilderImpl): OkHttpClient = fun provideOkHttp(okHttpBuilder: OkHttpBuilderImpl): OkHttpClient =
okHttpBuilder.buildOkHttp() okHttpBuilder.buildOkHttp()
@Provides
@IntoSet
fun provideLoggingInterceptor(logger: Logger): Interceptor {
val interceptor =
HttpLoggingInterceptor { message -> logger.v(tag = "OkHttp") { message } }
interceptor.level = when {
BuildConfig.LOG_NETWORK -> HttpLoggingInterceptor.Level.BODY
else -> HttpLoggingInterceptor.Level.BASIC
}
return interceptor
}
} }
@Binds @Binds

View File

@@ -1,19 +0,0 @@
package gq.kirmanak.mealient
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import okhttp3.Interceptor
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object ReleaseModule {
// Release version of the application doesn't have any interceptors but this Set
// is required by Dagger, so an empty Set is provided here
// Use @JvmSuppressWildcards because otherwise dagger can't inject it (https://stackoverflow.com/a/43149382)
@Provides
fun provideInterceptors(): Set<@JvmSuppressWildcards Interceptor> = emptySet()
}

View File

@@ -8,12 +8,13 @@ import dagger.multibindings.IntoSet
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
interface LoggingModule { internal interface AppenderModule {
@Binds
fun bindLogger(loggerImpl: LoggerImpl): Logger
@Binds @Binds
@IntoSet @IntoSet
fun bindLogcatAppender(logcatAppender: LogcatAppender): Appender fun bindLogcatAppender(logcatAppender: LogcatAppender): Appender
@Binds
@IntoSet
fun bindFileAppender(fileAppender: FileAppender): Appender
} }

View File

@@ -0,0 +1,109 @@
package gq.kirmanak.mealient.logging
import android.app.Application
import gq.kirmanak.mealient.architecture.configuration.AppDispatchers
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import java.io.BufferedWriter
import java.io.FileWriter
import java.io.IOException
import java.io.Writer
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import javax.inject.Inject
import javax.inject.Singleton
private const val MAX_LOG_FILE_SIZE = 1024 * 1024 * 10L // 10 MB
@Singleton
internal class FileAppender @Inject constructor(
private val application: Application,
dispatchers: AppDispatchers,
) : Appender {
private data class LogInfo(
val logTime: Instant,
val logLevel: LogLevel,
val tag: String,
val message: String,
)
private val fileWriter: Writer? = createFileWriter()
private val logChannel = Channel<LogInfo>(
capacity = 100,
onBufferOverflow = BufferOverflow.DROP_LATEST,
)
private val coroutineScope = CoroutineScope(dispatchers.io + SupervisorJob())
private val dateTimeFormatter =
DateTimeFormatter.ISO_LOCAL_DATE_TIME.withZone(ZoneId.systemDefault())
init {
startLogWriter()
}
private fun createFileWriter(): Writer? {
val file = application.getLogFile()
if (file.length() > MAX_LOG_FILE_SIZE) {
file.delete()
}
val writer = try {
FileWriter(file, /* append = */ true)
} catch (e: IOException) {
return null
}
return BufferedWriter(writer)
}
private fun startLogWriter() {
if (fileWriter == null) {
return
}
coroutineScope.launch {
for (logInfo in logChannel) {
val time = dateTimeFormatter.format(logInfo.logTime)
val level = logInfo.logLevel.name.first()
logInfo.message.lines().forEach {
try {
fileWriter.appendLine("$time $level ${logInfo.tag}: $it")
} catch (e: IOException) {
// Ignore
}
}
}
}
}
override fun isLoggable(logLevel: LogLevel): Boolean = true
override fun isLoggable(logLevel: LogLevel, tag: String): Boolean = true
override fun log(logLevel: LogLevel, tag: String, message: String) {
val logInfo = LogInfo(
logTime = Instant.now(),
logLevel = logLevel,
tag = tag,
message = message,
)
logChannel.trySend(logInfo)
}
protected fun finalize() {
coroutineScope.cancel("Object is being destroyed")
try {
fileWriter?.close()
} catch (e: IOException) {
// Ignore
}
}
}

View File

@@ -0,0 +1,6 @@
package gq.kirmanak.mealient.logging
interface LogRedactor {
fun redact(message: String): String
}

View File

@@ -4,7 +4,7 @@ import android.util.Log
import gq.kirmanak.mealient.architecture.configuration.BuildConfiguration import gq.kirmanak.mealient.architecture.configuration.BuildConfiguration
import javax.inject.Inject import javax.inject.Inject
class LogcatAppender @Inject constructor( internal class LogcatAppender @Inject constructor(
private val buildConfiguration: BuildConfiguration, private val buildConfiguration: BuildConfiguration,
) : Appender { ) : Appender {

View File

@@ -1,7 +1,12 @@
package gq.kirmanak.mealient.logging package gq.kirmanak.mealient.logging
import android.content.Context
import java.io.File
typealias MessageSupplier = () -> String typealias MessageSupplier = () -> String
private const val LOG_FILE_NAME = "log.txt"
interface Logger { interface Logger {
fun v(throwable: Throwable? = null, tag: String? = null, messageSupplier: MessageSupplier) fun v(throwable: Throwable? = null, tag: String? = null, messageSupplier: MessageSupplier)
@@ -13,4 +18,8 @@ interface Logger {
fun w(throwable: Throwable? = null, tag: String? = null, messageSupplier: MessageSupplier) fun w(throwable: Throwable? = null, tag: String? = null, messageSupplier: MessageSupplier)
fun e(throwable: Throwable? = null, tag: String? = null, messageSupplier: MessageSupplier) fun e(throwable: Throwable? = null, tag: String? = null, messageSupplier: MessageSupplier)
}
fun Context.getLogFile(): File {
return File(filesDir, LOG_FILE_NAME)
} }

View File

@@ -6,6 +6,7 @@ import javax.inject.Inject
class LoggerImpl @Inject constructor( class LoggerImpl @Inject constructor(
private val appenders: Set<@JvmSuppressWildcards Appender>, private val appenders: Set<@JvmSuppressWildcards Appender>,
private val redactors: Set<@JvmSuppressWildcards LogRedactor>,
) : Logger { ) : Logger {
override fun v(throwable: Throwable?, tag: String?, messageSupplier: MessageSupplier) { override fun v(throwable: Throwable?, tag: String?, messageSupplier: MessageSupplier) {
@@ -45,12 +46,23 @@ class LoggerImpl @Inject constructor(
if (appender.isLoggable(logLevel, logTag).not()) continue if (appender.isLoggable(logLevel, logTag).not()) continue
message = message ?: (messageSupplier() + createStackTrace(t)) message = message ?: buildLogMessage(messageSupplier, t)
appender.log(logLevel, logTag, message) appender.log(logLevel, logTag, message)
} }
} }
private fun buildLogMessage(
messageSupplier: MessageSupplier,
t: Throwable?
): String {
var message = messageSupplier() + createStackTrace(t)
for (redactor in redactors) {
message = redactor.redact(message)
}
return message
}
private fun createStackTrace(throwable: Throwable?): String = private fun createStackTrace(throwable: Throwable?): String =
throwable?.let { Log.getStackTraceString(it) } throwable?.let { Log.getStackTraceString(it) }
?.takeUnless { it.isBlank() } ?.takeUnless { it.isBlank() }

View File

@@ -0,0 +1,15 @@
package gq.kirmanak.mealient.logging
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
@Module
@InstallIn(SingletonComponent::class)
interface LoggerModule {
@Binds
fun bindLogger(loggerImpl: LoggerImpl): Logger
}

View File

@@ -5,14 +5,14 @@ import dagger.Module
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import dagger.hilt.testing.TestInstallIn import dagger.hilt.testing.TestInstallIn
import gq.kirmanak.mealient.logging.Logger import gq.kirmanak.mealient.logging.Logger
import gq.kirmanak.mealient.logging.LoggingModule import gq.kirmanak.mealient.logging.LoggerModule
@Module @Module
@TestInstallIn( @TestInstallIn(
components = [SingletonComponent::class], components = [SingletonComponent::class],
replaces = [LoggingModule::class] replaces = [LoggerModule::class]
) )
interface FakeLoggingModule { interface FakeLoggerModule {
@Binds @Binds
fun bindFakeLogger(impl: FakeLogger): Logger fun bindFakeLogger(impl: FakeLogger): Logger