diff --git a/WordPress/build.gradle b/WordPress/build.gradle index 904035c3af15..f617ac34401a 100644 --- a/WordPress/build.gradle +++ b/WordPress/build.gradle @@ -452,6 +452,7 @@ dependencies { implementation(libs.greenrobot.eventbus.main) implementation(libs.greenrobot.eventbus.java) implementation(libs.squareup.retrofit) + implementation(libs.chucker) implementation(libs.apache.commons.text) implementation(libs.airbnb.lottie.main) implementation(libs.facebook.shimmer) diff --git a/WordPress/src/main/java/org/wordpress/android/modules/ApplicationModule.java b/WordPress/src/main/java/org/wordpress/android/modules/ApplicationModule.java index cd7641468995..a3edcca516d8 100644 --- a/WordPress/src/main/java/org/wordpress/android/modules/ApplicationModule.java +++ b/WordPress/src/main/java/org/wordpress/android/modules/ApplicationModule.java @@ -5,6 +5,7 @@ import android.content.SharedPreferences; import android.hardware.SensorManager; +import androidx.annotation.NonNull; import androidx.lifecycle.LiveData; import androidx.preference.PreferenceManager; @@ -36,6 +37,7 @@ import org.wordpress.android.util.config.InAppUpdatesFeatureConfig; import org.wordpress.android.util.config.RemoteConfigWrapper; import org.wordpress.android.util.wizard.WizardManager; +import org.wordpress.android.fluxc.network.TrackNetworkRequestsInterceptor; import org.wordpress.android.viewmodel.helpers.ConnectionStatus; import org.wordpress.android.viewmodel.helpers.ConnectionStatusLiveData; @@ -153,7 +155,9 @@ public static RecordingStrategy provideVoiceToContentRecordingStrategy() { } @Provides - public static WpLoginClient provideWpLoginClient() { - return new WpLoginClient(Collections.emptyList()); + public static WpLoginClient provideWpLoginClient( + @NonNull TrackNetworkRequestsInterceptor trackNetworkRequestsInterceptor + ) { + return new WpLoginClient(Collections.singletonList(trackNetworkRequestsInterceptor)); } } diff --git a/WordPress/src/main/java/org/wordpress/android/modules/TrackNetworkRequestsModule.kt b/WordPress/src/main/java/org/wordpress/android/modules/TrackNetworkRequestsModule.kt new file mode 100644 index 000000000000..0f9a83712794 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/modules/TrackNetworkRequestsModule.kt @@ -0,0 +1,56 @@ +package org.wordpress.android.modules + +import android.content.Context +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.IntoSet +import okhttp3.Interceptor +import org.wordpress.android.fluxc.module.OkHttpClientQualifiers +import org.wordpress.android.fluxc.network.NetworkRequestsRetentionPeriod +import org.wordpress.android.fluxc.network.TrackNetworkRequestsInterceptor +import org.wordpress.android.fluxc.network.TrackNetworkRequestsPreference +import org.wordpress.android.ui.posts.editor.GutenbergKitNetworkLogger +import org.wordpress.android.ui.prefs.AppPrefsWrapper +import javax.inject.Named +import javax.inject.Singleton + +@InstallIn(SingletonComponent::class) +@Module +class TrackNetworkRequestsModule { + @Singleton + @Provides + fun provideTrackNetworkRequestsPreference(appPrefsWrapper: AppPrefsWrapper): TrackNetworkRequestsPreference { + return object : TrackNetworkRequestsPreference { + override fun isEnabled(): Boolean = appPrefsWrapper.isTrackNetworkRequestsEnabled + override fun getRetentionPeriod(): NetworkRequestsRetentionPeriod = + NetworkRequestsRetentionPeriod.fromInt(appPrefsWrapper.trackNetworkRequestsRetentionPeriod) + } + } + + @Singleton + @Provides + fun provideTrackNetworkRequestsInterceptor( + @ApplicationContext context: Context, + preference: TrackNetworkRequestsPreference + ): TrackNetworkRequestsInterceptor { + return TrackNetworkRequestsInterceptor(context, preference) + } + + @Provides + @IntoSet + @Named(OkHttpClientQualifiers.INTERCEPTORS) + fun provideTrackNetworkRequestsInterceptorAsInterceptor( + interceptor: TrackNetworkRequestsInterceptor + ): Interceptor = interceptor + + @Singleton + @Provides + fun provideGutenbergKitNetworkLogger( + interceptor: TrackNetworkRequestsInterceptor + ): GutenbergKitNetworkLogger { + return GutenbergKitNetworkLogger(interceptor) + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/support/main/ui/SupportActivity.kt b/WordPress/src/main/java/org/wordpress/android/support/main/ui/SupportActivity.kt index ee16d6c6565c..14ea79541611 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/main/ui/SupportActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/main/ui/SupportActivity.kt @@ -14,15 +14,18 @@ import androidx.core.net.toUri import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle +import com.chuckerteam.chucker.api.Chucker import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch import org.wordpress.android.BuildConfig +import org.wordpress.android.R +import org.wordpress.android.WordPress import org.wordpress.android.analytics.AnalyticsTracker import org.wordpress.android.analytics.AnalyticsTracker.Stat -import org.wordpress.android.WordPress +import org.wordpress.android.fluxc.network.NetworkRequestsRetentionPeriod import org.wordpress.android.support.aibot.ui.AIBotSupportActivity -import org.wordpress.android.support.logs.ui.LogsActivity import org.wordpress.android.support.he.ui.HESupportActivity +import org.wordpress.android.support.logs.ui.LogsActivity import org.wordpress.android.ui.ActivityLauncher import org.wordpress.android.ui.compose.theme.AppThemeM3 @@ -47,6 +50,8 @@ class SupportActivity : AppCompatActivity() { val userInfo by viewModel.userInfo.collectAsState() val optionsVisibility by viewModel.optionsVisibility.collectAsState() val isLoggedIn by viewModel.isLoggedIn.collectAsState() + val networkTrackingState by viewModel.networkTrackingState.collectAsState() + val dialogState by viewModel.dialogState.collectAsState() AppThemeM3 { SupportScreen( userName = userInfo.userName, @@ -55,13 +60,25 @@ class SupportActivity : AppCompatActivity() { isLoggedIn = isLoggedIn, showAskTheBots = optionsVisibility.showAskTheBots, showAskHappinessEngineers = optionsVisibility.showAskHappinessEngineers, + showNetworkDebugging = networkTrackingState.showNetworkDebugging, + isNetworkTrackingEnabled = networkTrackingState.isTrackingEnabled, + networkTrackingRetentionInfo = getRetentionInfoText( + networkTrackingState.retentionPeriod + ), versionName = WordPress.versionName, + dialogState = dialogState, onBackClick = { finish() }, onLoginClick = { viewModel.onLoginClick() }, onHelpCenterClick = { viewModel.onHelpCenterClick() }, onAskTheBotsClick = { viewModel.onAskTheBotsClick() }, onAskHappinessEngineersClick = { viewModel.onAskHappinessEngineersClick() }, - onApplicationLogsClick = { viewModel.onApplicationLogsClick() } + onApplicationLogsClick = { viewModel.onApplicationLogsClick() }, + onNetworkTrackingToggle = { viewModel.onNetworkTrackingToggle(it) }, + onViewNetworkRequestsClick = { viewModel.onViewNetworkRequestsClick() }, + onRetentionPeriodSelected = { viewModel.onRetentionPeriodSelected(it) }, + onEnableTrackingConfirmed = { viewModel.onEnableTrackingConfirmed(it) }, + onDisableTrackingConfirmed = { viewModel.onDisableTrackingConfirmed() }, + onDialogDismissed = { viewModel.onDialogDismissed() }, ) } } @@ -69,6 +86,24 @@ class SupportActivity : AppCompatActivity() { ) } + private fun getRetentionInfoText(period: NetworkRequestsRetentionPeriod): String { + val periodString = getRetentionPeriodDisplayString(period) + return getString(R.string.network_requests_retention_info, periodString) + } + +private fun getRetentionPeriodDisplayString(period: NetworkRequestsRetentionPeriod): String { + return getString(getRetentionPeriodStringRes(period)) +} + +private fun getRetentionPeriodStringRes(period: NetworkRequestsRetentionPeriod): Int { + return when (period) { + NetworkRequestsRetentionPeriod.ONE_HOUR -> R.string.network_requests_retention_one_hour + NetworkRequestsRetentionPeriod.ONE_DAY -> R.string.network_requests_retention_one_day + NetworkRequestsRetentionPeriod.ONE_WEEK -> R.string.network_requests_retention_one_week + NetworkRequestsRetentionPeriod.FOREVER -> R.string.network_requests_retention_until_cleared + } +} + private fun observeNavigationEvents() { lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { @@ -81,6 +116,9 @@ class SupportActivity : AppCompatActivity() { is SupportViewModel.NavigationEvent.NavigateToAskHappinessEngineers -> { navigateToAskTheHappinessEngineers() } + is SupportViewModel.NavigationEvent.NavigateToNetworkRequests -> { + navigateToNetworkRequests() + } } } } @@ -120,6 +158,10 @@ class SupportActivity : AppCompatActivity() { startActivity(LogsActivity.createIntent(this)) } + private fun navigateToNetworkRequests() { + startActivity(Chucker.getLaunchIntent(this)) + } + companion object { @JvmStatic fun createIntent(context: Context): Intent = Intent(context, SupportActivity::class.java) diff --git a/WordPress/src/main/java/org/wordpress/android/support/main/ui/SupportScreen.kt b/WordPress/src/main/java/org/wordpress/android/support/main/ui/SupportScreen.kt index c9c1a4690ae7..11d9f353671d 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/main/ui/SupportScreen.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/main/ui/SupportScreen.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -13,13 +14,16 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -33,7 +37,9 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import org.wordpress.android.R +import org.wordpress.android.fluxc.network.NetworkRequestsRetentionPeriod import org.wordpress.android.ui.compose.components.MainTopAppBar +import org.wordpress.android.ui.compose.components.SingleChoiceAlertDialog import org.wordpress.android.ui.compose.components.NavigationIcons import org.wordpress.android.ui.compose.theme.AppThemeM3 import org.wordpress.android.ui.dataview.compose.RemoteImage @@ -47,14 +53,43 @@ fun SupportScreen( isLoggedIn: Boolean, showAskTheBots: Boolean, showAskHappinessEngineers: Boolean, + showNetworkDebugging: Boolean, + isNetworkTrackingEnabled: Boolean, + networkTrackingRetentionInfo: String, versionName: String, + dialogState: SupportViewModel.DialogState, onBackClick: () -> Unit, onLoginClick: () -> Unit, onHelpCenterClick: () -> Unit, onAskTheBotsClick: () -> Unit, onAskHappinessEngineersClick: () -> Unit, onApplicationLogsClick: () -> Unit, + onNetworkTrackingToggle: (Boolean) -> Unit, + onViewNetworkRequestsClick: () -> Unit, + onRetentionPeriodSelected: (NetworkRequestsRetentionPeriod) -> Unit, + onEnableTrackingConfirmed: (NetworkRequestsRetentionPeriod) -> Unit, + onDisableTrackingConfirmed: () -> Unit, + onDialogDismissed: () -> Unit, ) { + // Show dialogs based on state + when (dialogState) { + is SupportViewModel.DialogState.EnableTracking -> { + EnableTrackingDialog( + selectedPeriod = dialogState.selectedPeriod, + onPeriodSelected = onRetentionPeriodSelected, + onConfirm = { onEnableTrackingConfirmed(dialogState.selectedPeriod) }, + onDismiss = onDialogDismissed + ) + } + is SupportViewModel.DialogState.DisableTracking -> { + DisableTrackingDialog( + onConfirm = onDisableTrackingConfirmed, + onDismiss = onDialogDismissed + ) + } + SupportViewModel.DialogState.Hidden -> { /* No dialog */ } + } + Scaffold( topBar = { MainTopAppBar( @@ -198,6 +233,38 @@ fun SupportScreen( color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) ) + // Network Tracking Section + if (showNetworkDebugging) { + NetworkTrackingToggleItem( + title = stringResource(R.string.track_network_requests), + description = stringResource(R.string.track_network_requests_description), + isChecked = isNetworkTrackingEnabled, + onCheckedChange = onNetworkTrackingToggle, + ) + + if (isNetworkTrackingEnabled) { + Text( + text = networkTrackingRetentionInfo, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 8.dp) + ) + + SupportOptionItem( + title = stringResource(R.string.view_network_requests), + description = "", + onClick = onViewNetworkRequestsClick, + ) + } + + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) + ) + } + // Version Name Text( text = stringResource(R.string.version_with_name_param, versionName), @@ -257,6 +324,102 @@ private fun SupportOptionItem( } } +@Composable +private fun NetworkTrackingToggleItem( + title: String, + description: String, + isChecked: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onCheckedChange(!isChecked) } + .padding(horizontal = 16.dp, vertical = 16.dp) + .semantics(mergeDescendants = true) { + contentDescription = "$title. $description" + }, + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + modifier = Modifier.padding(top = 4.dp), + text = description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Spacer(modifier = Modifier.size(16.dp)) + Switch( + checked = isChecked, + onCheckedChange = null, // Handled by row click + ) + } +} + +@Composable +private fun EnableTrackingDialog( + selectedPeriod: NetworkRequestsRetentionPeriod, + onPeriodSelected: (NetworkRequestsRetentionPeriod) -> Unit, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + val periods = NetworkRequestsRetentionPeriod.entries + val options = periods.map { period -> + when (period) { + NetworkRequestsRetentionPeriod.ONE_HOUR -> + stringResource(R.string.network_requests_retention_one_hour) + NetworkRequestsRetentionPeriod.ONE_DAY -> + stringResource(R.string.network_requests_retention_one_day) + NetworkRequestsRetentionPeriod.ONE_WEEK -> + stringResource(R.string.network_requests_retention_one_week) + NetworkRequestsRetentionPeriod.FOREVER -> + stringResource(R.string.network_requests_retention_until_cleared) + } + } + + SingleChoiceAlertDialog( + title = stringResource(R.string.track_network_requests), + message = stringResource(R.string.network_requests_enable_dialog_description), + options = options, + selectedIndex = periods.indexOf(selectedPeriod), + onOptionSelected = { index -> onPeriodSelected(periods[index]) }, + onConfirm = onConfirm, + onDismiss = onDismiss, + confirmButtonText = stringResource(R.string.network_requests_enable) + ) +} + +@Composable +private fun DisableTrackingDialog( + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.network_requests_disable_tracking_title)) }, + text = { Text(stringResource(R.string.network_requests_disable_tracking_description)) }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text(stringResource(R.string.network_requests_disable)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.cancel)) + } + } + ) +} + @Preview(showBackground = true, name = "Support Screen - Light - Logged In") @Composable private fun SupportScreenPreview() { @@ -268,13 +431,23 @@ private fun SupportScreenPreview() { isLoggedIn = true, showAskTheBots = true, showAskHappinessEngineers = true, + showNetworkDebugging = true, + isNetworkTrackingEnabled = true, + networkTrackingRetentionInfo = "Retention: 1 Hour", versionName = "1.0.0", + dialogState = SupportViewModel.DialogState.Hidden, onBackClick = {}, onLoginClick = {}, onHelpCenterClick = {}, onAskTheBotsClick = {}, onAskHappinessEngineersClick = {}, - onApplicationLogsClick = {} + onApplicationLogsClick = {}, + onNetworkTrackingToggle = {}, + onViewNetworkRequestsClick = {}, + onRetentionPeriodSelected = {}, + onEnableTrackingConfirmed = {}, + onDisableTrackingConfirmed = {}, + onDialogDismissed = {}, ) } } @@ -290,13 +463,23 @@ private fun SupportScreenPreviewDark() { isLoggedIn = true, showAskTheBots = true, showAskHappinessEngineers = true, + showNetworkDebugging = true, + isNetworkTrackingEnabled = false, + networkTrackingRetentionInfo = "", versionName = "1.0.0", + dialogState = SupportViewModel.DialogState.Hidden, onBackClick = {}, onLoginClick = {}, onHelpCenterClick = {}, onAskTheBotsClick = {}, onAskHappinessEngineersClick = {}, - onApplicationLogsClick = {} + onApplicationLogsClick = {}, + onNetworkTrackingToggle = {}, + onViewNetworkRequestsClick = {}, + onRetentionPeriodSelected = {}, + onEnableTrackingConfirmed = {}, + onDisableTrackingConfirmed = {}, + onDialogDismissed = {}, ) } } @@ -312,13 +495,23 @@ private fun SupportScreenPreviewLoggedOut() { isLoggedIn = false, showAskTheBots = false, showAskHappinessEngineers = false, + showNetworkDebugging = false, + isNetworkTrackingEnabled = false, + networkTrackingRetentionInfo = "", versionName = "1.0.0", + dialogState = SupportViewModel.DialogState.Hidden, onBackClick = {}, onLoginClick = {}, onHelpCenterClick = {}, onAskTheBotsClick = {}, onAskHappinessEngineersClick = {}, - onApplicationLogsClick = {} + onApplicationLogsClick = {}, + onNetworkTrackingToggle = {}, + onViewNetworkRequestsClick = {}, + onRetentionPeriodSelected = {}, + onEnableTrackingConfirmed = {}, + onDisableTrackingConfirmed = {}, + onDialogDismissed = {}, ) } } diff --git a/WordPress/src/main/java/org/wordpress/android/support/main/ui/SupportViewModel.kt b/WordPress/src/main/java/org/wordpress/android/support/main/ui/SupportViewModel.kt index 0eed4449c268..ba7dde83dc5b 100644 --- a/WordPress/src/main/java/org/wordpress/android/support/main/ui/SupportViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/support/main/ui/SupportViewModel.kt @@ -11,9 +11,12 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import org.wordpress.android.BuildConfig +import org.wordpress.android.fluxc.network.NetworkRequestsRetentionPeriod import org.wordpress.android.fluxc.store.AccountStore import org.wordpress.android.fluxc.utils.AppLogWrapper import org.wordpress.android.support.common.model.UserInfo +import org.wordpress.android.ui.prefs.AppPrefsWrapper +import org.wordpress.android.ui.prefs.experimentalfeatures.ExperimentalFeatures import org.wordpress.android.util.AppLog import javax.inject.Inject @@ -21,6 +24,8 @@ import javax.inject.Inject class SupportViewModel @Inject constructor( private val accountStore: AccountStore, private val appLogWrapper: AppLogWrapper, + private val appPrefsWrapper: AppPrefsWrapper, + private val experimentalFeatures: ExperimentalFeatures, ) : ViewModel() { sealed class NavigationEvent { data object NavigateToAskTheBots : NavigationEvent() @@ -28,6 +33,16 @@ class SupportViewModel @Inject constructor( data object NavigateToHelpCenter : NavigationEvent() data object NavigateToApplicationLogs : NavigationEvent() data object NavigateToAskHappinessEngineers : NavigationEvent() + data object NavigateToNetworkRequests : NavigationEvent() + } + + sealed class DialogState { + data object Hidden : DialogState() + data class EnableTracking( + val selectedPeriod: NetworkRequestsRetentionPeriod + ) : DialogState() + + data object DisableTracking : DialogState() } data class SupportOptionsVisibility( @@ -35,6 +50,12 @@ class SupportViewModel @Inject constructor( val showAskHappinessEngineers: Boolean = true ) + data class NetworkTrackingState( + val showNetworkDebugging: Boolean = false, + val isTrackingEnabled: Boolean = false, + val retentionPeriod: NetworkRequestsRetentionPeriod = NetworkRequestsRetentionPeriod.ONE_HOUR + ) + private val _userInfo = MutableStateFlow(UserInfo("", "", null)) val userInfo: StateFlow = _userInfo.asStateFlow() @@ -47,6 +68,12 @@ class SupportViewModel @Inject constructor( private val _navigationEvents = MutableSharedFlow() val navigationEvents: SharedFlow = _navigationEvents.asSharedFlow() + private val _dialogState = MutableStateFlow(DialogState.Hidden) + val dialogState: StateFlow = _dialogState.asStateFlow() + + private val _networkTrackingState = MutableStateFlow(NetworkTrackingState()) + val networkTrackingState: StateFlow = _networkTrackingState.asStateFlow() + fun init() { val hasAccessToken = accountStore.hasAccessToken() _isLoggedIn.value = hasAccessToken @@ -62,6 +89,22 @@ class SupportViewModel @Inject constructor( showAskTheBots = hasAccessToken && BuildConfig.IS_JETPACK_APP, showAskHappinessEngineers = hasAccessToken && BuildConfig.IS_JETPACK_APP ) + + initNetworkTrackingState() + } + + private fun initNetworkTrackingState() { + val isFeatureEnabled = experimentalFeatures.isEnabled(ExperimentalFeatures.Feature.NETWORK_DEBUGGING) + val isTrackingEnabled = appPrefsWrapper.isTrackNetworkRequestsEnabled + val retentionPeriod = NetworkRequestsRetentionPeriod.fromInt( + appPrefsWrapper.trackNetworkRequestsRetentionPeriod + ) + + _networkTrackingState.value = NetworkTrackingState( + showNetworkDebugging = isFeatureEnabled, + isTrackingEnabled = isTrackingEnabled, + retentionPeriod = retentionPeriod + ) } fun onHelpCenterClick() { @@ -105,4 +148,54 @@ class SupportViewModel @Inject constructor( _navigationEvents.emit(NavigationEvent.NavigateToLogin) } } + + fun onNetworkTrackingToggle(enabled: Boolean) { + if (enabled) { + val currentPeriod = NetworkRequestsRetentionPeriod.fromInt( + appPrefsWrapper.trackNetworkRequestsRetentionPeriod + ) + _dialogState.value = DialogState.EnableTracking(currentPeriod) + } else { + _dialogState.value = DialogState.DisableTracking + } + } + + fun onEnableTrackingConfirmed(period: NetworkRequestsRetentionPeriod) { + appPrefsWrapper.trackNetworkRequestsRetentionPeriod = period.value + appPrefsWrapper.isTrackNetworkRequestsEnabled = true + + _networkTrackingState.value = _networkTrackingState.value.copy( + isTrackingEnabled = true, + retentionPeriod = period + ) + _dialogState.value = DialogState.Hidden + appLogWrapper.d(AppLog.T.API, "Track network requests enabled with retention: $period") + } + + fun onDisableTrackingConfirmed() { + appPrefsWrapper.isTrackNetworkRequestsEnabled = false + + _networkTrackingState.value = _networkTrackingState.value.copy( + isTrackingEnabled = false + ) + _dialogState.value = DialogState.Hidden + appLogWrapper.d(AppLog.T.API, "Track network requests disabled") + } + + fun onDialogDismissed() { + _dialogState.value = DialogState.Hidden + } + + fun onRetentionPeriodSelected(period: NetworkRequestsRetentionPeriod) { + val currentState = _dialogState.value + if (currentState is DialogState.EnableTracking) { + _dialogState.value = currentState.copy(selectedPeriod = period) + } + } + + fun onViewNetworkRequestsClick() { + viewModelScope.launch { + _navigationEvents.emit(NavigationEvent.NavigateToNetworkRequests) + } + } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/accounts/HelpActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/accounts/HelpActivity.kt index 2f24eb7f8415..af0eb05068f1 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/accounts/HelpActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/accounts/HelpActivity.kt @@ -2,16 +2,22 @@ package org.wordpress.android.ui.accounts +import android.annotation.SuppressLint import android.app.ProgressDialog import android.content.Context import android.content.Intent import android.os.Bundle import android.text.TextUtils import android.view.MenuItem +import android.widget.CompoundButton +import android.widget.TextView import androidx.activity.viewModels +import androidx.appcompat.app.AlertDialog import androidx.core.net.toUri import androidx.core.view.isVisible +import com.chuckerteam.chucker.api.Chucker import dagger.hilt.android.AndroidEntryPoint +import org.wordpress.android.fluxc.network.NetworkRequestsRetentionPeriod import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode.MAIN import org.wordpress.android.BuildConfig @@ -40,6 +46,7 @@ import org.wordpress.android.ui.main.BaseAppCompatActivity import org.wordpress.android.ui.main.utils.MeGravatarLoader import org.wordpress.android.ui.prefs.AppPrefs import org.wordpress.android.ui.prefs.AppPrefsWrapper +import org.wordpress.android.ui.prefs.experimentalfeatures.ExperimentalFeatures import org.wordpress.android.util.AppLog import org.wordpress.android.util.AppLog.T.API import org.wordpress.android.util.SiteUtils @@ -75,6 +82,9 @@ class HelpActivity : BaseAppCompatActivity() { @Inject lateinit var contactSupportFeatureConfig: ContactSupportFeatureConfig + @Inject + lateinit var experimentalFeatures: ExperimentalFeatures + private lateinit var binding: HelpActivityBinding @Suppress("DEPRECATION") @@ -125,6 +135,8 @@ class HelpActivity : BaseAppCompatActivity() { if (originFromExtras == Origin.JETPACK_MIGRATION_HELP) { configureForJetpackMigrationHelp() } + + setupTrackNetworkRequestsToggle() } /** * If the user taps on a Zendesk notification, we want to show them the `My Tickets` page. However, this @@ -144,6 +156,121 @@ class HelpActivity : BaseAppCompatActivity() { } } + private fun HelpActivityBinding.setupTrackNetworkRequestsToggle() { + // Hide entire section if experimental feature is not enabled + val isFeatureEnabled = experimentalFeatures.isEnabled(ExperimentalFeatures.Feature.NETWORK_DEBUGGING) + trackNetworkRequestsSection.isVisible = isFeatureEnabled + + if (!isFeatureEnabled) { + return + } + + val isEnabled = appPrefsWrapper.isTrackNetworkRequestsEnabled + trackNetworkRequestsSwitch.isChecked = isEnabled + networkRequestsRetentionInfo.isVisible = isEnabled + viewNetworkRequestsButton.isVisible = isEnabled + if (isEnabled) { + updateRetentionInfoText() + } + + trackNetworkRequestsSwitch.setOnCheckedChangeListener(trackNetworkRequestsCheckedChangeListener) + viewNetworkRequestsButton.setOnClickListener { + startActivity(Chucker.getLaunchIntent(this@HelpActivity)) + } + } + + private fun HelpActivityBinding.updateRetentionInfoText() { + val period = NetworkRequestsRetentionPeriod.fromInt(appPrefsWrapper.trackNetworkRequestsRetentionPeriod) + val periodString = getRetentionPeriodDisplayString(period) + networkRequestsRetentionInfo.text = getString(R.string.network_requests_retention_info, periodString) + } + + /** + * Listener for the track network requests switch. Defined as a property so we can + * remove/re-add it when programmatically changing the switch state. + */ + private val trackNetworkRequestsCheckedChangeListener = + CompoundButton.OnCheckedChangeListener { _, isChecked -> + if (isChecked) { + showEnableTrackingDialog() + } else { + showDisableTrackingDialog() + } + } + + /** + * Sets the switch state without triggering the listener. + */ + private fun HelpActivityBinding.setSwitchCheckedSilently(checked: Boolean) { + trackNetworkRequestsSwitch.setOnCheckedChangeListener(null) + trackNetworkRequestsSwitch.isChecked = checked + trackNetworkRequestsSwitch.setOnCheckedChangeListener(trackNetworkRequestsCheckedChangeListener) + } + + private fun getRetentionPeriodDisplayString(period: NetworkRequestsRetentionPeriod): String { + return when (period) { + NetworkRequestsRetentionPeriod.ONE_HOUR -> getString(R.string.network_requests_retention_one_hour) + NetworkRequestsRetentionPeriod.ONE_DAY -> getString(R.string.network_requests_retention_one_day) + NetworkRequestsRetentionPeriod.ONE_WEEK -> getString(R.string.network_requests_retention_one_week) + NetworkRequestsRetentionPeriod.FOREVER -> getString(R.string.network_requests_retention_until_cleared) + } + } + + private fun showEnableTrackingDialog() { + val periods = NetworkRequestsRetentionPeriod.entries.toTypedArray() + val displayNames = periods.map { getRetentionPeriodDisplayString(it) }.toTypedArray() + val currentPeriod = NetworkRequestsRetentionPeriod.fromInt(appPrefsWrapper.trackNetworkRequestsRetentionPeriod) + var selectedIndex = periods.indexOf(currentPeriod) + + // Custom title view with title + description (setMessage conflicts with setSingleChoiceItems) + @SuppressLint("InflateParams") // Parent is null because AlertDialog attaches it internally + val titleView = layoutInflater.inflate(R.layout.dialog_title_with_message, null).apply { + findViewById(R.id.dialog_title).setText(R.string.track_network_requests) + findViewById(R.id.dialog_message).setText(R.string.network_requests_enable_dialog_description) + } + + AlertDialog.Builder(this) + .setCustomTitle(titleView) + .setSingleChoiceItems(displayNames, selectedIndex) { _, which -> + selectedIndex = which + } + .setPositiveButton(R.string.network_requests_enable) { _, _ -> + val selectedPeriod = periods[selectedIndex] + appPrefsWrapper.trackNetworkRequestsRetentionPeriod = selectedPeriod.value + appPrefsWrapper.isTrackNetworkRequestsEnabled = true + binding.networkRequestsRetentionInfo.isVisible = true + binding.viewNetworkRequestsButton.isVisible = true + binding.updateRetentionInfoText() + AppLog.d(API, "Track network requests enabled with retention: $selectedPeriod") + } + .setNegativeButton(R.string.cancel) { _, _ -> + binding.setSwitchCheckedSilently(false) + } + .setOnCancelListener { + binding.setSwitchCheckedSilently(false) + } + .show() + } + + private fun showDisableTrackingDialog() { + AlertDialog.Builder(this) + .setTitle(R.string.network_requests_disable_tracking_title) + .setMessage(R.string.network_requests_disable_tracking_description) + .setPositiveButton(R.string.network_requests_disable) { _, _ -> + appPrefsWrapper.isTrackNetworkRequestsEnabled = false + binding.networkRequestsRetentionInfo.isVisible = false + binding.viewNetworkRequestsButton.isVisible = false + AppLog.d(API, "Track network requests disabled") + } + .setNegativeButton(R.string.cancel) { _, _ -> + binding.setSwitchCheckedSilently(true) + } + .setOnCancelListener { + binding.setSwitchCheckedSilently(true) + } + .show() + } + override fun onResume() { super.onResume() ActivityId.trackLastActivity(ActivityId.HELP_SCREEN) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/accounts/applicationpassword/ApplicationPasswordsViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/accounts/applicationpassword/ApplicationPasswordsViewModel.kt index c8e8259ca4b7..d93285f31f45 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/accounts/applicationpassword/ApplicationPasswordsViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/accounts/applicationpassword/ApplicationPasswordsViewModel.kt @@ -8,6 +8,7 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.withContext import org.wordpress.android.R import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.TrackNetworkRequestsInterceptor import org.wordpress.android.fluxc.network.rest.wpapi.rs.WpApiClientProvider import org.wordpress.android.fluxc.store.AccountStore import org.wordpress.android.fluxc.utils.AppLogWrapper @@ -41,6 +42,7 @@ class ApplicationPasswordsViewModel @Inject constructor( sharedPrefs: SharedPreferences, networkUtilsWrapper: NetworkUtilsWrapper, @Named(IO_THREAD) ioDispatcher: CoroutineDispatcher, + trackNetworkRequestsInterceptor: TrackNetworkRequestsInterceptor, ) : DataViewViewModel( mainDispatcher = mainDispatcher, appLogWrapper = appLogWrapper, @@ -48,7 +50,8 @@ class ApplicationPasswordsViewModel @Inject constructor( networkUtilsWrapper = networkUtilsWrapper, selectedSiteRepository = selectedSiteRepository, accountStore = accountStore, - ioDispatcher = ioDispatcher + ioDispatcher = ioDispatcher, + trackNetworkRequestsInterceptor = trackNetworkRequestsInterceptor ) { init { initialize() diff --git a/WordPress/src/main/java/org/wordpress/android/ui/compose/components/SingleChoiceAlertDialog.kt b/WordPress/src/main/java/org/wordpress/android/ui/compose/components/SingleChoiceAlertDialog.kt new file mode 100644 index 000000000000..3f336498ca96 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/compose/components/SingleChoiceAlertDialog.kt @@ -0,0 +1,121 @@ +package org.wordpress.android.ui.compose.components + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.wordpress.android.R +import org.wordpress.android.ui.compose.theme.AppThemeM3 + +/** + * A dialog that displays a list of options with radio buttons for single selection. + * + * @param title The dialog title + * @param message Optional message displayed below the title + * @param options List of option labels to display + * @param selectedIndex Currently selected option index + * @param onOptionSelected Callback when an option is selected + * @param onConfirm Callback when the confirm button is clicked + * @param onDismiss Callback when the dialog is dismissed + * @param confirmButtonText Text for the confirm button + * @param dismissButtonText Text for the dismiss button, defaults to "Cancel" + */ +@Composable +fun SingleChoiceAlertDialog( + title: String, + message: String? = null, + options: List, + selectedIndex: Int, + onOptionSelected: (Int) -> Unit, + onConfirm: () -> Unit, + onDismiss: () -> Unit, + confirmButtonText: String, + dismissButtonText: String = stringResource(R.string.cancel), +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { + Column { + Text(text = title) + if (message != null) { + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 8.dp) + ) + } + } + }, + text = { + Column(modifier = Modifier.selectableGroup()) { + options.forEachIndexed { index, option -> + Row( + modifier = Modifier + .fillMaxWidth() + .selectable( + selected = index == selectedIndex, + onClick = { onOptionSelected(index) }, + role = Role.RadioButton + ) + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = index == selectedIndex, + onClick = null // Handled by row's selectable + ) + Text( + text = option, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(start = 8.dp) + ) + } + } + } + }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text(confirmButtonText) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(dismissButtonText) + } + } + ) +} + +@Preview(name = "Light Mode", showBackground = true) +@Preview(name = "Dark Mode", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun SingleChoiceAlertDialogPreview() { + AppThemeM3 { + SingleChoiceAlertDialog( + title = "Select Option", + message = "Choose one of the available options", + options = listOf("Option A", "Option B", "Option C"), + selectedIndex = 1, + onOptionSelected = {}, + onConfirm = {}, + onDismiss = {}, + confirmButtonText = "Confirm" + ) + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/dataview/DataViewViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/dataview/DataViewViewModel.kt index 6ae67d18af3c..34e1ad174370 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/dataview/DataViewViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/dataview/DataViewViewModel.kt @@ -21,6 +21,7 @@ import org.wordpress.android.modules.UI_THREAD import org.wordpress.android.ui.mysite.SelectedSiteRepository import org.wordpress.android.util.AppLog import org.wordpress.android.util.NetworkUtilsWrapper +import org.wordpress.android.fluxc.network.TrackNetworkRequestsInterceptor import org.wordpress.android.viewmodel.ScopedViewModel import rs.wordpress.api.kotlin.WpComApiClient import uniffi.wp_api.WpApiParamOrder @@ -43,6 +44,7 @@ open class DataViewViewModel @Inject constructor( private val selectedSiteRepository: SelectedSiteRepository, private val accountStore: AccountStore, @Named(IO_THREAD) protected val ioDispatcher: CoroutineDispatcher, + private val trackNetworkRequestsInterceptor: TrackNetworkRequestsInterceptor, ) : ScopedViewModel(mainDispatcher) { private val _uiState = MutableStateFlow(DataViewUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -86,7 +88,7 @@ open class DataViewViewModel @Inject constructor( WpAuthentication.Bearer(token = token) } ), - interceptors = emptyList() + interceptors = listOf(trackNetworkRequestsInterceptor) ) } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/jetpackrestconnection/JetpackConnectionHelper.kt b/WordPress/src/main/java/org/wordpress/android/ui/jetpackrestconnection/JetpackConnectionHelper.kt index 67bf37ea3962..8d1f603f201d 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/jetpackrestconnection/JetpackConnectionHelper.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/jetpackrestconnection/JetpackConnectionHelper.kt @@ -1,6 +1,7 @@ package org.wordpress.android.ui.jetpackrestconnection import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.TrackNetworkRequestsInterceptor import org.wordpress.android.fluxc.network.rest.wpapi.rs.WpApiClientProvider import org.wordpress.android.fluxc.utils.AppLogWrapper import org.wordpress.android.util.AppLog @@ -16,7 +17,8 @@ import javax.inject.Inject class JetpackConnectionHelper @Inject constructor( private val wpApiClientProvider: WpApiClientProvider, - private val appLogWrapper: AppLogWrapper + private val appLogWrapper: AppLogWrapper, + private val trackNetworkRequestsInterceptor: TrackNetworkRequestsInterceptor, ) { fun initWpApiClient(site: SiteModel): WpApiClient { requireRestCredentials(site) @@ -28,7 +30,7 @@ class JetpackConnectionHelper @Inject constructor( val delegate = WpApiClientDelegate( authProvider = createRestAuthProvider(site), - requestExecutor = WpRequestExecutor(interceptors = emptyList()), + requestExecutor = WpRequestExecutor(interceptors = listOf(trackNetworkRequestsInterceptor)), middlewarePipeline = WpApiMiddlewarePipeline(emptyList()), appNotifier = InvalidAuthNotifier() ) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/EditorConfigurationBuilder.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/EditorConfigurationBuilder.kt index 3ff8f8bb770d..a1bea3d546b0 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/EditorConfigurationBuilder.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/EditorConfigurationBuilder.kt @@ -48,6 +48,9 @@ object EditorConfigurationBuilder { // Cookies setCookies(settings.getSetting>("cookies") ?: emptyMap()) + // Network logging for debugging + setEnableNetworkLogging(settings.getSettingOrDefault("enableNetworkLogging", false)) + // Editor settings (null for warmup scenarios) setEditorSettings(editorSettings) }.build() diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitActivity.kt index 7239228f90f8..3563492b2f96 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitActivity.kt @@ -67,6 +67,7 @@ import org.wordpress.android.editor.EditorImageSettingsListener import org.wordpress.android.editor.ExceptionLogger import org.wordpress.android.editor.gutenberg.DialogVisibility import org.wordpress.android.ui.posts.editor.GutenbergKitEditorFragment +import org.wordpress.android.ui.posts.editor.GutenbergKitNetworkLogger import org.wordpress.android.editor.savedinstance.SavedInstanceDatabase import org.wordpress.android.editor.savedinstance.SavedInstanceDatabase.Companion.getDatabase import org.wordpress.android.fluxc.Dispatcher @@ -225,6 +226,7 @@ import org.wordpress.android.widgets.AppReviewManager.incrementInteractions import org.wordpress.android.widgets.WPSnackbar.Companion.make import org.wordpress.android.widgets.WPViewPager import org.wordpress.gutenberg.GutenbergView +import org.wordpress.gutenberg.RecordedNetworkRequest import java.io.File import java.util.regex.Matcher import java.util.regex.Pattern @@ -386,6 +388,7 @@ class GutenbergKitActivity : BaseAppCompatActivity(), EditorImageSettingsListene @Inject lateinit var storageUtilsViewModel: StorageUtilsViewModel @Inject lateinit var editorBloggingPromptsViewModel: EditorBloggingPromptsViewModel @Inject lateinit var editorJetpackSocialViewModel: EditorJetpackSocialViewModel + @Inject lateinit var gutenbergKitNetworkLogger: GutenbergKitNetworkLogger private lateinit var editPostNavigationViewModel: EditPostNavigationViewModel private lateinit var editPostSettingsViewModel: EditPostSettingsViewModel private lateinit var prepublishingViewModel: PrepublishingViewModel @@ -2304,7 +2307,8 @@ class GutenbergKitActivity : BaseAppCompatActivity(), EditorImageSettingsListene val featureConfig = GutenbergKitSettingsBuilder.FeatureConfig( isPluginsFeatureEnabled = gutenbergKitPluginsFeature.isEnabled(), - isThemeStylesFeatureEnabled = siteSettings?.useThemeStyles ?: true + isThemeStylesFeatureEnabled = siteSettings?.useThemeStyles ?: true, + isNetworkLoggingEnabled = AppPrefs.isTrackNetworkRequestsEnabled() ) val appConfig = GutenbergKitSettingsBuilder.AppConfig( @@ -2344,6 +2348,17 @@ class GutenbergKitActivity : BaseAppCompatActivity(), EditorImageSettingsListene // Set up custom headers for the visual editor's internal WebView editorFragment?.setCustomHttpHeader("User-Agent", userAgent.webViewUserAgent) + + // Set up network request logging if enabled + if (AppPrefs.isTrackNetworkRequestsEnabled()) { + editorFragment?.setNetworkRequestListener( + object : GutenbergView.NetworkRequestListener { + override fun onNetworkRequest(request: RecordedNetworkRequest) { + gutenbergKitNetworkLogger.log(request) + } + } + ) + } } VIEW_PAGER_PAGE_SETTINGS -> editPostSettingsFragment = fragment as EditPostSettingsFragment } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitSettingsBuilder.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitSettingsBuilder.kt index 94309435e09c..8e43e1eb2891 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitSettingsBuilder.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/GutenbergKitSettingsBuilder.kt @@ -70,7 +70,8 @@ object GutenbergKitSettingsBuilder { data class FeatureConfig( val isPluginsFeatureEnabled: Boolean, - val isThemeStylesFeatureEnabled: Boolean + val isThemeStylesFeatureEnabled: Boolean, + val isNetworkLoggingEnabled: Boolean = false ) data class AppConfig( @@ -141,7 +142,8 @@ object GutenbergKitSettingsBuilder { applicationPassword = applicationPassword ), "locale" to wpcomLocaleSlug, - "cookies" to appConfig.cookies + "cookies" to appConfig.cookies, + "enableNetworkLogging" to featureConfig.isNetworkLoggingEnabled ) } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/GutenbergKitEditorFragment.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/GutenbergKitEditorFragment.kt index 70e8f10b1461..eb865c9803ab 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/GutenbergKitEditorFragment.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/GutenbergKitEditorFragment.kt @@ -54,6 +54,7 @@ class GutenbergKitEditorFragment : GutenbergKitEditorFragmentBase() { private var openMediaLibraryListener: OpenMediaLibraryListener? = null private var onLogJsExceptionListener: LogJsExceptionListener? = null private var modalDialogStateListener: GutenbergView.ModalDialogStateListener? = null + private var networkRequestListener: GutenbergView.NetworkRequestListener? = null private var editorStarted = false private var isEditorDidMount = false @@ -177,6 +178,7 @@ class GutenbergKitEditorFragment : GutenbergKitEditorFragmentBase() { openMediaLibraryListener?.let(gutenbergView::setOpenMediaLibraryListener) onLogJsExceptionListener?.let(gutenbergView::setLogJsExceptionListener) modalDialogStateListener?.let(gutenbergView::setModalDialogStateListener) + networkRequestListener?.let(gutenbergView::setNetworkRequestListener) // Set up autocomplete listener for user mentions and cross-post suggestions gutenbergView.setAutocompleterTriggeredListener(object : GutenbergView.AutocompleterTriggeredListener { @@ -466,6 +468,11 @@ class GutenbergKitEditorFragment : GutenbergKitEditorFragmentBase() { isXPostsEnabled = enabled } + fun setNetworkRequestListener(listener: GutenbergView.NetworkRequestListener) { + networkRequestListener = listener + gutenbergView?.setNetworkRequestListener(listener) + } + private fun buildEditorConfiguration(editorSettings: String): EditorConfiguration { val settingsMap = settings!! return EditorConfigurationBuilder.build(settingsMap, editorSettings) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/GutenbergKitNetworkLogger.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/GutenbergKitNetworkLogger.kt new file mode 100644 index 000000000000..fb687ce0f40a --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/GutenbergKitNetworkLogger.kt @@ -0,0 +1,89 @@ +package org.wordpress.android.ui.posts.editor + +import okhttp3.Headers.Companion.toHeaders +import okhttp3.Interceptor +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.OkHttpClient +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import org.wordpress.android.fluxc.network.TrackNetworkRequestsInterceptor +import org.wordpress.android.util.AppLog +import org.wordpress.gutenberg.RecordedNetworkRequest + +/** + * Logs GutenbergKit WebView network requests to Chucker by replaying them through OkHttp. + * + * GutenbergKit intercepts JavaScript `fetch` calls and reports them via [RecordedNetworkRequest]. + * Since Chucker only captures OkHttp traffic, we "replay" these requests through an OkHttp + * client with Chucker attached, allowing all network activity to appear in a unified log. + * + * Note: This class is provided via [org.wordpress.android.modules.TrackNetworkRequestsModule]. + */ +class GutenbergKitNetworkLogger( + private val trackNetworkRequestsInterceptor: TrackNetworkRequestsInterceptor +) { + private val client: OkHttpClient by lazy { + OkHttpClient.Builder() + .addInterceptor(trackNetworkRequestsInterceptor) + .addInterceptor(ReplayInterceptor()) + .build() + } + + /** + * Logs a GutenbergKit network request to Chucker. + * Call this from [org.wordpress.gutenberg.GutenbergView.NetworkRequestListener.onNetworkRequest]. + */ + fun log(networkRequest: RecordedNetworkRequest) { + val contentType = networkRequest.requestHeaders["content-type"]?.toMediaTypeOrNull() + // OkHttp doesn't allow request bodies for GET/HEAD methods + val requestBody = if (networkRequest.method.uppercase() in listOf("GET", "HEAD")) { + null + } else { + networkRequest.requestBody?.toRequestBody(contentType) + } + + val request = Request.Builder() + .url(networkRequest.url) + .method(networkRequest.method, requestBody) + .headers(networkRequest.requestHeaders.toHeaders()) + .tag(RecordedNetworkRequest::class.java, networkRequest) + .build() + + @Suppress("TooGenericExceptionCaught") + try { + client.newCall(request).execute().close() + } catch (e: Exception) { + // Catch all exceptions since various things can fail (IOException, IllegalStateException, etc.) + // and we don't want logging failures to crash the app + AppLog.e(AppLog.T.EDITOR, "Failed to log GutenbergKit network request", e) + } + } +} + +/** + * Interceptor that returns a pre-recorded response from the request's tag. + * Used to "replay" GutenbergKit requests through OkHttp without making actual network calls. + */ +private class ReplayInterceptor : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val networkRequest = chain.request().tag(RecordedNetworkRequest::class.java) + ?: error("ReplayInterceptor requires RecordedNetworkRequest tag") + + val contentType = networkRequest.responseHeaders["content-type"]?.toMediaTypeOrNull() + + val responseBody = networkRequest.responseBody?.toResponseBody(contentType) + ?: "".toResponseBody(contentType) + + return Response.Builder() + .request(chain.request()) + .protocol(Protocol.HTTP_1_1) + .code(networkRequest.status) + .message(networkRequest.statusText) + .headers(networkRequest.responseHeaders.toHeaders()) + .body(responseBody) + .build() + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefs.java b/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefs.java index 61309d20204f..cfeea8f103b3 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefs.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefs.java @@ -313,6 +313,12 @@ public enum UndeletablePrefKey implements PrefKey { // Indicates if the user is eligible for the Jetpack migration flow IS_JETPACK_MIGRATION_ELIGIBLE, + + // Track Network Requests (Chucker) preferences - stored as device-level preferences + // (not user-specific) to allow troubleshooting network issues during login flows. + // These preferences persist across logout/login cycles. + IS_TRACK_NETWORK_REQUESTS_ENABLED, + TRACK_NETWORK_REQUESTS_RETENTION_PERIOD, } static SharedPreferences prefs() { @@ -1837,4 +1843,30 @@ public static void setReaderReadingPreferencesJson(@Nullable String json) { setString(DeletablePrefKey.READER_READING_PREFERENCES_JSON, json); } } + + /** + * Returns whether network request tracking (Chucker) is enabled. + * This is a device-level preference that persists across logout/login cycles + * to support troubleshooting network issues during login flows. + */ + public static boolean isTrackNetworkRequestsEnabled() { + return getBoolean(UndeletablePrefKey.IS_TRACK_NETWORK_REQUESTS_ENABLED, false); + } + + public static void setTrackNetworkRequestsEnabled(boolean enabled) { + setBoolean(UndeletablePrefKey.IS_TRACK_NETWORK_REQUESTS_ENABLED, enabled); + } + + /** + * Returns the retention period for network request tracking. + * This is a device-level preference that persists across logout/login cycles. + * @see org.wordpress.android.fluxc.network.NetworkRequestsRetentionPeriod + */ + public static int getTrackNetworkRequestsRetentionPeriod() { + return getInt(UndeletablePrefKey.TRACK_NETWORK_REQUESTS_RETENTION_PERIOD, 0); + } + + public static void setTrackNetworkRequestsRetentionPeriod(int period) { + setInt(UndeletablePrefKey.TRACK_NETWORK_REQUESTS_RETENTION_PERIOD, period); + } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefsWrapper.kt b/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefsWrapper.kt index 5e85da27e5ed..c4a040b34b70 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefsWrapper.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefsWrapper.kt @@ -513,6 +513,14 @@ class AppPrefsWrapper @Inject constructor(val buildConfigWrapper: BuildConfigWra get() = AppPrefs.getSupportEmail() set(value) = AppPrefs.setSupportEmail(value) + var isTrackNetworkRequestsEnabled: Boolean + get() = AppPrefs.isTrackNetworkRequestsEnabled() + set(value) = AppPrefs.setTrackNetworkRequestsEnabled(value) + + var trackNetworkRequestsRetentionPeriod: Int + get() = AppPrefs.getTrackNetworkRequestsRetentionPeriod() + set(value) = AppPrefs.setTrackNetworkRequestsRetentionPeriod(value) + companion object { private const val LIGHT_MODE_ID = 0 private const val DARK_MODE_ID = 1 diff --git a/WordPress/src/main/java/org/wordpress/android/ui/prefs/experimentalfeatures/ExperimentalFeatures.kt b/WordPress/src/main/java/org/wordpress/android/ui/prefs/experimentalfeatures/ExperimentalFeatures.kt index 5728476b6ddf..78cfc432e4a8 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/prefs/experimentalfeatures/ExperimentalFeatures.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/prefs/experimentalfeatures/ExperimentalFeatures.kt @@ -39,6 +39,11 @@ class ExperimentalFeatures @Inject constructor( "modern_support", R.string.modern_support, R.string.modern_support_description + ), + NETWORK_DEBUGGING( + "network_debugging", + R.string.experimental_network_debugging, + R.string.experimental_network_debugging_description ); } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/prefs/experimentalfeatures/ExperimentalFeaturesActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/prefs/experimentalfeatures/ExperimentalFeaturesActivity.kt index 2d1f1ebb1020..dc3939b9b351 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/prefs/experimentalfeatures/ExperimentalFeaturesActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/prefs/experimentalfeatures/ExperimentalFeaturesActivity.kt @@ -18,6 +18,7 @@ import org.wordpress.android.ui.accounts.HelpActivity class ExperimentalFeaturesActivity : BaseAppCompatActivity() { private val viewModel: ExperimentalFeaturesViewModel by viewModels() + @Suppress("LongMethod") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -26,8 +27,16 @@ class ExperimentalFeaturesActivity : BaseAppCompatActivity() { val features by viewModel.switchStates.collectAsStateWithLifecycle() val applicationPasswordDialogState by viewModel.applicationPasswordDialogState.collectAsStateWithLifecycle() + val showNetworkDebuggingError by + viewModel.showNetworkDebuggingError.collectAsStateWithLifecycle() val showDialog = remember { mutableStateOf(false) } + if (showNetworkDebuggingError) { + NetworkDebuggingErrorDialog( + onDismiss = { viewModel.dismissNetworkDebuggingError() } + ) + } + when (applicationPasswordDialogState) { is ExperimentalFeaturesViewModel.ApplicationPasswordDialogState.Disable -> { ApplicationPasswordOffConfirmationDialog( diff --git a/WordPress/src/main/java/org/wordpress/android/ui/prefs/experimentalfeatures/ExperimentalFeaturesScreen.kt b/WordPress/src/main/java/org/wordpress/android/ui/prefs/experimentalfeatures/ExperimentalFeaturesScreen.kt index d4543e9c13ee..cca0b8e886bf 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/prefs/experimentalfeatures/ExperimentalFeaturesScreen.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/prefs/experimentalfeatures/ExperimentalFeaturesScreen.kt @@ -302,6 +302,39 @@ fun ApplicationPasswordInfoDialog( ) } +@Composable +fun NetworkDebuggingErrorDialog( + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + icon = { + Icon( + imageVector = Icons.Outlined.Warning, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(Margin.ExtraLarge.value) + ) + }, + title = { + Text( + text = stringResource(R.string.experimental_network_debugging_disable_error_title), + textAlign = TextAlign.Center + ) + }, + text = { + Text( + text = stringResource(R.string.experimental_network_debugging_disable_error_message) + ) + }, + confirmButton = { + Button(onClick = onDismiss) { + Text(text = stringResource(R.string.ok)) + } + } + ) +} + @Preview @Preview(uiMode = UI_MODE_NIGHT_YES) @Composable diff --git a/WordPress/src/main/java/org/wordpress/android/ui/prefs/experimentalfeatures/ExperimentalFeaturesViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/prefs/experimentalfeatures/ExperimentalFeaturesViewModel.kt index 5ed5b153e56b..b32d05747bec 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/prefs/experimentalfeatures/ExperimentalFeaturesViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/prefs/experimentalfeatures/ExperimentalFeaturesViewModel.kt @@ -12,10 +12,11 @@ import org.wordpress.android.analytics.AnalyticsTracker import org.wordpress.android.analytics.AnalyticsTracker.Stat import org.wordpress.android.fluxc.utils.AppLogWrapper import org.wordpress.android.ui.accounts.login.ApplicationPasswordLoginHelper -import org.wordpress.android.util.config.GutenbergKitFeature -import javax.inject.Inject +import org.wordpress.android.ui.prefs.AppPrefsWrapper import org.wordpress.android.ui.prefs.experimentalfeatures.ExperimentalFeatures.Feature import org.wordpress.android.util.AppLog +import org.wordpress.android.util.config.GutenbergKitFeature +import javax.inject.Inject private const val AFFECTED_SITES = "affected_sites" @@ -25,6 +26,7 @@ internal class ExperimentalFeaturesViewModel @Inject constructor( private val gutenbergKitFeature: GutenbergKitFeature, private val applicationPasswordLoginHelper: ApplicationPasswordLoginHelper, private val appLogWrapper: AppLogWrapper, + private val appPrefsWrapper: AppPrefsWrapper, ) : ViewModel() { private val _switchStates = MutableStateFlow>(emptyMap()) val switchStates: StateFlow> = _switchStates.asStateFlow() @@ -34,6 +36,9 @@ internal class ExperimentalFeaturesViewModel @Inject constructor( val applicationPasswordDialogState: StateFlow = _applicationPasswordDialogState.asStateFlow() + private val _showNetworkDebuggingError = MutableStateFlow(false) + val showNetworkDebuggingError: StateFlow = _showNetworkDebuggingError.asStateFlow() + init { val initialStates = Feature.entries .filter { feature -> @@ -53,25 +58,40 @@ internal class ExperimentalFeaturesViewModel @Inject constructor( } fun onFeatureToggled(feature: Feature, enabled: Boolean) { - // Since FluxC has not way to access the experimental features, this is a workaround to remove the - // Application Password credentials when the feature is disabled to avoid FluxC to use them. - // See the logic in [SiteModelExtensions.kt] and how it can not access to the feature flag - if (feature == Feature.EXPERIMENTAL_APPLICATION_PASSWORD_FEATURE) { - if (enabled) { - _applicationPasswordDialogState.value = ApplicationPasswordDialogState.Info - } else { - val affectedSites = applicationPasswordLoginHelper.getApplicationPasswordSitesCount() - if (affectedSites > 0) { - _applicationPasswordDialogState.value = ApplicationPasswordDialogState.Disable(affectedSites) + when (feature) { + // Since FluxC has no way to access the experimental features, this is a workaround to + // remove the Application Password credentials when the feature is disabled to avoid + // FluxC to use them. + // See the logic in [SiteModelExtensions.kt] and how it can not access to the feature flag + Feature.EXPERIMENTAL_APPLICATION_PASSWORD_FEATURE -> { + if (enabled) { + _applicationPasswordDialogState.value = ApplicationPasswordDialogState.Info } else { - confirmDisableApplicationPassword() + val affectedSites = applicationPasswordLoginHelper.getApplicationPasswordSitesCount() + if (affectedSites > 0) { + _applicationPasswordDialogState.value = + ApplicationPasswordDialogState.Disable(affectedSites) + } else { + confirmDisableApplicationPassword() + } } } - } else { - setFeatureSwitchState(feature, enabled) + // Prevent disabling the feature if network tracking is currently enabled + Feature.NETWORK_DEBUGGING -> { + if (!enabled && appPrefsWrapper.isTrackNetworkRequestsEnabled) { + _showNetworkDebuggingError.value = true + } else { + setFeatureSwitchState(feature, enabled) + } + } + else -> setFeatureSwitchState(feature, enabled) } } + fun dismissNetworkDebuggingError() { + _showNetworkDebuggingError.value = false + } + fun dismissDisableApplicationPassword() { _applicationPasswordDialogState.value = ApplicationPasswordDialogState.None } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/subscribers/AddSubscribersViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/subscribers/AddSubscribersViewModel.kt index 3a7108194d41..86abc76eb27c 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/subscribers/AddSubscribersViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/subscribers/AddSubscribersViewModel.kt @@ -6,6 +6,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.withContext import org.wordpress.android.R +import org.wordpress.android.fluxc.network.TrackNetworkRequestsInterceptor import org.wordpress.android.fluxc.store.AccountStore import org.wordpress.android.fluxc.utils.AppLogWrapper import org.wordpress.android.modules.BG_THREAD @@ -29,6 +30,7 @@ class AddSubscribersViewModel @Inject constructor( @Named(BG_THREAD) private val bgDispatcher: CoroutineDispatcher, private val appLogWrapper: AppLogWrapper, private val toastUtilsWrapper: ToastUtilsWrapper, + private val trackNetworkRequestsInterceptor: TrackNetworkRequestsInterceptor, ) : ScopedViewModel(bgDispatcher) { @Inject @Named(IO_THREAD) @@ -48,7 +50,7 @@ class AddSubscribersViewModel @Inject constructor( WpAuthenticationProvider.staticWithAuth( WpAuthentication.Bearer(token = accountStore.accessToken!!) ), - interceptors = emptyList() + interceptors = listOf(trackNetworkRequestsInterceptor) ) } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/subscribers/SubscribersViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/subscribers/SubscribersViewModel.kt index e5c7243ede3f..babf8906c9f9 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/subscribers/SubscribersViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/subscribers/SubscribersViewModel.kt @@ -8,6 +8,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.withContext import org.wordpress.android.R +import org.wordpress.android.fluxc.network.TrackNetworkRequestsInterceptor import org.wordpress.android.fluxc.store.AccountStore import org.wordpress.android.fluxc.utils.AppLogWrapper import org.wordpress.android.models.wrappers.SimpleDateFormatWrapper @@ -43,6 +44,7 @@ class SubscribersViewModel @Inject constructor( selectedSiteRepository: SelectedSiteRepository, accountStore: AccountStore, @Named(IO_THREAD) ioDispatcher: CoroutineDispatcher, + trackNetworkRequestsInterceptor: TrackNetworkRequestsInterceptor, ) : DataViewViewModel( mainDispatcher = mainDispatcher, appLogWrapper = appLogWrapper, @@ -50,7 +52,8 @@ class SubscribersViewModel @Inject constructor( networkUtilsWrapper = networkUtilsWrapper, selectedSiteRepository = selectedSiteRepository, accountStore = accountStore, - ioDispatcher = ioDispatcher + ioDispatcher = ioDispatcher, + trackNetworkRequestsInterceptor = trackNetworkRequestsInterceptor ) { private val _subscriberStats = MutableStateFlow(null) val subscriberStats = _subscriberStats.asStateFlow() diff --git a/WordPress/src/main/java/org/wordpress/android/ui/taxonomies/TermsViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/taxonomies/TermsViewModel.kt index 53d6794dc3d4..2cb764259d43 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/taxonomies/TermsViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/taxonomies/TermsViewModel.kt @@ -19,6 +19,7 @@ import org.wordpress.android.fluxc.generated.TaxonomyActionBuilder import org.wordpress.android.fluxc.model.SiteModel import org.wordpress.android.fluxc.model.TermModel import org.wordpress.android.fluxc.model.TermsModel +import org.wordpress.android.fluxc.network.TrackNetworkRequestsInterceptor import org.wordpress.android.fluxc.network.rest.wpapi.rs.WpApiClientProvider import org.wordpress.android.fluxc.store.AccountStore import org.wordpress.android.fluxc.store.TaxonomyStore @@ -87,6 +88,7 @@ class TermsViewModel @Inject constructor( sharedPrefs: SharedPreferences, networkUtilsWrapper: NetworkUtilsWrapper, @Named(IO_THREAD) ioDispatcher: CoroutineDispatcher, + trackNetworkRequestsInterceptor: TrackNetworkRequestsInterceptor, ) : DataViewViewModel( mainDispatcher = mainDispatcher, appLogWrapper = appLogWrapper, @@ -94,7 +96,8 @@ class TermsViewModel @Inject constructor( networkUtilsWrapper = networkUtilsWrapper, selectedSiteRepository = selectedSiteRepository, accountStore = accountStore, - ioDispatcher = ioDispatcher + ioDispatcher = ioDispatcher, + trackNetworkRequestsInterceptor = trackNetworkRequestsInterceptor ) { private var taxonomySlug: String = "" private var isHierarchical: Boolean = false diff --git a/WordPress/src/main/res/layout/dialog_title_with_message.xml b/WordPress/src/main/res/layout/dialog_title_with_message.xml new file mode 100644 index 000000000000..aab01a6a17af --- /dev/null +++ b/WordPress/src/main/res/layout/dialog_title_with_message.xml @@ -0,0 +1,25 @@ + + + + + + + + diff --git a/WordPress/src/main/res/layout/help_activity.xml b/WordPress/src/main/res/layout/help_activity.xml index 76cb796b1b2e..7469bef0e6ff 100644 --- a/WordPress/src/main/res/layout/help_activity.xml +++ b/WordPress/src/main/res/layout/help_activity.xml @@ -198,6 +198,78 @@ android:layout_height="@dimen/divider_size" android:background="?android:attr/listDivider" /> + + + + + + + + + + + + + + + + + + + + + Enable Application Password to log into self-hosted sites Modern Support Enable the new modern support experience + Network Debugging + Show network request tracking tools in the Help screen + Disable Tracking First + Network tracking is currently enabled. Please go to Help and disable \"Track Network Requests\" before turning off this feature. + Track Network Requests + Record HTTP requests for troubleshooting. Logs stay on device unless you share them. + View Network Requests + Retention Period + 1 Hour + 1 Day + 1 Week + Until Cleared + Retention: %s + Logs are stored only on your device and never shared unless you explicitly share them. You can clear logs anytime via \"View Network Requests\" using the trash icon. Select how long to retain logs. + Enable + Disable Tracking? + Existing logs will expire based on the retention period. To clear logs now, tap \"View Network Requests\" and use the trash icon before disabling. + Disable Debug Settings diff --git a/WordPress/src/test/java/org/wordpress/android/support/main/ui/SupportViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/support/main/ui/SupportViewModelTest.kt index f6561536bf77..7acdf80576f1 100644 --- a/WordPress/src/test/java/org/wordpress/android/support/main/ui/SupportViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/support/main/ui/SupportViewModelTest.kt @@ -11,8 +11,11 @@ import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.wordpress.android.BaseUnitTest import org.wordpress.android.fluxc.model.AccountModel +import org.wordpress.android.fluxc.network.NetworkRequestsRetentionPeriod import org.wordpress.android.fluxc.store.AccountStore import org.wordpress.android.fluxc.utils.AppLogWrapper +import org.wordpress.android.ui.prefs.AppPrefsWrapper +import org.wordpress.android.ui.prefs.experimentalfeatures.ExperimentalFeatures import org.wordpress.android.util.AppLog @ExperimentalCoroutinesApi @@ -23,6 +26,12 @@ class SupportViewModelTest : BaseUnitTest() { @Mock lateinit var appLogWrapper: AppLogWrapper + @Mock + lateinit var appPrefsWrapper: AppPrefsWrapper + + @Mock + lateinit var experimentalFeatures: ExperimentalFeatures + @Mock lateinit var account: AccountModel @@ -32,7 +41,9 @@ class SupportViewModelTest : BaseUnitTest() { fun setUp() { viewModel = SupportViewModel( accountStore = accountStore, - appLogWrapper = appLogWrapper + appLogWrapper = appLogWrapper, + appPrefsWrapper = appPrefsWrapper, + experimentalFeatures = experimentalFeatures ) } @@ -169,6 +180,70 @@ class SupportViewModelTest : BaseUnitTest() { assertThat(viewModel.optionsVisibility.value.showAskHappinessEngineers).isFalse() } + @Test + fun `init shows network debugging when feature flag is enabled`() { + // Given + whenever(accountStore.hasAccessToken()).thenReturn(false) + whenever(accountStore.account).thenReturn(account) + whenever(account.displayName).thenReturn("") + whenever(account.userName).thenReturn("") + whenever(account.email).thenReturn("") + whenever(account.avatarUrl).thenReturn("") + whenever(experimentalFeatures.isEnabled(ExperimentalFeatures.Feature.NETWORK_DEBUGGING)) + .thenReturn(true) + whenever(appPrefsWrapper.isTrackNetworkRequestsEnabled).thenReturn(false) + whenever(appPrefsWrapper.trackNetworkRequestsRetentionPeriod).thenReturn(0) + + // When + viewModel.init() + + // Then + assertThat(viewModel.networkTrackingState.value.showNetworkDebugging).isTrue() + } + + @Test + fun `init hides network debugging when feature flag is disabled`() { + // Given + whenever(accountStore.hasAccessToken()).thenReturn(false) + whenever(accountStore.account).thenReturn(account) + whenever(account.displayName).thenReturn("") + whenever(account.userName).thenReturn("") + whenever(account.email).thenReturn("") + whenever(account.avatarUrl).thenReturn("") + whenever(experimentalFeatures.isEnabled(ExperimentalFeatures.Feature.NETWORK_DEBUGGING)) + .thenReturn(false) + + // When + viewModel.init() + + // Then + assertThat(viewModel.networkTrackingState.value.showNetworkDebugging).isFalse() + } + + @Test + fun `init loads tracking enabled state from preferences`() { + // Given + whenever(accountStore.hasAccessToken()).thenReturn(false) + whenever(accountStore.account).thenReturn(account) + whenever(account.displayName).thenReturn("") + whenever(account.userName).thenReturn("") + whenever(account.email).thenReturn("") + whenever(account.avatarUrl).thenReturn("") + whenever(experimentalFeatures.isEnabled(ExperimentalFeatures.Feature.NETWORK_DEBUGGING)) + .thenReturn(true) + whenever(appPrefsWrapper.isTrackNetworkRequestsEnabled).thenReturn(true) + whenever(appPrefsWrapper.trackNetworkRequestsRetentionPeriod) + .thenReturn(NetworkRequestsRetentionPeriod.ONE_WEEK.value) + + // When + viewModel.init() + + // Then + assertThat(viewModel.networkTrackingState.value.isTrackingEnabled).isTrue() + assertThat(viewModel.networkTrackingState.value.retentionPeriod) + .isEqualTo(NetworkRequestsRetentionPeriod.ONE_WEEK) + } + // endregion // region onAskTheBotsClick() tests @@ -305,4 +380,109 @@ class SupportViewModelTest : BaseUnitTest() { } // endregion + + // region Network tracking dialog tests + + @Test + fun `onNetworkTrackingToggle shows enable dialog when toggled on`() { + // Given + whenever(appPrefsWrapper.trackNetworkRequestsRetentionPeriod) + .thenReturn(NetworkRequestsRetentionPeriod.ONE_DAY.value) + + // When + viewModel.onNetworkTrackingToggle(true) + + // Then + val dialogState = viewModel.dialogState.value + assertThat(dialogState).isInstanceOf(SupportViewModel.DialogState.EnableTracking::class.java) + assertThat((dialogState as SupportViewModel.DialogState.EnableTracking).selectedPeriod) + .isEqualTo(NetworkRequestsRetentionPeriod.ONE_DAY) + } + + @Test + fun `onNetworkTrackingToggle shows disable dialog when toggled off`() { + // When + viewModel.onNetworkTrackingToggle(false) + + // Then + assertThat(viewModel.dialogState.value) + .isEqualTo(SupportViewModel.DialogState.DisableTracking) + } + + @Test + fun `onEnableTrackingConfirmed updates state and hides dialog`() { + // Given + viewModel.onNetworkTrackingToggle(true) // Show dialog first + + // When + viewModel.onEnableTrackingConfirmed(NetworkRequestsRetentionPeriod.ONE_WEEK) + + // Then + assertThat(viewModel.networkTrackingState.value.isTrackingEnabled).isTrue() + assertThat(viewModel.networkTrackingState.value.retentionPeriod) + .isEqualTo(NetworkRequestsRetentionPeriod.ONE_WEEK) + assertThat(viewModel.dialogState.value).isEqualTo(SupportViewModel.DialogState.Hidden) + verify(appPrefsWrapper).isTrackNetworkRequestsEnabled = true + verify(appPrefsWrapper).trackNetworkRequestsRetentionPeriod = + NetworkRequestsRetentionPeriod.ONE_WEEK.value + } + + @Test + fun `onDisableTrackingConfirmed updates state and hides dialog`() { + // Given + viewModel.onNetworkTrackingToggle(false) // Show dialog first + + // When + viewModel.onDisableTrackingConfirmed() + + // Then + assertThat(viewModel.networkTrackingState.value.isTrackingEnabled).isFalse() + assertThat(viewModel.dialogState.value).isEqualTo(SupportViewModel.DialogState.Hidden) + verify(appPrefsWrapper).isTrackNetworkRequestsEnabled = false + } + + @Test + fun `onDialogDismissed hides dialog without changing tracking state`() { + // Given + viewModel.onNetworkTrackingToggle(true) // Show dialog first + val initialTrackingState = viewModel.networkTrackingState.value + + // When + viewModel.onDialogDismissed() + + // Then + assertThat(viewModel.dialogState.value).isEqualTo(SupportViewModel.DialogState.Hidden) + assertThat(viewModel.networkTrackingState.value).isEqualTo(initialTrackingState) + } + + @Test + fun `onRetentionPeriodSelected updates selected period in dialog`() { + // Given + whenever(appPrefsWrapper.trackNetworkRequestsRetentionPeriod) + .thenReturn(NetworkRequestsRetentionPeriod.ONE_HOUR.value) + viewModel.onNetworkTrackingToggle(true) // Show dialog with ONE_HOUR + + // When + viewModel.onRetentionPeriodSelected(NetworkRequestsRetentionPeriod.FOREVER) + + // Then + val dialogState = viewModel.dialogState.value + assertThat(dialogState).isInstanceOf(SupportViewModel.DialogState.EnableTracking::class.java) + assertThat((dialogState as SupportViewModel.DialogState.EnableTracking).selectedPeriod) + .isEqualTo(NetworkRequestsRetentionPeriod.FOREVER) + } + + @Test + fun `onRetentionPeriodSelected does nothing when dialog is not EnableTracking`() { + // Given - dialog is Hidden + assertThat(viewModel.dialogState.value).isEqualTo(SupportViewModel.DialogState.Hidden) + + // When + viewModel.onRetentionPeriodSelected(NetworkRequestsRetentionPeriod.FOREVER) + + // Then - dialog is still Hidden + assertThat(viewModel.dialogState.value).isEqualTo(SupportViewModel.DialogState.Hidden) + } + + // endregion } diff --git a/WordPress/src/test/java/org/wordpress/android/ui/dataview/DataViewViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/dataview/DataViewViewModelTest.kt index 7170e53827d9..96f0cde381d2 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/dataview/DataViewViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/dataview/DataViewViewModelTest.kt @@ -13,6 +13,7 @@ import org.mockito.kotlin.whenever import org.wordpress.android.BaseUnitTest import org.wordpress.android.R import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.TrackNetworkRequestsInterceptor import org.wordpress.android.fluxc.store.AccountStore import org.wordpress.android.fluxc.utils.AppLogWrapper import org.wordpress.android.ui.dataview.DataViewViewModel.Companion.PAGE_SIZE @@ -40,6 +41,9 @@ class DataViewViewModelTest : BaseUnitTest() { @Mock private lateinit var accountStore: AccountStore + @Mock + private lateinit var trackNetworkRequestsInterceptor: TrackNetworkRequestsInterceptor + private val testSite = SiteModel().apply { id = 1 siteId = TEST_SITE_SITE_ID @@ -68,7 +72,8 @@ class DataViewViewModelTest : BaseUnitTest() { networkUtilsWrapper = networkUtilsWrapper, selectedSiteRepository = selectedSiteRepository, accountStore = accountStore, - ioDispatcher = testDispatcher() + ioDispatcher = testDispatcher(), + trackNetworkRequestsInterceptor = trackNetworkRequestsInterceptor ) } @@ -226,7 +231,8 @@ class DataViewViewModelTest : BaseUnitTest() { networkUtilsWrapper = networkUtilsWrapper, selectedSiteRepository = selectedSiteRepository, accountStore = accountStore, - ioDispatcher = testDispatcher() + ioDispatcher = testDispatcher(), + trackNetworkRequestsInterceptor = trackNetworkRequestsInterceptor ) // Access the wpComApiClient property to trigger the lazy initialization viewModel.testAccessWpComApiClient() @@ -493,7 +499,8 @@ class DataViewViewModelTest : BaseUnitTest() { networkUtilsWrapper: NetworkUtilsWrapper, selectedSiteRepository: SelectedSiteRepository, accountStore: AccountStore, - ioDispatcher: kotlinx.coroutines.CoroutineDispatcher + ioDispatcher: kotlinx.coroutines.CoroutineDispatcher, + trackNetworkRequestsInterceptor: TrackNetworkRequestsInterceptor ) : DataViewViewModel( mainDispatcher, appLogWrapper, @@ -501,7 +508,8 @@ class DataViewViewModelTest : BaseUnitTest() { networkUtilsWrapper, selectedSiteRepository, accountStore, - ioDispatcher + ioDispatcher, + trackNetworkRequestsInterceptor ) { init { initialize() diff --git a/WordPress/src/test/java/org/wordpress/android/ui/prefs/experimentalfeatures/ExperimentalFeaturesViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/prefs/experimentalfeatures/ExperimentalFeaturesViewModelTest.kt index e07435afedd9..bb9d8e7442fd 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/prefs/experimentalfeatures/ExperimentalFeaturesViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/prefs/experimentalfeatures/ExperimentalFeaturesViewModelTest.kt @@ -15,6 +15,7 @@ import org.wordpress.android.BaseUnitTest import org.wordpress.android.BuildConfig import org.wordpress.android.fluxc.utils.AppLogWrapper import org.wordpress.android.ui.accounts.login.ApplicationPasswordLoginHelper +import org.wordpress.android.ui.prefs.AppPrefsWrapper import org.wordpress.android.ui.prefs.experimentalfeatures.ExperimentalFeatures.Feature import org.wordpress.android.ui.prefs.experimentalfeatures.ExperimentalFeaturesViewModel.ApplicationPasswordDialogState import org.wordpress.android.util.AppLog @@ -34,6 +35,9 @@ class ExperimentalFeaturesViewModelTest : BaseUnitTest() { @Mock private lateinit var appLogWrapper: AppLogWrapper + @Mock + private lateinit var appPrefsWrapper: AppPrefsWrapper + private lateinit var viewModel: ExperimentalFeaturesViewModel @Before @@ -225,7 +229,8 @@ class ExperimentalFeaturesViewModelTest : BaseUnitTest() { experimentalFeatures = experimentalFeatures, gutenbergKitFeature = gutenbergKitFeature, applicationPasswordLoginHelper = applicationPasswordLoginHelper, - appLogWrapper = appLogWrapper + appLogWrapper = appLogWrapper, + appPrefsWrapper = appPrefsWrapper ) } } diff --git a/WordPress/src/test/java/org/wordpress/android/ui/taxonomies/TermsViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/taxonomies/TermsViewModelTest.kt index 45242e2ade1a..337957fe08d6 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/taxonomies/TermsViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/taxonomies/TermsViewModelTest.kt @@ -17,6 +17,7 @@ import org.wordpress.android.BaseUnitTest import org.wordpress.android.R import org.wordpress.android.fluxc.Dispatcher import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.network.TrackNetworkRequestsInterceptor import org.wordpress.android.fluxc.network.rest.wpapi.rs.WpApiClientProvider import org.wordpress.android.fluxc.store.AccountStore import org.wordpress.android.fluxc.store.TaxonomyStore @@ -55,6 +56,9 @@ class TermsViewModelTest : BaseUnitTest() { @Mock private lateinit var fluxCDispatcher: Dispatcher + @Mock + private lateinit var trackNetworkRequestsInterceptor: TrackNetworkRequestsInterceptor + @Before fun setUp() { MockitoAnnotations.openMocks(this) @@ -72,7 +76,8 @@ class TermsViewModelTest : BaseUnitTest() { networkUtilsWrapper = networkUtilsWrapper, ioDispatcher = testDispatcher(), taxonomyStore = taxonomyStore, - fluxCDispatcher = fluxCDispatcher + fluxCDispatcher = fluxCDispatcher, + trackNetworkRequestsInterceptor = trackNetworkRequestsInterceptor ) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b785ecb466ef..f0a88c0acff0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -48,6 +48,7 @@ automattic-tracks = '6.0.6' bumptech-glide = '5.0.5' cascade = '2.3.0' checkstyle = '10.17.0' +chucker = '4.2.0' coil = '2.7.0' commons-fileupload = '1.5' detekt = '1.23.8' @@ -73,7 +74,7 @@ google-play-services-auth = '20.4.1' google-services = '4.4.4' gravatar = '2.5.0' greenrobot-eventbus = '3.3.1' -gutenberg-kit = 'v0.10.2' +gutenberg-kit = 'v0.11.1' gutenberg-mobile = 'v1.121.0' indexos-media-for-mobile = '43a9026f0973a2f0a74fa813132f6a16f7499c3a' jackson-databind = '2.12.7.1' @@ -195,6 +196,7 @@ bumptech-glide-ksp = { group = "com.github.bumptech.glide", name = "ksp", versio bumptech-glide-main = { group = "com.github.bumptech.glide", name = "glide", version.ref = "bumptech-glide" } bumptech-glide-volley-integration = { group = "com.github.bumptech.glide", name = "volley-integration", version.ref = "bumptech-glide" } cascade-compose = { group = "me.saket.cascade", name = "cascade-compose", version.ref = "cascade" } +chucker = { group = "com.github.chuckerteam.chucker", name = "library", version.ref = "chucker" } coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" } coil-video = { group = "io.coil-kt", name = "coil-video", version.ref = "coil" } commons-fileupload = { group = "commons-fileupload", name = "commons-fileupload", version.ref = "commons-fileupload" } diff --git a/libs/fluxc/build.gradle b/libs/fluxc/build.gradle index 4741873d4cd2..bf279d3f5cea 100644 --- a/libs/fluxc/build.gradle +++ b/libs/fluxc/build.gradle @@ -93,6 +93,7 @@ dependencies { implementation libs.squareup.okhttp3.urlconnection api libs.android.volley implementation libs.google.gson + implementation libs.chucker implementation libs.apache.commons.text api libs.androidx.paging.runtime diff --git a/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/TrackNetworkRequestsInterceptor.kt b/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/TrackNetworkRequestsInterceptor.kt new file mode 100644 index 000000000000..7f46b993f8f4 --- /dev/null +++ b/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/TrackNetworkRequestsInterceptor.kt @@ -0,0 +1,120 @@ +package org.wordpress.android.fluxc.network + +import android.content.Context +import com.chuckerteam.chucker.api.ChuckerCollector +import com.chuckerteam.chucker.api.ChuckerInterceptor +import com.chuckerteam.chucker.api.RetentionManager +import okhttp3.Interceptor +import okhttp3.Response + +/** + * Retention period options for tracked network requests. + * + * IMPORTANT: Do not modify existing int values as they are persisted to SharedPreferences. + * To add new options, append them with new int values. If existing values must change, + * implement a preference migration. + */ +enum class NetworkRequestsRetentionPeriod(val value: Int) { + ONE_HOUR(0), + ONE_DAY(1), + ONE_WEEK(2), + FOREVER(3); + + fun toChuckerPeriod(): RetentionManager.Period = when (this) { + ONE_HOUR -> RetentionManager.Period.ONE_HOUR + ONE_DAY -> RetentionManager.Period.ONE_DAY + ONE_WEEK -> RetentionManager.Period.ONE_WEEK + FOREVER -> RetentionManager.Period.FOREVER + } + + companion object { + fun fromInt(value: Int): NetworkRequestsRetentionPeriod = + entries.find { it.value == value } ?: ONE_HOUR + } +} + +/** + * Interface to check tracking preferences. + * This is implemented in the app module to access preferences. + */ +interface TrackNetworkRequestsPreference { + fun isEnabled(): Boolean + fun getRetentionPeriod(): NetworkRequestsRetentionPeriod +} + +/** + * OkHttp interceptor that tracks network requests when enabled for troubleshooting purposes. + * + * This interceptor wraps Chucker's ChuckerInterceptor and only delegates to it when the + * feature is enabled via [TrackNetworkRequestsPreference]. When disabled, requests pass + * through without any logging or inspection. + * + * @param context Application context for Chucker initialization + * @param preference Provides the enabled/disabled state and retention period from app preferences + */ +class TrackNetworkRequestsInterceptor( + private val context: Context, + private val preference: TrackNetworkRequestsPreference +) : Interceptor { + @Volatile + private var chuckerInterceptor: ChuckerInterceptor? = null + + @Volatile + private var currentRetentionPeriod: NetworkRequestsRetentionPeriod? = null + + override fun intercept(chain: Interceptor.Chain): Response { + // Note: Reading the preference on every request is acceptable because SharedPreferences + // caches values in memory after initial load. The only costs are: + // 1. Initial disk read (happens once at app start, regardless of our usage) + // 2. Memory sync after apply()/commit() (rare, only when user toggles the setting) + // 3. HashMap lookup (negligible) + // See: https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-14.0.0_r1/core/java/android/app/SharedPreferencesImpl.java#345 + return if (preference.isEnabled()) { + getOrCreateChuckerInterceptor().intercept(chain) + } else { + chain.proceed(chain.request()) + } + } + + private fun getOrCreateChuckerInterceptor(): ChuckerInterceptor { + val desiredRetention = preference.getRetentionPeriod() + val currentInterceptor = chuckerInterceptor + + // Recreate interceptor if retention period changed + if (currentInterceptor == null || currentRetentionPeriod != desiredRetention) { + synchronized(this) { + // Double-check after acquiring lock + if (chuckerInterceptor == null || currentRetentionPeriod != desiredRetention) { + chuckerInterceptor = createChuckerInterceptor(desiredRetention) + currentRetentionPeriod = desiredRetention + } + } + } + return chuckerInterceptor!! + } + + private fun createChuckerInterceptor(retention: NetworkRequestsRetentionPeriod): ChuckerInterceptor { + val collector = ChuckerCollector( + context = context, + showNotification = false, + retentionPeriod = retention.toChuckerPeriod() + ) + return ChuckerInterceptor.Builder(context) + .collector(collector) + .maxContentLength(MAX_CONTENT_LENGTH) + .redactHeaders(SENSITIVE_HEADERS) + .alwaysReadResponseBody(false) + .build() + } + + companion object { + private val SENSITIVE_HEADERS = setOf( + "Authorization", + "Cookie", + "Set-Cookie", + "X-WP-Nonce" + ) + + private const val MAX_CONTENT_LENGTH = 250_000L + } +} diff --git a/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/WPcomLoginClient.kt b/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/WPcomLoginClient.kt index 8ae0f0f8814b..c15904d81d1d 100644 --- a/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/WPcomLoginClient.kt +++ b/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/WPcomLoginClient.kt @@ -5,20 +5,26 @@ import android.util.Log import com.google.gson.Gson import kotlinx.coroutines.withContext import okhttp3.FormBody +import okhttp3.Interceptor import okhttp3.OkHttpClient import okhttp3.Request +import org.wordpress.android.fluxc.module.OkHttpClientQualifiers import org.wordpress.android.fluxc.network.rest.wpapi.applicationpasswords.WPcomAuthorizationCodeResponse import org.wordpress.android.fluxc.network.rest.wpcom.auth.AppSecrets import javax.inject.Inject +import javax.inject.Named import javax.inject.Singleton import kotlin.coroutines.CoroutineContext @Singleton class WPcomLoginClient @Inject constructor( private val context: CoroutineContext, - private val appSecrets: AppSecrets + private val appSecrets: AppSecrets, + @Named(OkHttpClientQualifiers.INTERCEPTORS) interceptors: Set<@JvmSuppressWildcards Interceptor> ) { - private val client = OkHttpClient() + private val client = OkHttpClient.Builder().apply { + interceptors.forEach { addInterceptor(it) } + }.build() fun loginUri(redirectUri: String): Uri { return Uri.Builder().scheme("https") diff --git a/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/rs/WpApiClientProvider.kt b/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/rs/WpApiClientProvider.kt index e37df9967014..9b3f0982ef07 100644 --- a/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/rs/WpApiClientProvider.kt +++ b/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/rs/WpApiClientProvider.kt @@ -3,8 +3,10 @@ package org.wordpress.android.fluxc.network.rest.wpapi.rs import okhttp3.Cookie import okhttp3.CookieJar import okhttp3.HttpUrl +import okhttp3.Interceptor import okhttp3.OkHttpClient import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.module.OkHttpClientQualifiers import org.wordpress.android.fluxc.network.rest.wpapi.applicationpasswords.WpAppNotifierHandler import rs.wordpress.api.kotlin.WpApiClient import rs.wordpress.api.kotlin.WpHttpClient @@ -14,9 +16,11 @@ import uniffi.wp_api.WpAppNotifier import uniffi.wp_api.WpAuthenticationProvider import java.net.URL import javax.inject.Inject +import javax.inject.Named class WpApiClientProvider @Inject constructor( private val wpAppNotifierHandler: WpAppNotifierHandler, + @Named(OkHttpClientQualifiers.INTERCEPTORS) private val interceptors: Set<@JvmSuppressWildcards Interceptor>, ) { fun getWpApiClient( site: SiteModel, @@ -29,7 +33,7 @@ class WpApiClientProvider @Inject constructor( val client = WpApiClient( wpOrgSiteApiRootUrl = apiRootUrl, authProvider = authProvider, - requestExecutor = WpRequestExecutor(uploadListener = uploadListener, interceptors = emptyList()), + requestExecutor = WpRequestExecutor(interceptors = interceptors.toList(), uploadListener = uploadListener), appNotifier = object : WpAppNotifier { override suspend fun requestedWithInvalidAuthentication(requestUrl: String) { wpAppNotifierHandler.notifyRequestedWithInvalidAuthentication(site) @@ -54,6 +58,7 @@ class WpApiClientProvider @Inject constructor( return cookieStore[url.host] ?: emptyList() } }) + .apply { interceptors.forEach { addInterceptor(it) } } .build() val httpClient = WpHttpClient.CustomOkHttpClient(okHttpClient)