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:
@@ -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>
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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>")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) }
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
|||||||
11
app/src/main/res/drawable/ic_send.xml
Normal file
11
app/src/main/res/drawable/ic_send.xml
Normal 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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
6
app/src/main/res/xml/file_provider_paths.xml
Normal file
6
app/src/main/res/xml/file_provider_paths.xml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<paths>
|
||||||
|
<files-path
|
||||||
|
path="."
|
||||||
|
name="log.txt" />
|
||||||
|
</paths>
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package gq.kirmanak.mealient.logging
|
||||||
|
|
||||||
|
interface LogRedactor {
|
||||||
|
|
||||||
|
fun redact(message: String): String
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -14,3 +19,7 @@ interface Logger {
|
|||||||
|
|
||||||
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)
|
||||||
|
}
|
||||||
@@ -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() }
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user