diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index c2fae41a..f15fe3fd 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -1,46 +1,56 @@
+ xmlns:tools="http://schemas.android.com/tools">
-
-
+
+
-
-
-
-
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthRepoImpl.kt b/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthRepoImpl.kt
index 8d2627e2..5b32790a 100644
--- a/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthRepoImpl.kt
+++ b/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthRepoImpl.kt
@@ -15,17 +15,20 @@ class AuthRepoImpl @Inject constructor(
private val authDataSource: AuthDataSource,
private val logger: Logger,
private val signOutHandler: SignOutHandler,
+ private val credentialsLogRedactor: CredentialsLogRedactor,
) : AuthRepo, AuthenticationProvider {
override val isAuthorizedFlow: Flow
get() = authStorage.authTokenFlow.map { it != null }
override suspend fun authenticate(email: String, password: String) {
- logger.v { "authenticate() called with: email = $email, password = $password" }
+ logger.v { "authenticate() called" }
+ credentialsLogRedactor.set(email, password)
val token = authDataSource.authenticate(email, password)
authStorage.setAuthToken(token)
val apiToken = authDataSource.createApiToken(API_TOKEN_NAME)
authStorage.setAuthToken(apiToken)
+ credentialsLogRedactor.clear()
}
override suspend fun getAuthToken(): String? = authStorage.getAuthToken()
diff --git a/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/CredentialsLogRedactor.kt b/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/CredentialsLogRedactor.kt
new file mode 100644
index 00000000..e73f7799
--- /dev/null
+++ b/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/CredentialsLogRedactor.kt
@@ -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(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, "")
+ .replace(credentials.urlEncodedLogin, "")
+ .replace(credentials.password, "")
+ .replace(credentials.urlEncodedPassword, "")
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/gq/kirmanak/mealient/data/baseurl/impl/BaseUrlLogRedactor.kt b/app/src/main/java/gq/kirmanak/mealient/data/baseurl/impl/BaseUrlLogRedactor.kt
new file mode 100644
index 00000000..bebde249
--- /dev/null
+++ b/app/src/main/java/gq/kirmanak/mealient/data/baseurl/impl/BaseUrlLogRedactor.kt
@@ -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,
+ private val dispatchers: AppDispatchers,
+) : LogRedactor {
+
+ private val hostState = MutableStateFlow(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, "")
+ }
+}
+
+private fun String.extractHost() = runCatching { toUri() }.getOrNull()?.host
diff --git a/app/src/main/java/gq/kirmanak/mealient/di/AuthModule.kt b/app/src/main/java/gq/kirmanak/mealient/di/AuthModule.kt
index c4b953bb..a93544c0 100644
--- a/app/src/main/java/gq/kirmanak/mealient/di/AuthModule.kt
+++ b/app/src/main/java/gq/kirmanak/mealient/di/AuthModule.kt
@@ -4,13 +4,16 @@ import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
+import dagger.multibindings.IntoSet
import gq.kirmanak.mealient.data.auth.AuthDataSource
import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.data.auth.AuthStorage
import gq.kirmanak.mealient.data.auth.impl.AuthDataSourceImpl
import gq.kirmanak.mealient.data.auth.impl.AuthRepoImpl
import gq.kirmanak.mealient.data.auth.impl.AuthStorageImpl
+import gq.kirmanak.mealient.data.auth.impl.CredentialsLogRedactor
import gq.kirmanak.mealient.datasource.AuthenticationProvider
+import gq.kirmanak.mealient.logging.LogRedactor
import gq.kirmanak.mealient.shopping_lists.repo.ShoppingListsAuthRepo
@Module
@@ -31,4 +34,8 @@ interface AuthModule {
@Binds
fun bindShoppingListsAuthRepo(impl: AuthRepoImpl): ShoppingListsAuthRepo
+
+ @Binds
+ @IntoSet
+ fun bindCredentialsLogRedactor(impl: CredentialsLogRedactor): LogRedactor
}
diff --git a/app/src/main/java/gq/kirmanak/mealient/di/BaseURLModule.kt b/app/src/main/java/gq/kirmanak/mealient/di/BaseURLModule.kt
index 34ec7d93..98403777 100644
--- a/app/src/main/java/gq/kirmanak/mealient/di/BaseURLModule.kt
+++ b/app/src/main/java/gq/kirmanak/mealient/di/BaseURLModule.kt
@@ -4,9 +4,12 @@ import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
+import dagger.multibindings.IntoSet
import gq.kirmanak.mealient.data.baseurl.*
+import gq.kirmanak.mealient.data.baseurl.impl.BaseUrlLogRedactor
import gq.kirmanak.mealient.data.baseurl.impl.ServerInfoStorageImpl
import gq.kirmanak.mealient.datasource.ServerUrlProvider
+import gq.kirmanak.mealient.logging.LogRedactor
@Module
@InstallIn(SingletonComponent::class)
@@ -23,4 +26,8 @@ interface BaseURLModule {
@Binds
fun bindServerUrlProvider(serverInfoRepoImpl: ServerInfoRepoImpl): ServerUrlProvider
+
+ @Binds
+ @IntoSet
+ fun bindBaseUrlLogRedactor(impl: BaseUrlLogRedactor): LogRedactor
}
\ No newline at end of file
diff --git a/app/src/main/java/gq/kirmanak/mealient/extensions/ViewExtensions.kt b/app/src/main/java/gq/kirmanak/mealient/extensions/ViewExtensions.kt
index 722ab6d9..c70c5eb3 100644
--- a/app/src/main/java/gq/kirmanak/mealient/extensions/ViewExtensions.kt
+++ b/app/src/main/java/gq/kirmanak/mealient/extensions/ViewExtensions.kt
@@ -73,7 +73,6 @@ fun EditText.checkIfInputIsEmpty(
): String? {
val input = if (trim) text?.trim() else text
val text = input?.toString().orEmpty()
- logger.d { "Input text is \"$text\"" }
return text.ifEmpty {
inputLayout.error = resources.getString(stringId)
val textChangesLiveData = textChangesLiveData(logger)
diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/activity/MainActivity.kt b/app/src/main/java/gq/kirmanak/mealient/ui/activity/MainActivity.kt
index 3ca005d6..00c7a0a7 100644
--- a/app/src/main/java/gq/kirmanak/mealient/ui/activity/MainActivity.kt
+++ b/app/src/main/java/gq/kirmanak/mealient/ui/activity/MainActivity.kt
@@ -1,8 +1,11 @@
package gq.kirmanak.mealient.ui.activity
+import android.content.Intent
+import android.net.Uri
import android.os.Bundle
import android.view.MenuItem
import androidx.activity.viewModels
+import androidx.core.content.FileProvider
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.isVisible
import androidx.core.view.iterator
@@ -10,6 +13,7 @@ import androidx.drawerlayout.widget.DrawerLayout
import androidx.navigation.NavController
import androidx.navigation.NavDirections
import androidx.navigation.fragment.NavHostFragment
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint
import gq.kirmanak.mealient.NavGraphDirections.Companion.actionGlobalAddRecipeFragment
import gq.kirmanak.mealient.NavGraphDirections.Companion.actionGlobalAuthenticationFragment
@@ -20,10 +24,13 @@ import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.databinding.MainActivityBinding
import gq.kirmanak.mealient.extensions.collectWhenResumed
import gq.kirmanak.mealient.extensions.observeOnce
+import gq.kirmanak.mealient.logging.getLogFile
import gq.kirmanak.mealient.ui.ActivityUiState
import gq.kirmanak.mealient.ui.BaseActivity
import gq.kirmanak.mealient.ui.CheckableMenuItem
+private const val EMAIL_FOR_LOGS = "mealient@gmail.com"
+
@AndroidEntryPoint
class MainActivity : BaseActivity(
binder = MainActivityBinding::bind,
@@ -87,6 +94,12 @@ class MainActivity : BaseActivity(
viewModel.logout()
return true
}
+
+ R.id.email_logs -> {
+ emailLogs()
+ return true
+ }
+
else -> throw IllegalArgumentException("Unknown menu item id: ${menuItem.itemId}")
}
menuItem.isChecked = true
@@ -94,6 +107,39 @@ class MainActivity : BaseActivity(
return true
}
+ private fun emailLogs() {
+ MaterialAlertDialogBuilder(this)
+ .setMessage(R.string.activity_main_email_logs_confirmation_message)
+ .setTitle(R.string.activity_main_email_logs_confirmation_title)
+ .setPositiveButton(R.string.activity_main_email_logs_confirmation_positive) { _, _ -> doEmailLogs() }
+ .setNegativeButton(R.string.activity_main_email_logs_confirmation_negative, null)
+ .show()
+ }
+
+ private fun doEmailLogs() {
+ val logFileUri = try {
+ FileProvider.getUriForFile(this, "$packageName.provider", getLogFile())
+ } catch (e: Exception) {
+ return
+ }
+ val emailIntent = buildIntent(logFileUri)
+ val chooserIntent = Intent.createChooser(emailIntent, null)
+ startActivity(chooserIntent)
+ }
+
+ private fun buildIntent(logFileUri: Uri?): Intent {
+ val emailIntent = Intent(Intent.ACTION_SEND)
+ val to = arrayOf(EMAIL_FOR_LOGS)
+ emailIntent.setType("text/plain")
+ emailIntent.putExtra(Intent.EXTRA_EMAIL, to)
+ emailIntent.putExtra(Intent.EXTRA_STREAM, logFileUri)
+ emailIntent.putExtra(
+ Intent.EXTRA_SUBJECT,
+ getString(R.string.activity_main_email_logs_subject)
+ )
+ return emailIntent
+ }
+
private fun onUiStateChange(uiState: ActivityUiState) {
logger.v { "onUiStateChange() called with: uiState = $uiState" }
val checkedMenuItem = when (uiState.checkedMenuItem) {
diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticationViewModel.kt b/app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticationViewModel.kt
index 145852d5..203ce390 100644
--- a/app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticationViewModel.kt
+++ b/app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticationViewModel.kt
@@ -22,7 +22,7 @@ class AuthenticationViewModel @Inject constructor(
val uiState: LiveData> get() = _uiState
fun authenticate(email: String, password: String) {
- logger.v { "authenticate() called with: email = $email, password = $password" }
+ logger.v { "authenticate() called" }
_uiState.value = OperationUiState.Progress()
viewModelScope.launch {
val result = runCatchingExceptCancel { authRepo.authenticate(email, password) }
diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/baseurl/BaseURLViewModel.kt b/app/src/main/java/gq/kirmanak/mealient/ui/baseurl/BaseURLViewModel.kt
index 6c76d969..56709055 100644
--- a/app/src/main/java/gq/kirmanak/mealient/ui/baseurl/BaseURLViewModel.kt
+++ b/app/src/main/java/gq/kirmanak/mealient/ui/baseurl/BaseURLViewModel.kt
@@ -7,6 +7,7 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.data.baseurl.ServerInfoRepo
+import gq.kirmanak.mealient.data.baseurl.impl.BaseUrlLogRedactor
import gq.kirmanak.mealient.data.recipes.RecipeRepo
import gq.kirmanak.mealient.datasource.CertificateCombinedException
import gq.kirmanak.mealient.datasource.NetworkError
@@ -27,6 +28,7 @@ class BaseURLViewModel @Inject constructor(
private val recipeRepo: RecipeRepo,
private val logger: Logger,
private val trustedCertificatesStore: TrustedCertificatesStore,
+ private val baseUrlLogRedactor: BaseUrlLogRedactor,
) : ViewModel() {
private val _uiState = MutableLiveData>(OperationUiState.Initial())
@@ -36,18 +38,20 @@ class BaseURLViewModel @Inject constructor(
val invalidCertificatesFlow = invalidCertificatesChannel.receiveAsFlow()
fun saveBaseUrl(baseURL: String) {
- logger.v { "saveBaseUrl() called with: baseURL = $baseURL" }
+ logger.v { "saveBaseUrl() called" }
_uiState.value = OperationUiState.Progress()
viewModelScope.launch { checkBaseURL(baseURL) }
}
private suspend fun checkBaseURL(baseURL: String) {
- logger.v { "checkBaseURL() called with: baseURL = $baseURL" }
+ logger.v { "checkBaseURL() called" }
val hasPrefix = listOf("http://", "https://").any { baseURL.startsWith(it) }
val urlWithPrefix = baseURL.takeIf { hasPrefix } ?: "https://%s".format(baseURL)
val url = urlWithPrefix.trimEnd { it == '/' }
+ baseUrlLogRedactor.set(baseUrl = url)
+
logger.d { "checkBaseURL: Created URL = \"$url\", with prefix = \"$urlWithPrefix\"" }
if (url == serverInfoRepo.getUrl()) {
logger.d { "checkBaseURL: new URL matches current" }
diff --git a/app/src/main/res/drawable/ic_send.xml b/app/src/main/res/drawable/ic_send.xml
new file mode 100644
index 00000000..3112f6db
--- /dev/null
+++ b/app/src/main/res/drawable/ic_send.xml
@@ -0,0 +1,11 @@
+
+
+
diff --git a/app/src/main/res/layout/main_activity.xml b/app/src/main/res/layout/main_activity.xml
index ade29313..275da235 100644
--- a/app/src/main/res/layout/main_activity.xml
+++ b/app/src/main/res/layout/main_activity.xml
@@ -1,46 +1,45 @@
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ xmlns:tools="http://schemas.android.com/tools"
+ android:id="@+id/drawer"
+ style="?drawerLayoutStyle"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ tools:context=".ui.activity.MainActivity">
-
+
-
+
-
-
+
+
-
+
diff --git a/app/src/main/res/menu/navigation_menu.xml b/app/src/main/res/menu/navigation_menu.xml
index 781ac621..a32cb251 100644
--- a/app/src/main/res/menu/navigation_menu.xml
+++ b/app/src/main/res/menu/navigation_menu.xml
@@ -1,38 +1,43 @@
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index d3e8e648..928fa7f5 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -73,4 +73,10 @@
Added %1$s to favorites
Removed %1$s from favorites
Shopping lists
+ Email logs
+ Mealient logs
+ 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.
+ Sending sensitive data
+ Choose how to send
+ Cancel
\ No newline at end of file
diff --git a/app/src/main/res/xml/file_provider_paths.xml b/app/src/main/res/xml/file_provider_paths.xml
new file mode 100644
index 00000000..7cc365b5
--- /dev/null
+++ b/app/src/main/res/xml/file_provider_paths.xml
@@ -0,0 +1,6 @@
+
+
+
+
\ No newline at end of file
diff --git a/app/src/test/java/gq/kirmanak/mealient/data/auth/impl/AuthRepoImplTest.kt b/app/src/test/java/gq/kirmanak/mealient/data/auth/impl/AuthRepoImplTest.kt
index 1a5c425b..b9f2cc98 100644
--- a/app/src/test/java/gq/kirmanak/mealient/data/auth/impl/AuthRepoImplTest.kt
+++ b/app/src/test/java/gq/kirmanak/mealient/data/auth/impl/AuthRepoImplTest.kt
@@ -16,6 +16,7 @@ import io.mockk.coVerify
import io.mockk.confirmVerified
import io.mockk.every
import io.mockk.impl.annotations.MockK
+import io.mockk.impl.annotations.RelaxedMockK
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.test.runTest
@@ -33,12 +34,21 @@ class AuthRepoImplTest : BaseUnitTest() {
@MockK(relaxUnitFun = true)
lateinit var signOutHandler: SignOutHandler
+ @RelaxedMockK
+ lateinit var credentialsLogRedactor: CredentialsLogRedactor
+
lateinit var subject: AuthRepo
@Before
override fun setUp() {
super.setUp()
- subject = AuthRepoImpl(storage, dataSource, logger, signOutHandler)
+ subject = AuthRepoImpl(
+ authStorage = storage,
+ authDataSource = dataSource,
+ logger = logger,
+ signOutHandler = signOutHandler,
+ credentialsLogRedactor = credentialsLogRedactor,
+ )
}
@Test
diff --git a/app/src/test/java/gq/kirmanak/mealient/ui/baseurl/BaseURLViewModelTest.kt b/app/src/test/java/gq/kirmanak/mealient/ui/baseurl/BaseURLViewModelTest.kt
index 486bc1ae..aa211958 100644
--- a/app/src/test/java/gq/kirmanak/mealient/ui/baseurl/BaseURLViewModelTest.kt
+++ b/app/src/test/java/gq/kirmanak/mealient/ui/baseurl/BaseURLViewModelTest.kt
@@ -3,6 +3,7 @@ package gq.kirmanak.mealient.ui.baseurl
import com.google.common.truth.Truth.assertThat
import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.data.baseurl.ServerInfoRepo
+import gq.kirmanak.mealient.data.baseurl.impl.BaseUrlLogRedactor
import gq.kirmanak.mealient.data.recipes.RecipeRepo
import gq.kirmanak.mealient.datasource.NetworkError
import gq.kirmanak.mealient.datasource.TrustedCertificatesStore
@@ -13,8 +14,11 @@ import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.coVerifyOrder
import io.mockk.impl.annotations.MockK
+import io.mockk.impl.annotations.RelaxedMockK
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.Test
import java.io.IOException
@@ -35,6 +39,9 @@ class BaseURLViewModelTest : BaseUnitTest() {
@MockK(relaxUnitFun = true)
lateinit var trustedCertificatesStore: TrustedCertificatesStore
+ @RelaxedMockK
+ lateinit var baseUrlLogRedactor: BaseUrlLogRedactor
+
lateinit var subject: BaseURLViewModel
@Before
@@ -46,6 +53,7 @@ class BaseURLViewModelTest : BaseUnitTest() {
recipeRepo = recipeRepo,
logger = logger,
trustedCertificatesStore = trustedCertificatesStore,
+ baseUrlLogRedactor = baseUrlLogRedactor,
)
}
diff --git a/datasource/build.gradle.kts b/datasource/build.gradle.kts
index 8e9dfccb..7ae367a7 100644
--- a/datasource/build.gradle.kts
+++ b/datasource/build.gradle.kts
@@ -7,7 +7,7 @@ plugins {
android {
defaultConfig {
- buildConfigField("Boolean", "LOG_NETWORK", "false")
+ buildConfigField("Boolean", "LOG_NETWORK", "true")
consumerProguardFiles("consumer-proguard-rules.pro")
}
namespace = "gq.kirmanak.mealient.datasource"
@@ -31,7 +31,7 @@ dependencies {
implementation(platform(libs.okhttp3.bom))
implementation(libs.okhttp3.okhttp)
- debugImplementation(libs.okhttp3.loggingInterceptor)
+ implementation(libs.okhttp3.loggingInterceptor)
implementation(libs.ktor.core)
implementation(libs.ktor.auth)
diff --git a/datasource/src/debug/kotlin/gq/kirmanak/mealient/DebugModule.kt b/datasource/src/debug/kotlin/gq/kirmanak/mealient/DebugModule.kt
index 6ca1b865..ed426449 100644
--- a/datasource/src/debug/kotlin/gq/kirmanak/mealient/DebugModule.kt
+++ b/datasource/src/debug/kotlin/gq/kirmanak/mealient/DebugModule.kt
@@ -10,25 +10,12 @@ import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
-import gq.kirmanak.mealient.datasource.BuildConfig
-import gq.kirmanak.mealient.logging.Logger
import okhttp3.Interceptor
-import okhttp3.logging.HttpLoggingInterceptor
@Module
@InstallIn(SingletonComponent::class)
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
@IntoSet
diff --git a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/DataSourceModule.kt b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/DataSourceModule.kt
index cc564d5a..1cc09457 100644
--- a/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/DataSourceModule.kt
+++ b/datasource/src/main/kotlin/gq/kirmanak/mealient/datasource/DataSourceModule.kt
@@ -5,13 +5,17 @@ import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
+import dagger.multibindings.IntoSet
import gq.kirmanak.mealient.datasource.impl.MealieDataSourceImpl
import gq.kirmanak.mealient.datasource.impl.MealieServiceKtor
import gq.kirmanak.mealient.datasource.impl.NetworkRequestWrapperImpl
import gq.kirmanak.mealient.datasource.impl.OkHttpBuilderImpl
import gq.kirmanak.mealient.datasource.impl.TrustedCertificatesStoreImpl
+import gq.kirmanak.mealient.logging.Logger
import kotlinx.serialization.json.Json
+import okhttp3.Interceptor
import okhttp3.OkHttpClient
+import okhttp3.logging.HttpLoggingInterceptor
import javax.inject.Singleton
@Module
@@ -33,6 +37,17 @@ internal interface DataSourceModule {
fun provideOkHttp(okHttpBuilder: OkHttpBuilderImpl): OkHttpClient =
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
diff --git a/datasource/src/release/java/gq/kirmanak/mealient/ReleaseModule.kt b/datasource/src/release/java/gq/kirmanak/mealient/ReleaseModule.kt
deleted file mode 100644
index a8fdf32b..00000000
--- a/datasource/src/release/java/gq/kirmanak/mealient/ReleaseModule.kt
+++ /dev/null
@@ -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()
-}
diff --git a/logging/src/main/kotlin/gq/kirmanak/mealient/logging/LoggingModule.kt b/logging/src/main/kotlin/gq/kirmanak/mealient/logging/AppenderModule.kt
similarity index 75%
rename from logging/src/main/kotlin/gq/kirmanak/mealient/logging/LoggingModule.kt
rename to logging/src/main/kotlin/gq/kirmanak/mealient/logging/AppenderModule.kt
index 7f267a35..11df41bb 100644
--- a/logging/src/main/kotlin/gq/kirmanak/mealient/logging/LoggingModule.kt
+++ b/logging/src/main/kotlin/gq/kirmanak/mealient/logging/AppenderModule.kt
@@ -8,12 +8,13 @@ import dagger.multibindings.IntoSet
@Module
@InstallIn(SingletonComponent::class)
-interface LoggingModule {
+internal interface AppenderModule {
@Binds
- fun bindLogger(loggerImpl: LoggerImpl): Logger
+ @IntoSet
+ fun bindLogcatAppender(logcatAppender: LogcatAppender): Appender
@Binds
@IntoSet
- fun bindLogcatAppender(logcatAppender: LogcatAppender): Appender
+ fun bindFileAppender(fileAppender: FileAppender): Appender
}
\ No newline at end of file
diff --git a/logging/src/main/kotlin/gq/kirmanak/mealient/logging/FileAppender.kt b/logging/src/main/kotlin/gq/kirmanak/mealient/logging/FileAppender.kt
new file mode 100644
index 00000000..18e0e10e
--- /dev/null
+++ b/logging/src/main/kotlin/gq/kirmanak/mealient/logging/FileAppender.kt
@@ -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(
+ 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
+ }
+ }
+}
\ No newline at end of file
diff --git a/logging/src/main/kotlin/gq/kirmanak/mealient/logging/LogRedactor.kt b/logging/src/main/kotlin/gq/kirmanak/mealient/logging/LogRedactor.kt
new file mode 100644
index 00000000..19aafb5a
--- /dev/null
+++ b/logging/src/main/kotlin/gq/kirmanak/mealient/logging/LogRedactor.kt
@@ -0,0 +1,6 @@
+package gq.kirmanak.mealient.logging
+
+interface LogRedactor {
+
+ fun redact(message: String): String
+}
\ No newline at end of file
diff --git a/logging/src/main/kotlin/gq/kirmanak/mealient/logging/LogcatAppender.kt b/logging/src/main/kotlin/gq/kirmanak/mealient/logging/LogcatAppender.kt
index 78b4a1d4..f4aff9f5 100644
--- a/logging/src/main/kotlin/gq/kirmanak/mealient/logging/LogcatAppender.kt
+++ b/logging/src/main/kotlin/gq/kirmanak/mealient/logging/LogcatAppender.kt
@@ -4,7 +4,7 @@ import android.util.Log
import gq.kirmanak.mealient.architecture.configuration.BuildConfiguration
import javax.inject.Inject
-class LogcatAppender @Inject constructor(
+internal class LogcatAppender @Inject constructor(
private val buildConfiguration: BuildConfiguration,
) : Appender {
diff --git a/logging/src/main/kotlin/gq/kirmanak/mealient/logging/Logger.kt b/logging/src/main/kotlin/gq/kirmanak/mealient/logging/Logger.kt
index 66b83149..53f16364 100644
--- a/logging/src/main/kotlin/gq/kirmanak/mealient/logging/Logger.kt
+++ b/logging/src/main/kotlin/gq/kirmanak/mealient/logging/Logger.kt
@@ -1,7 +1,12 @@
package gq.kirmanak.mealient.logging
+import android.content.Context
+import java.io.File
+
typealias MessageSupplier = () -> String
+private const val LOG_FILE_NAME = "log.txt"
+
interface Logger {
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 e(throwable: Throwable? = null, tag: String? = null, messageSupplier: MessageSupplier)
+}
+
+fun Context.getLogFile(): File {
+ return File(filesDir, LOG_FILE_NAME)
}
\ No newline at end of file
diff --git a/logging/src/main/kotlin/gq/kirmanak/mealient/logging/LoggerImpl.kt b/logging/src/main/kotlin/gq/kirmanak/mealient/logging/LoggerImpl.kt
index 09d79769..40a194bd 100644
--- a/logging/src/main/kotlin/gq/kirmanak/mealient/logging/LoggerImpl.kt
+++ b/logging/src/main/kotlin/gq/kirmanak/mealient/logging/LoggerImpl.kt
@@ -6,6 +6,7 @@ import javax.inject.Inject
class LoggerImpl @Inject constructor(
private val appenders: Set<@JvmSuppressWildcards Appender>,
+ private val redactors: Set<@JvmSuppressWildcards LogRedactor>,
) : Logger {
override fun v(throwable: Throwable?, tag: String?, messageSupplier: MessageSupplier) {
@@ -45,12 +46,23 @@ class LoggerImpl @Inject constructor(
if (appender.isLoggable(logLevel, logTag).not()) continue
- message = message ?: (messageSupplier() + createStackTrace(t))
+ message = message ?: buildLogMessage(messageSupplier, t)
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 =
throwable?.let { Log.getStackTraceString(it) }
?.takeUnless { it.isBlank() }
diff --git a/logging/src/main/kotlin/gq/kirmanak/mealient/logging/LoggerModule.kt b/logging/src/main/kotlin/gq/kirmanak/mealient/logging/LoggerModule.kt
new file mode 100644
index 00000000..d02312ed
--- /dev/null
+++ b/logging/src/main/kotlin/gq/kirmanak/mealient/logging/LoggerModule.kt
@@ -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
+
+}
\ No newline at end of file
diff --git a/testing/src/main/kotlin/gq/kirmanak/mealient/test/FakeLoggingModule.kt b/testing/src/main/kotlin/gq/kirmanak/mealient/test/FakeLoggerModule.kt
similarity index 74%
rename from testing/src/main/kotlin/gq/kirmanak/mealient/test/FakeLoggingModule.kt
rename to testing/src/main/kotlin/gq/kirmanak/mealient/test/FakeLoggerModule.kt
index 8021399e..d1684277 100644
--- a/testing/src/main/kotlin/gq/kirmanak/mealient/test/FakeLoggingModule.kt
+++ b/testing/src/main/kotlin/gq/kirmanak/mealient/test/FakeLoggerModule.kt
@@ -5,14 +5,14 @@ import dagger.Module
import dagger.hilt.components.SingletonComponent
import dagger.hilt.testing.TestInstallIn
import gq.kirmanak.mealient.logging.Logger
-import gq.kirmanak.mealient.logging.LoggingModule
+import gq.kirmanak.mealient.logging.LoggerModule
@Module
@TestInstallIn(
components = [SingletonComponent::class],
- replaces = [LoggingModule::class]
+ replaces = [LoggerModule::class]
)
-interface FakeLoggingModule {
+interface FakeLoggerModule {
@Binds
fun bindFakeLogger(impl: FakeLogger): Logger