Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions app/src/main/java/com/vonage/android/MainApplication.kt
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
package com.vonage.android

import android.app.Application
import com.vonage.android.data.ClientLogsRepository
import com.vonage.android.logging.OpenTokLoggingController
import com.vonage.android.notifications.VeraNotificationChannelRegistry
import com.vonage.logger.DefaultVonageLogger
import com.vonage.logger.LogLevel
import com.vonage.logger.interceptor.FileLogInterceptor
import dagger.hilt.android.HiltAndroidApp
import javax.inject.Inject

Expand All @@ -11,9 +16,26 @@ open class MainApplication : Application() {
@Inject
lateinit var notificationChannelRegistry: VeraNotificationChannelRegistry

@Inject
lateinit var clientLogsRepository: ClientLogsRepository

override fun onCreate() {
super.onCreate()

val minLogLevel = LogLevel.INFO

DefaultVonageLogger.init(
context = this,
retentionDays = FileLogInterceptor.DEFAULT_RETENTION_DAYS,
minLogLevel = minLogLevel,
)

OpenTokLoggingController.apply(
enabled = DefaultVonageLogger.isEnabled,
minLogLevel = DefaultVonageLogger.currentMinLogLevel,
)
clientLogsRepository.warmUp()

notificationChannelRegistry.createNotificationChannels()
}
}
163 changes: 163 additions & 0 deletions app/src/main/java/com/vonage/android/data/ClientLogsRepository.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
package com.vonage.android.data

import android.content.Context
import android.net.Uri
import androidx.core.content.FileProvider
import com.vonage.android.data.network.APIService
import com.vonage.android.data.storage.ClientLogsSettingsStorage
import com.vonage.android.logging.OpenTokLoggingController
import com.vonage.logger.DefaultVonageLogger
import com.vonage.logger.LogLevel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class ClientLogsRepository @Inject constructor(
@param:ApplicationContext private val context: Context,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: Extract file access behind a LogFileProvider interface

The repository currently depends on Android's Context directly, which ties it to the Android framework and makes unit testing harder (requires Robolectric or instrumented tests).

I'd suggest introducing a LogFileProvider interface that encapsulates all file and URI resolution logic, with an AndroidLogFileProvider implementation that holds the Context. The repository would depend only on the interface.

Why:

  • Testability — The repository becomes pure Kotlin. In tests you can provide a fake LogFileProvider that returns files from a temp directory, no Android dependencies needed.
  • Single Responsibility — The repository focuses on log settings and upload logic. File discovery and URI resolution are a separate concern.
  • Encapsulation — All knowledge about log directories, file suffixes, and FileProvider URIs lives in one place. If the storage strategy changes (e.g. different directory, different naming convention), only the provider needs updating.
  • Consistency — This follows the same pattern we already use elsewhere to abstract Android-specific dependencies behind interfaces.

The interface would expose methods like getLatestLogFile() and getLatestLogUri(), and the Android implementation would hold the Context internally. The repository wouldn't need to know anything about filesDir, FileProvider, or packageName.


private val apiService: APIService,
private val clientLogsSettingsStorage: ClientLogsSettingsStorage,
) {

private val repositoryScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)

private val _logsEnabled = MutableStateFlow(DefaultVonageLogger.isEnabled)
val logsEnabled: StateFlow<Boolean> = _logsEnabled.asStateFlow()

private val _logLevel = MutableStateFlow(DefaultVonageLogger.currentMinLogLevel)
val logLevel: StateFlow<LogLevel> = _logLevel.asStateFlow()

init {
repositoryScope.launch { restorePersistedSettings() }
}

fun warmUp() {
// No-op. Exists to force repository instantiation at app startup.
}

fun setLogsEnabled(enabled: Boolean) {
applyRuntimeSettings(enabled = enabled, level = _logLevel.value)
repositoryScope.launch {
clientLogsSettingsStorage.saveLogsEnabled(enabled)
}
}

fun setLogLevel(level: LogLevel) {
applyRuntimeSettings(enabled = _logsEnabled.value, level = level)
repositoryScope.launch {
clientLogsSettingsStorage.saveLogLevel(level)
}
}

fun getLatestLogFile(): File? {
val logDir = File(context.filesDir, DefaultVonageLogger.LOGS_DIRECTORY_NAME)
if (!logDir.exists() || !logDir.isDirectory) return null
return logDir.listFiles { file ->
file.isFile && file.name.endsWith(LOG_FILE_SUFFIX)
}?.maxByOrNull { it.name }
}

fun getLatestLogUri(): Uri? {
val file = getLatestLogFile() ?: return null
return FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
file,
)
}

suspend fun sendLogs(): SendClientLogsResult {
val payload = buildPayload() ?: return SendClientLogsResult.NoLogsAvailable
return runCatching {
apiService.sendClientLogs(payload.toRequestBody(JSON_MEDIA_TYPE))
}.fold(
onSuccess = { response ->
if (response.isSuccessful) {
SendClientLogsResult.Success
} else {
SendClientLogsResult.Failure
}
},
onFailure = {
SendClientLogsResult.Failure
},
)
}

private fun buildPayload(): String? {
val logDir = File(context.filesDir, DefaultVonageLogger.LOGS_DIRECTORY_NAME)
if (!logDir.exists() || !logDir.isDirectory) return null

val latestLogFile = logDir.listFiles { file ->
file.isFile && file.name.endsWith(LOG_FILE_SUFFIX)
}
?.maxByOrNull { it.name }

val entries = latestLogFile
?.readLines(Charsets.UTF_8)
?.filter(String::isNotBlank)
?.filter(::isUploadableLevel)
.orEmpty()

if (entries.isEmpty()) return null

return entries.joinToString(
separator = ",",
prefix = "[",
postfix = "]",
)
}

private suspend fun restorePersistedSettings() {
val persistedEnabled = clientLogsSettingsStorage.getLogsEnabled() ?: false
val persistedLevel = clientLogsSettingsStorage.getLogLevel() ?: _logLevel.value
applyRuntimeSettings(enabled = persistedEnabled, level = persistedLevel)
}

private fun applyRuntimeSettings(enabled: Boolean, level: LogLevel) {
DefaultVonageLogger.setEnabled(enabled)
DefaultVonageLogger.setMinLogLevel(level)
_logsEnabled.value = enabled
_logLevel.value = level
OpenTokLoggingController.apply(enabled = enabled, minLogLevel = level)
}

companion object {
private const val LOG_FILE_SUFFIX = ".json.log"
private val JSON_MEDIA_TYPE = "application/json".toMediaType()
private val LOG_LEVELS_TO_UPLOAD = setOf("error", "info")

private fun isUploadableLevel(logLine: String): Boolean {
val level = runCatching {
Json.parseToJsonElement(logLine)
.jsonObject["level"]
?.toString()
?.trim('"')
?.lowercase()
}.getOrNull()

return level in LOG_LEVELS_TO_UPLOAD
}
}
}

sealed interface SendClientLogsResult {
data object Success : SendClientLogsResult
data object NoLogsAvailable : SendClientLogsResult
data object Failure : SendClientLogsResult
}



Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.vonage.android.data.network

import okhttp3.RequestBody
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.POST
Expand All @@ -9,4 +10,7 @@ interface APIService {
@POST("feedback/report")
suspend fun report(@Body reportDataRequest: ReportDataRequest): Response<ReportResponse>

@POST("client-logs/batch")
suspend fun sendClientLogs(@Body requestBody: RequestBody): Response<Unit>

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.vonage.android.data.storage

import androidx.datastore.preferences.core.edit
import com.vonage.android.data.storage.GlobalDataStorage.Companion.CLIENT_LOGS_ENABLED
import com.vonage.android.data.storage.GlobalDataStorage.Companion.CLIENT_LOG_LEVEL
import com.vonage.android.util.ext.get
import com.vonage.logger.LogLevel
import javax.inject.Inject

class ClientLogsSettingsStorage @Inject constructor(
private val globalDataStorage: GlobalDataStorage,
) {
suspend fun saveLogsEnabled(enabled: Boolean) {
globalDataStorage.edit { preferences ->
preferences[CLIENT_LOGS_ENABLED] = enabled
}
}

suspend fun getLogsEnabled(): Boolean? = globalDataStorage.get(CLIENT_LOGS_ENABLED)

suspend fun saveLogLevel(level: LogLevel) {
globalDataStorage.edit { preferences ->
preferences[CLIENT_LOG_LEVEL] = level.name
}
}

suspend fun getLogLevel(): LogLevel? = globalDataStorage
.get(CLIENT_LOG_LEVEL)
?.let { value -> runCatching { LogLevel.valueOf(value) }.getOrNull() }
}

Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.vonage.android.data.storage
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import dagger.hilt.android.qualifiers.ApplicationContext
Expand All @@ -17,5 +18,7 @@ class GlobalDataStorage @Inject constructor(
) : DataStore<Preferences> by context.dataStore {
companion object {
val USER_NAME = stringPreferencesKey("user_name")
val CLIENT_LOGS_ENABLED = booleanPreferencesKey("client_logs_enabled")
val CLIENT_LOG_LEVEL = stringPreferencesKey("client_log_level")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package com.vonage.android.logging

import com.opentok.android.OpenTokConfig
import com.vonage.logger.LogLevel
import com.vonage.logger.VonageLogger
import com.vonage.logger.interceptor.OpenTokLogcatInterceptor
import com.vonage.logger.vonageLogger

/**
* Keeps OpenTok SDK log emission and interception aligned with app logging settings.
*/
object OpenTokLoggingController {

private val lock = Any()

@Volatile
private var interceptor: OpenTokLogcatInterceptor? = null

@Volatile
private var currentMinLogLevel: LogLevel? = null

fun apply(
enabled: Boolean,
minLogLevel: LogLevel,
logger: VonageLogger = vonageLogger,
) {
synchronized(lock) {
if (!enabled) {
setOpenTokLogsEnabled(false)
interceptor?.stop()
interceptor = null
currentMinLogLevel = null
return
}

setOpenTokLogsEnabled(true)

if (interceptor != null && currentMinLogLevel == minLogLevel) return

interceptor?.stop()
interceptor = OpenTokLogcatInterceptor(
logger = logger,
minLogLevel = minLogLevel,
).also { it.start() }
currentMinLogLevel = minLogLevel
}
}

private fun setOpenTokLogsEnabled(enabled: Boolean) {
OpenTokConfig.setWebRTCLogs(enabled)
OpenTokConfig.setOTKitLogs(enabled)
OpenTokConfig.setJNILogs(enabled)
}
}
Loading
Loading