diff --git a/WordPress/src/main/AndroidManifest.xml b/WordPress/src/main/AndroidManifest.xml index ae6d2bbd5899..ecd7635c5fe4 100644 --- a/WordPress/src/main/AndroidManifest.xml +++ b/WordPress/src/main/AndroidManifest.xml @@ -849,6 +849,11 @@ android:name=".ui.jetpackplugininstall.fullplugin.install.JetpackFullPluginInstallActivity" android:theme="@style/WordPress.NoActionBar" /> + + + Unit), + onCloseClick: () -> Unit, + modifier: Modifier = Modifier +) { + AppThemeM3 { + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(id = titleRes)) }, + navigationIcon = { + IconButton(onClick = onCloseClick) { + Icon(Icons.Filled.Close, stringResource(R.string.close)) + } + }, + ) + }, + modifier = modifier, + ) { contentPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .imePadding() + .padding(contentPadding) + ) { + content() + } + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/jetpackconnection/JetpackConnectionActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/jetpackconnection/JetpackConnectionActivity.kt new file mode 100644 index 000000000000..2e74f5da8066 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/jetpackconnection/JetpackConnectionActivity.kt @@ -0,0 +1,58 @@ +package org.wordpress.android.ui.jetpackconnection + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.activity.viewModels +import androidx.compose.runtime.collectAsState +import androidx.lifecycle.lifecycleScope +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.launch +import org.wordpress.android.R +import org.wordpress.android.ui.main.BaseAppCompatActivity +import org.wordpress.android.util.extensions.setContent + +@AndroidEntryPoint +class JetpackConnectionActivity : BaseAppCompatActivity() { + private val viewModel: JetpackConnectionViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + JetpackConnectionScreen( + currentStep = viewModel.currentStep.collectAsState(), + stepStates = viewModel.stepStates.collectAsState(), + buttonType = viewModel.buttonType.collectAsState(), + onCloseClick = viewModel::onCloseClick, + onRetryClick = viewModel::onRetryClick + ) + } + + lifecycleScope.launch { + viewModel.uiEvent.filterNotNull().collect { event -> + when (event) { + JetpackConnectionViewModel.UiEvent.Close -> finish() + JetpackConnectionViewModel.UiEvent.ShowCancelConfirmation -> showCancelConfirmationDialog() + } + } + } + } + + private fun showCancelConfirmationDialog() { + MaterialAlertDialogBuilder(this) + .setTitle(R.string.jetpack_connection_cancel_title) + .setMessage(R.string.jetpack_connection_cancel_message) + .setPositiveButton(R.string.yes) { _, _ -> viewModel.onCancelConfirmed() } + .setNegativeButton(R.string.no) { _, _ -> viewModel.onCancelDismissed() } + .setCancelable(false) + .show() + } + + companion object { + @JvmStatic + fun createIntent(context: Context) = + Intent(context, JetpackConnectionActivity::class.java) + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/jetpackconnection/JetpackConnectionScreen.kt b/WordPress/src/main/java/org/wordpress/android/ui/jetpackconnection/JetpackConnectionScreen.kt new file mode 100644 index 000000000000..8216d14b1e1b --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/jetpackconnection/JetpackConnectionScreen.kt @@ -0,0 +1,419 @@ +package org.wordpress.android.ui.jetpackconnection + +import android.content.Context +import android.content.res.Configuration +import androidx.annotation.ColorRes +import androidx.annotation.StringRes +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.fadeIn +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountCircle +import androidx.compose.material.icons.filled.Build +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource +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.ui.compose.components.ScreenWithTopAppBarM3 +import org.wordpress.android.ui.compose.components.buttons.PrimaryButtonM3 +import org.wordpress.android.ui.jetpackconnection.JetpackConnectionViewModel.ButtonType +import org.wordpress.android.ui.jetpackconnection.JetpackConnectionViewModel.ConnectionStatus +import org.wordpress.android.ui.jetpackconnection.JetpackConnectionViewModel.ConnectionStep +import org.wordpress.android.ui.jetpackconnection.JetpackConnectionViewModel.ErrorType +import org.wordpress.android.ui.jetpackconnection.JetpackConnectionViewModel.StepState + +@Composable +fun JetpackConnectionScreen( + currentStep: State, + stepStates: State>, + buttonType: State, + onCloseClick: () -> Unit = {}, + onRetryClick: () -> Unit = {} +) { + ScreenWithTopAppBarM3( + titleRes = R.string.jetpack_connection_setup_title, + onCloseClick = onCloseClick, + content = { + Column { + JetpackConnectionSteps( + currentStep = currentStep.value, + stepStates = stepStates.value, + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()) + ) + + AnimatedVisibility( + visible = buttonType.value != null, + enter = fadeIn() + ) { + JetpackConnectionButton( + buttonType = buttonType.value, + onDoneClick = onCloseClick, + onRetryClick = onRetryClick + ) + } + } + }, + ) +} + +@Composable +private fun JetpackConnectionButton( + buttonType: ButtonType?, + onDoneClick: () -> Unit, + onRetryClick: () -> Unit +) { + val (labelRes, onClick) = when (buttonType) { + ButtonType.Done -> R.string.label_done_button to onDoneClick + ButtonType.Retry -> R.string.retry to onRetryClick + null -> return + } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + PrimaryButtonM3( + onClick = onClick, + modifier = Modifier.fillMaxWidth(), + text = stringResource(labelRes), + ) + } +} + +private data class StepConfig( + val step: ConnectionStep, + val titleRes: Int, + val icon: ImageVector +) + +private val stepConfigs = listOf( + StepConfig(ConnectionStep.LoginWpCom, R.string.jetpack_connection_step_login_wpcom, Icons.Default.AccountCircle), + StepConfig(ConnectionStep.InstallJetpack, R.string.jetpack_connection_step_install_jetpack, Icons.Default.Build), + StepConfig(ConnectionStep.ConnectSite, R.string.jetpack_connection_step_connect_site, Icons.Default.Home), + StepConfig(ConnectionStep.ConnectWpCom, R.string.jetpack_connection_step_connect_wpcom, Icons.Default.Settings), + StepConfig(ConnectionStep.Finalize, R.string.jetpack_connection_step_finalize, Icons.Default.CheckCircle) +) + +@Composable +private fun JetpackConnectionSteps( + currentStep: ConnectionStep?, + stepStates: Map, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + stepConfigs.forEach { config -> + ConnectionStepItem( + title = stringResource(config.titleRes), + icon = config.icon, + stepState = stepStates[config.step] ?: StepState(), + isCurrentStep = currentStep == config.step + ) + } + } +} + +@Composable +private fun ConnectionStepItem( + title: String, + icon: ImageVector, + stepState: StepState, + isCurrentStep: Boolean +) { + val status = stepState.status + val style = rememberConnectionStepStyle(status, isCurrentStep) + + Row( + modifier = style.modifier, + verticalAlignment = Alignment.CenterVertically + ) { + ConnectionStepIcon( + icon = icon, + style = style + ) + + Spacer(modifier = Modifier.width(16.dp)) + + ConnectionStepContent( + title = title, + status = status, + errorType = stepState.errorType, + style = style, + modifier = Modifier.weight(1f) + ) + + ConnectionStepStatusIndicator( + status = status, + style = style + ) + } +} + +@Composable +private fun ConnectionStepIcon( + icon: ImageVector, + style: ConnectionStepStyle +) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = style.iconColor + ) +} + +@Composable +private fun ConnectionStepContent( + title: String, + status: ConnectionStatus, + errorType: ErrorType?, + style: ConnectionStepStyle, + modifier: Modifier = Modifier +) { + Column(modifier = modifier) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = style.titleFontWeight, + color = style.titleColor + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = getStatusText(status), + style = MaterialTheme.typography.bodyMedium, + color = style.statusColor + ) + + if (errorType != null && status == ConnectionStatus.Failed) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = getErrorText(LocalContext.current, errorType), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + } +} + +private fun getErrorText(context: Context, errorType: ErrorType): String { + @StringRes val messageRes = when (errorType) { + is ErrorType.JetpackAlreadyInstalled -> R.string.jetpack_connection_error_jetpack_already_installed + is ErrorType.Timeout -> R.string.jetpack_connection_error_timeout + is ErrorType.Offline -> R.string.jetpack_connection_error_offline + is ErrorType.Unknown -> R.string.jetpack_connection_error_unknown + is ErrorType.FailedToConnectWpCom -> R.string.jetpack_connection_error_wpcom + } + val baseMessage = context.getString(messageRes) + return errorType.message?.let { "$baseMessage: $it" } ?: baseMessage +} + +@Composable +private fun ConnectionStepStatusIndicator( + status: ConnectionStatus, + style: ConnectionStepStyle +) { + when (status) { + ConnectionStatus.InProgress -> { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + color = style.progressColor + ) + } + + ConnectionStatus.Completed -> { + Icon( + imageVector = Icons.Default.Check, + contentDescription = stringResource(R.string.jetpack_connection_status_completed), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary + ) + } + + ConnectionStatus.Failed -> { + Icon( + imageVector = Icons.Default.Warning, + contentDescription = stringResource(R.string.jetpack_connection_status_failed), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.error + ) + } + + ConnectionStatus.NotStarted -> { + // No indicator for not started + } + } +} + +@Composable +private fun getStatusText(status: ConnectionStatus): String = when (status) { + ConnectionStatus.NotStarted -> stringResource(R.string.jetpack_connection_status_not_started) + ConnectionStatus.InProgress -> stringResource(R.string.jetpack_connection_status_in_progress) + ConnectionStatus.Completed -> stringResource(R.string.jetpack_connection_status_completed) + ConnectionStatus.Failed -> stringResource(R.string.jetpack_connection_status_failed) +} + +@Composable +private fun rememberConnectionStepStyle( + status: ConnectionStatus, + isCurrentStep: Boolean +): ConnectionStepStyle { + val targetAlpha = if (status == ConnectionStatus.Completed) 0.6f else 1f + val animatedAlpha by animateFloatAsState(targetValue = targetAlpha) + + val targetColor = when { + status == ConnectionStatus.Completed -> MaterialTheme.colorScheme.primary.copy(alpha = 0.1f) + status == ConnectionStatus.InProgress -> colorResource(IN_PROGRESS_BACKGROUND_COLOR) + status == ConnectionStatus.Failed -> MaterialTheme.colorScheme.error.copy(alpha = 0.1f) + isCurrentStep -> MaterialTheme.colorScheme.primaryContainer + else -> MaterialTheme.colorScheme.surface + } + val animatedColor by animateColorAsState(targetValue = targetColor) + + val elevation = when (status) { + ConnectionStatus.NotStarted -> 2.dp + ConnectionStatus.InProgress -> 4.dp + else -> 0.dp + } + + val shape = MaterialTheme.shapes.medium + + val iconColor = when { + status == ConnectionStatus.InProgress -> colorResource(IN_PROGRESS_FOREGROUND_COLOR) + isCurrentStep -> MaterialTheme.colorScheme.onPrimaryContainer + else -> MaterialTheme.colorScheme.onSurface + } + + val titleColor = when { + status == ConnectionStatus.InProgress -> colorResource(IN_PROGRESS_FOREGROUND_COLOR) + isCurrentStep -> MaterialTheme.colorScheme.onPrimaryContainer + else -> MaterialTheme.colorScheme.onSurface + } + + val statusColor = when { + status == ConnectionStatus.InProgress -> colorResource(IN_PROGRESS_FOREGROUND_COLOR).copy(alpha = 0.7f) + isCurrentStep -> MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f) + else -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + } + + val progressColor = when { + status == ConnectionStatus.InProgress -> colorResource(IN_PROGRESS_FOREGROUND_COLOR) + isCurrentStep -> MaterialTheme.colorScheme.onPrimaryContainer + else -> MaterialTheme.colorScheme.primary + } + + return ConnectionStepStyle( + modifier = Modifier + .fillMaxWidth() + .shadow( + elevation = elevation, + shape = shape, + clip = false + ) + .clip(shape) + .background(animatedColor) + .alpha(animatedAlpha) + .padding(16.dp), + iconColor = iconColor, + titleColor = titleColor, + statusColor = statusColor, + progressColor = progressColor, + titleFontWeight = if (isCurrentStep) FontWeight.Bold else FontWeight.Normal + ) +} + +private data class ConnectionStepStyle( + val modifier: Modifier, + val iconColor: Color, + val titleColor: Color, + val statusColor: Color, + val progressColor: Color, + val titleFontWeight: FontWeight +) + +@Preview( + name = "Light Mode", + showBackground = true +) +@Preview( + name = "Dark Mode", + showBackground = true, + uiMode = Configuration.UI_MODE_NIGHT_YES, +) +@Composable +private fun JetpackConnectionScreenPreview() { + val currentStep = remember { mutableStateOf(ConnectionStep.ConnectSite) } + val stepStates = remember { + mutableStateOf( + mapOf( + ConnectionStep.LoginWpCom to StepState(ConnectionStatus.Completed), + ConnectionStep.InstallJetpack to StepState(ConnectionStatus.Completed), + ConnectionStep.ConnectSite to StepState(ConnectionStatus.InProgress), + ConnectionStep.ConnectWpCom to StepState( + ConnectionStatus.Failed, + ErrorType.FailedToConnectWpCom() + ), + ConnectionStep.Finalize to StepState(ConnectionStatus.NotStarted) + ) + ) + } + val buttonType = remember { mutableStateOf(ButtonType.Done) } + + JetpackConnectionScreen( + currentStep = currentStep, + stepStates = stepStates, + buttonType = buttonType, + onCloseClick = {}, + onRetryClick = {} + ) +} + +@ColorRes private val IN_PROGRESS_BACKGROUND_COLOR = R.color.yellow_10 // Light yellow +@ColorRes private val IN_PROGRESS_FOREGROUND_COLOR = R.color.yellow_90 // Dark brown for readability on the above diff --git a/WordPress/src/main/java/org/wordpress/android/ui/jetpackconnection/JetpackConnectionViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/jetpackconnection/JetpackConnectionViewModel.kt new file mode 100644 index 000000000000..479e3af0d3ba --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/jetpackconnection/JetpackConnectionViewModel.kt @@ -0,0 +1,345 @@ +package org.wordpress.android.ui.jetpackconnection + +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.store.AccountStore +import org.wordpress.android.fluxc.utils.AppLogWrapper +import org.wordpress.android.modules.BG_THREAD +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.VersionUtils.checkMinimalVersion +import org.wordpress.android.viewmodel.ScopedViewModel +import kotlinx.coroutines.delay +import uniffi.wp_api.JetpackConnectionClient +import uniffi.wp_api.WpAuthentication +import javax.inject.Inject +import javax.inject.Named + +@HiltViewModel +class JetpackConnectionViewModel @Inject constructor( + @Named(UI_THREAD) mainDispatcher: CoroutineDispatcher, + @Named(BG_THREAD) private val bgDispatcher: CoroutineDispatcher, + private val selectedSiteRepository: SelectedSiteRepository, + private val accountStore: AccountStore, + private val appLogWrapper: AppLogWrapper, +) : ScopedViewModel(mainDispatcher) { + private val _currentStep = MutableStateFlow(null) + val currentStep = _currentStep + + private val _uiEvent = MutableStateFlow(null) + val uiEvent = _uiEvent + + private val _buttonType = MutableStateFlow(null) + val buttonType = _buttonType + + data class StepState( + val status: ConnectionStatus = ConnectionStatus.NotStarted, + val errorType: ErrorType? = null, + ) + + private val _stepStates = MutableStateFlow(initialStepStates) + val stepStates = _stepStates + + private var job: Job? = null + + // TODO Inject or initialize this properly when the actual implementation is ready + private var jetpackConnectionClient: JetpackConnectionClient? = null + + init { + startConnectionJob() + } + + private fun startConnectionJob(fromStep: ConnectionStep? = null) { + val stepInfo = fromStep?.let { " from step: $it" } ?: "" + appLogWrapper.d(AppLog.T.API, "$TAG: Starting Jetpack connection job$stepInfo") + job?.cancel() + job = launch { + startStep(fromStep ?: ConnectionStep.LoginWpCom) + } + } + + private fun onJobCompleted() { + appLogWrapper.d(AppLog.T.API, "$TAG: Jetpack connection job completed") + job?.cancel() + _buttonType.value = ButtonType.Done + _currentStep.value = null + } + + private fun getNextStep(): ConnectionStep? = when (currentStep.value) { + null -> ConnectionStep.LoginWpCom + ConnectionStep.LoginWpCom -> ConnectionStep.InstallJetpack + ConnectionStep.InstallJetpack -> ConnectionStep.ConnectSite + ConnectionStep.ConnectSite -> ConnectionStep.ConnectWpCom + ConnectionStep.ConnectWpCom -> ConnectionStep.Finalize + ConnectionStep.Finalize -> null + } + + private suspend fun startNextStep() { + // Mark current step as completed if exists + currentStep.value?.let { + if (_stepStates.value[it]?.status == ConnectionStatus.InProgress) { + updateStepStatus(it, ConnectionStatus.Completed) + } + } + + // Start the next step if there is one + getNextStep()?.let { + startStep(it) + } + } + + private suspend fun startStep(step: ConnectionStep) { + appLogWrapper.d(AppLog.T.API, "$TAG: Starting step: $step") + _currentStep.value = step + updateStepStatus(step, ConnectionStatus.InProgress) + // TODO executeStepWithErrorHandling(nextStep) + + // TODO this is just to test the UI + delay(STEP_DELAY_MS) + updateStepStatus(step, ConnectionStatus.Completed) + } + + private fun updateStepStatus( + step: ConnectionStep, + status: ConnectionStatus, + error: ErrorType? = null + ) { + appLogWrapper.d(AppLog.T.API, "$TAG: updateStepStatus $step -> $status${error?.let { " (error: $it)" } ?: ""}") + _stepStates.value = _stepStates.value.toMutableMap().apply { + this[step] = StepState(status = status, errorType = error) + } + + when (status) { + ConnectionStatus.Failed -> { + job?.cancel() + _currentStep.value = null + _buttonType.value = ButtonType.Retry + } + ConnectionStatus.Completed -> { + if (step == ConnectionStep.Finalize) { + onJobCompleted() + } else { + launch { + startNextStep() + } + } + } + else -> {} + } + } + + fun onCloseClick() { + appLogWrapper.d(AppLog.T.API, "$TAG: Close clicked") + if (isActive()) { + // Connection is in progress, show confirmation dialog + appLogWrapper.d(AppLog.T.API, "$TAG: Connection in progress, showing confirmation") + setUiEvent(UiEvent.ShowCancelConfirmation) + } else { + // No active connection, close immediately + setUiEvent(UiEvent.Close) + } + } + + fun onCancelConfirmed() { + appLogWrapper.d(AppLog.T.API, "$TAG: Cancel confirmed") + job?.cancel() + setUiEvent(UiEvent.Close) + } + + fun onCancelDismissed() { + appLogWrapper.d(AppLog.T.API, "$TAG: Cancel dismissed, continuing connection") + } + + fun onRetryClick() { + appLogWrapper.d(AppLog.T.API, "$TAG: Retry clicked") + // Find the failed step from stepStates + val stepToRetry = _stepStates.value.entries.find { (_, state) -> + state.status == ConnectionStatus.Failed + }?.key + + stepToRetry?.let { step -> + // Only reset the failed step status, keep other steps intact + _stepStates.value = _stepStates.value.toMutableMap().apply { + this[step] = StepState() + } + _buttonType.value = null + _uiEvent.value = null + startConnectionJob(fromStep = step) + } ?: run { + // Fallback to original behavior if no failed step found + clearValues() + startConnectionJob() + } + } + + private fun clearValues() { + _uiEvent.value = null + _stepStates.value = initialStepStates + _buttonType.value = null + _currentStep.value = null + } + + private fun isActive(): Boolean = job?.isActive == true || run { + // if there's a current step, and it's not failed, then it's active + val step = currentStep.value + step != null && _stepStates.value[step]?.status != ConnectionStatus.Failed + } + + private fun setUiEvent(event: UiEvent) { + appLogWrapper.d(AppLog.T.API, "$TAG: setUiEvent $event") + // Clear the event first or else it won't be observed if its the same as the previous event + _uiEvent.value = null + _uiEvent.value = event + } + + @Suppress("TooGenericExceptionCaught", "Unused", "UnusedPrivateMember") + private suspend fun executeStepWithErrorHandling(step: ConnectionStep) { + try { + withContext(bgDispatcher) { + withTimeout(STEP_TIMEOUT_MS) { + executeNetworkRequest(step) + } + } + updateStepStatus(step, ConnectionStatus.Completed) + } catch (e: Exception) { + appLogWrapper.e(AppLog.T.API, "$TAG: Error in step $step: ${e.message}") + val errorType = when (e) { + is TimeoutCancellationException -> ErrorType.Timeout(e.message) + else -> ErrorType.Unknown(e.message) + } + updateStepStatus(step, ConnectionStatus.Failed, errorType) + } + } + + private suspend fun executeNetworkRequest(step: ConnectionStep) { + when (step) { + ConnectionStep.LoginWpCom -> { + // TODO + } + + ConnectionStep.InstallJetpack -> { + val site = getSite() + if (site.isJetpackInstalled) { + appLogWrapper.d(AppLog.T.API, "$TAG: Jetpack already installed") + updateStepStatus( + step = step, + status = ConnectionStatus.Failed, + error = ErrorType.JetpackAlreadyInstalled() + ) + } else { + installJetpackPlugin() + } + } + + ConnectionStep.ConnectSite -> { + appLogWrapper.d(AppLog.T.API, "$TAG: Connecting site") + jetpackConnectionClient?.connectSite( + from = getSiteId().toString() + ) ?: error("JetpackConnectionClient not initialized") + } + + ConnectionStep.ConnectWpCom -> { + val token = accountStore.accessToken + ?: error("No access token available") + + appLogWrapper.d(AppLog.T.API, "$TAG: Connecting WordPress.com user") + jetpackConnectionClient?.connectUser( + wpComAuthentication = WpAuthentication.Bearer(token = token), + from = getSiteId().toString() + ) ?: error("JetpackConnectionClient not initialized") + } + + ConnectionStep.Finalize -> { + appLogWrapper.d(AppLog.T.API, "$TAG: Finalizing connection") + } + } + } + + private suspend fun installJetpackPlugin() { + appLogWrapper.d(AppLog.T.API, "$TAG: Installing Jetpack plugin") + // TODO Implement actual plugin installation API call when ready + // val params = PluginCreateParams( + // slug = PluginWpOrgDirectorySlug("jetpack"), + // status = PluginStatus.ACTIVE, + // ) + // For now, simulate network delay + delay(STEP_DELAY_MS) + } + + private fun getSiteId() = getSite().siteId + + private fun getSite() = selectedSiteRepository.getSelectedSite() + ?: error("No site is currently selected in SelectedSiteRepository") + + sealed class ConnectionStep { + data object LoginWpCom : ConnectionStep() + data object InstallJetpack : ConnectionStep() + data object ConnectSite : ConnectionStep() + data object ConnectWpCom : ConnectionStep() + data object Finalize : ConnectionStep() + } + + sealed class ConnectionStatus { + data object NotStarted : ConnectionStatus() + data object InProgress : ConnectionStatus() + data object Completed : ConnectionStatus() + data object Failed : ConnectionStatus() + } + + sealed class UiEvent { + data object Close : UiEvent() + data object ShowCancelConfirmation : UiEvent() + } + + sealed class ErrorType(open val message: String? = null) { + data class JetpackAlreadyInstalled(override val message: String? = null) : ErrorType(message) + data class FailedToConnectWpCom(override val message: String? = null) : ErrorType(message) + data class Timeout(override val message: String? = null) : ErrorType(message) + data class Offline(override val message: String? = null) : ErrorType(message) + data class Unknown(override val message: String? = null) : ErrorType(message) + } + + sealed class ButtonType { + data object Done : ButtonType() + data object Retry : ButtonType() + } + + companion object { + private const val TAG = "JetpackConnectionViewModel" + private const val LIMIT_VERSION = "14.2" + private const val STEP_TIMEOUT_MS = 30000L // 30 seconds timeout per step + private const val STEP_DELAY_MS = 2000L + + /** + * Requirements: + * - Self-hosted site, and + * - The site is authenticated with application password, and + * - the site isn't already connected to Jetpack, and + * - Jetpack is not installed or the installed jetpack version is 14.2 or above + */ + @Suppress("Unused") + fun canInitiateJetpackConnection(site: SiteModel): Boolean { + return site.isSelfHostedAdmin + && site.isApplicationPasswordsSupported + && !site.applicationPasswordsAuthorizeUrl.isNullOrEmpty() + && !site.wpApiRestUrl.isNullOrEmpty() + && !site.isJetpackConnected + && (!site.isJetpackInstalled || checkMinimalVersion(site.jetpackVersion, LIMIT_VERSION)) + } + + private val initialStepStates = mapOf( + ConnectionStep.LoginWpCom to StepState(), + ConnectionStep.InstallJetpack to StepState(), + ConnectionStep.ConnectSite to StepState(), + ConnectionStep.ConnectWpCom to StepState(), + ConnectionStep.Finalize to StepState() + ) + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/main/WPMainActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/main/WPMainActivity.java index 7d8973bb66d0..4fd33e93d412 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/main/WPMainActivity.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/main/WPMainActivity.java @@ -94,6 +94,7 @@ import org.wordpress.android.ui.bloggingreminders.BloggingReminderUtils; import org.wordpress.android.ui.bloggingreminders.BloggingRemindersViewModel; import org.wordpress.android.ui.deeplinks.DeepLinkOpenWebLinksWithJetpackHelper; +import org.wordpress.android.ui.jetpackconnection.JetpackConnectionActivity; import org.wordpress.android.ui.jetpackoverlay.JetpackFeatureFullScreenOverlayFragment; import org.wordpress.android.ui.jetpackoverlay.JetpackFeatureRemovalOverlayUtil; import org.wordpress.android.ui.jetpackoverlay.JetpackFeatureRemovalOverlayUtil.JetpackFeatureCollectionOverlaySource; @@ -518,6 +519,10 @@ && getIntent().getExtras().getBoolean(ARG_CONTINUE_JETPACK_CONNECT, false)) { if (savedInstanceState != null) { mIsChangingConfiguration = savedInstanceState.getBoolean(ARG_IS_CHANGING_CONFIGURATION, false); } + + // TODO remove this + Intent intent = JetpackConnectionActivity.createIntent(this); + startActivity(intent); } private void initBackPressHandler() { diff --git a/WordPress/src/main/res/values/colors.xml b/WordPress/src/main/res/values/colors.xml index 9495d5e7b512..ee76302d498f 100644 --- a/WordPress/src/main/res/values/colors.xml +++ b/WordPress/src/main/res/values/colors.xml @@ -159,5 +159,4 @@ #EDD6C5 - diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml index e315c12ad59d..a7b813538f96 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -1004,6 +1004,26 @@ People looking at graphs and charts By setting up Jetpack you agree to our %1$sterms and conditions%2$s + + Jetpack Connection + Setting up Jetpack + Login to WordPress.com + Install Jetpack Plugin + Connect Your Site + Connect to WordPress.com + Finalize Setup + Not started + In progress… + Completed + Failed + Cancel Jetpack Setup? + The Jetpack connection process is in progress. Are you sure you want to cancel? + Jetpack is already installed + Timed out + @string/error_generic + @string/no_network_title + Unable to connect to WordPress.com + No data yet No data for this period