Skip to content

Commit

Permalink
Implement sending logs to developer (#190)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
kirmanak authored Dec 10, 2023
1 parent f6f44c7 commit 36a72b6
Show file tree
Hide file tree
Showing 29 changed files with 499 additions and 156 deletions.
88 changes: 49 additions & 39 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,46 +1,56 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
xmlns:tools="http://schemas.android.com/tools">

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />

<application
android:name="gq.kirmanak.mealient.App"
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/full_backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher"
android:supportsRtl="true"
android:theme="@style/Theme.App.Starting"
android:localeConfig="@xml/locales_config"
tools:ignore="UnusedAttribute">
<activity
android:name=".ui.activity.MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustPan">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<application
android:name="gq.kirmanak.mealient.App"
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/full_backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:localeConfig="@xml/locales_config"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher"
android:supportsRtl="true"
android:theme="@style/Theme.App.Starting"
tools:ignore="UnusedAttribute">
<activity
android:name=".ui.activity.MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustPan">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".ui.share.ShareRecipeActivity"
android:exported="true"
android:theme="@style/AppTheme">
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
<meta-data
android:name="android.service.chooser.chooser_target_service"
android:value="androidx.sharetarget.ChooserTargetServiceCompat" />
</activity>
</application>
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".ui.share.ShareRecipeActivity"
android:exported="true"
android:theme="@style/AppTheme">
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
<meta-data
android:name="android.service.chooser.chooser_target_service"
android:value="androidx.sharetarget.ChooserTargetServiceCompat" />
</activity>

<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_provider_paths" />
</provider>
</application>

</manifest>
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,20 @@ class AuthRepoImpl @Inject constructor(
private val authDataSource: AuthDataSource,
private val logger: Logger,
private val signOutHandler: SignOutHandler,
private val credentialsLogRedactor: CredentialsLogRedactor,
) : AuthRepo, AuthenticationProvider {

override val isAuthorizedFlow: Flow<Boolean>
get() = authStorage.authTokenFlow.map { it != null }

override suspend fun authenticate(email: String, password: String) {
logger.v { "authenticate() called with: email = $email, password = $password" }
logger.v { "authenticate() called" }
credentialsLogRedactor.set(email, password)
val token = authDataSource.authenticate(email, password)
authStorage.setAuthToken(token)
val apiToken = authDataSource.createApiToken(API_TOKEN_NAME)
authStorage.setAuthToken(apiToken)
credentialsLogRedactor.clear()
}

override suspend fun getAuthToken(): String? = authStorage.getAuthToken()
Expand Down
Original file line number Diff line number Diff line change
@@ -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>")
}
}
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions app/src/main/java/gq/kirmanak/mealient/di/AuthModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -31,4 +34,8 @@ interface AuthModule {

@Binds
fun bindShoppingListsAuthRepo(impl: AuthRepoImpl): ShoppingListsAuthRepo

@Binds
@IntoSet
fun bindCredentialsLogRedactor(impl: CredentialsLogRedactor): LogRedactor
}
7 changes: 7 additions & 0 deletions app/src/main/java/gq/kirmanak/mealient/di/BaseURLModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -23,4 +26,8 @@ interface BaseURLModule {

@Binds
fun bindServerUrlProvider(serverInfoRepoImpl: ServerInfoRepoImpl): ServerUrlProvider

@Binds
@IntoSet
fun bindBaseUrlLogRedactor(impl: BaseUrlLogRedactor): LogRedactor
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
46 changes: 46 additions & 0 deletions app/src/main/java/gq/kirmanak/mealient/ui/activity/MainActivity.kt
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
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
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
Expand All @@ -20,10 +24,13 @@ import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.databinding.MainActivityBinding
import gq.kirmanak.mealient.extensions.collectWhenResumed
import gq.kirmanak.mealient.extensions.observeOnce
import gq.kirmanak.mealient.logging.getLogFile
import gq.kirmanak.mealient.ui.ActivityUiState
import gq.kirmanak.mealient.ui.BaseActivity
import gq.kirmanak.mealient.ui.CheckableMenuItem

private const val EMAIL_FOR_LOGS = "mealient@gmail.com"

@AndroidEntryPoint
class MainActivity : BaseActivity<MainActivityBinding>(
binder = MainActivityBinding::bind,
Expand Down Expand Up @@ -87,13 +94,52 @@ class MainActivity : BaseActivity<MainActivityBinding>(
viewModel.logout()
return true
}

R.id.email_logs -> {
emailLogs()
return true
}

else -> throw IllegalArgumentException("Unknown menu item id: ${menuItem.itemId}")
}
menuItem.isChecked = true
navigateTo(directions)
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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ class AuthenticationViewModel @Inject constructor(
val uiState: LiveData<OperationUiState<Unit>> get() = _uiState

fun authenticate(email: String, password: String) {
logger.v { "authenticate() called with: email = $email, password = $password" }
logger.v { "authenticate() called" }
_uiState.value = OperationUiState.Progress()
viewModelScope.launch {
val result = runCatchingExceptCancel { authRepo.authenticate(email, password) }
Expand Down
Loading

0 comments on commit 36a72b6

Please sign in to comment.