diff --git a/WordPress/src/main/java/org/wordpress/android/ui/sitemonitor/SiteMonitorFragmentContainer.kt b/WordPress/src/main/java/org/wordpress/android/ui/sitemonitor/SiteMonitorFragmentContainer.kt new file mode 100644 index 000000000000..eb5eea65c8a1 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/sitemonitor/SiteMonitorFragmentContainer.kt @@ -0,0 +1,76 @@ +package org.wordpress.android.ui.sitemonitor + +import android.content.Context +import android.view.View +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.viewinterop.AndroidView +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import androidx.fragment.app.FragmentContainerView +import androidx.fragment.app.FragmentTransaction +import androidx.fragment.app.commit +import androidx.fragment.app.findFragment + +@Suppress("SwallowedException") +@Composable +fun SiteMonitorFragmentContainer( + modifier: Modifier = Modifier, + commit: FragmentTransaction.(containerId: Int) -> Unit +) { + val currentLocalView = LocalView.current + // Using the current view, check if a parent fragment exists. + // This will help ensure that the fragment are nested correctly. + // This assists in saving/restoring the fragments to their proper state + val parentFragment = remember(currentLocalView) { + try { + currentLocalView.findFragment() + } catch (e: IllegalStateException) { + null + } + } + val viewId by rememberSaveable { mutableIntStateOf(View.generateViewId()) } + val container = remember { mutableStateOf(null) } + val viewSection: (Context) -> View = remember(currentLocalView) { + { context -> + FragmentContainerView(context) + .apply { id = viewId } + .also { + val fragmentManager = parentFragment?.childFragmentManager + ?: (context as? FragmentActivity)?.supportFragmentManager + fragmentManager?.commit { commit(it.id) } + container.value = it + } + } + } + AndroidView( + modifier = modifier, + factory = viewSection, + update = {} + ) + + // Be sure to clean up the fragments when the FragmentContainer is disposed + val localContext = LocalContext.current + DisposableEffect(currentLocalView, localContext, container) { + onDispose { + val fragmentManager = parentFragment?.childFragmentManager + ?: (localContext as? FragmentActivity)?.supportFragmentManager + // Use the FragmentContainerView to find the inflated fragment + val existingFragment = fragmentManager?.findFragmentById(container.value?.id ?: 0) + if (existingFragment != null && !fragmentManager.isStateSaved) { + // A composable has been removed from the hierarchy if the state isn't saved + fragmentManager.commit { + remove(existingFragment) + } + } + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/sitemonitor/SiteMonitorMapper.kt b/WordPress/src/main/java/org/wordpress/android/ui/sitemonitor/SiteMonitorMapper.kt new file mode 100644 index 000000000000..f4bcb0d3c619 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/sitemonitor/SiteMonitorMapper.kt @@ -0,0 +1,23 @@ +package org.wordpress.android.ui.sitemonitor + +import javax.inject.Inject + +class SiteMonitorMapper @Inject constructor( + private val siteMonitorUtils: SiteMonitorUtils +) { + fun toPrepared(url: String, addressToLoad: String, siteMonitorType: SiteMonitorType) = SiteMonitorUiState.Prepared( + model = SiteMonitorModel( + enableJavascript = true, + enableDomStorage = true, + userAgent = siteMonitorUtils.getUserAgent(), + enableChromeClient = true, + url = url, + addressToLoad = addressToLoad, + siteMonitorType = siteMonitorType + ) + ) + + fun toNoNetworkError(buttonClick: () -> Unit) = SiteMonitorUiState.NoNetworkError(buttonClick = buttonClick) + + fun toGenericError(buttonClick: () -> Unit) = SiteMonitorUiState.GenericError(buttonClick = buttonClick) +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/sitemonitor/SiteMonitorParentActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/sitemonitor/SiteMonitorParentActivity.kt index 96087a86b48e..7291b52263ab 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/sitemonitor/SiteMonitorParentActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/sitemonitor/SiteMonitorParentActivity.kt @@ -2,23 +2,24 @@ package org.wordpress.android.ui.sitemonitor import android.annotation.SuppressLint import android.os.Bundle +import android.util.SparseArray import androidx.activity.compose.setContent -import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.material.MaterialTheme +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding import androidx.compose.material.Scaffold -import androidx.compose.material.TabRow -import androidx.compose.material.Text -import androidx.compose.material3.Tab +import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.FragmentTransaction import dagger.hilt.android.AndroidEntryPoint import org.wordpress.android.R import org.wordpress.android.WordPress @@ -30,25 +31,49 @@ import org.wordpress.android.util.extensions.getSerializableExtraCompat @AndroidEntryPoint class SiteMonitorParentActivity: AppCompatActivity() { - val viewModel:SiteMonitorParentViewModel by viewModels() + private var savedStateSparseArray = SparseArray() + private var currentSelectItemId = 0 + @Suppress("DEPRECATION") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + if (savedInstanceState != null) { + savedStateSparseArray = savedInstanceState.getSparseParcelableArray( + SAVED_STATE_CONTAINER_KEY + ) + ?: savedStateSparseArray + currentSelectItemId = savedInstanceState.getInt(SAVED_STATE_CURRENT_TAB_KEY) + } setContent { AppTheme { - viewModel.start(getSite()) - SiteMonitorScreen() + Surface( + modifier = Modifier.fillMaxSize(), + ) { + SiteMonitorScreen() + } } } } + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putSparseParcelableArray(SAVED_STATE_CONTAINER_KEY, savedStateSparseArray) + outState.putInt(SAVED_STATE_CURRENT_TAB_KEY, currentSelectItemId) + } + private fun getSite(): SiteModel { return requireNotNull(intent.getSerializableExtraCompat(WordPress.SITE)) as SiteModel } + companion object { + const val SAVED_STATE_CONTAINER_KEY = "ContainerKey" + const val SAVED_STATE_CURRENT_TAB_KEY = "CurrentTabKey" + } + @Composable @SuppressLint("UnusedMaterialScaffoldPaddingParameter") - fun SiteMonitorScreen(modifier: Modifier = Modifier) { + fun SiteMonitorScreen() { + var selectedTab by rememberSaveable { mutableStateOf(SiteMonitorTabItem.Metrics.route) } Scaffold( topBar = { MainTopAppBar( @@ -56,47 +81,51 @@ class SiteMonitorParentActivity: AppCompatActivity() { navigationIcon = NavigationIcons.BackIcon, onNavigationIconClick = onBackPressedDispatcher::onBackPressed, ) - }, - content = { - TabScreen(modifier = modifier) } - ) - } - - @Composable - @SuppressLint("UnusedMaterialScaffoldPaddingParameter") - fun TabScreen(modifier: Modifier = Modifier) { - var tabIndex by remember { mutableStateOf(0) } - - val tabs = listOf( - R.string.site_monitoring_tab_title_metrics, - R.string.site_monitoring_tab_title_php_logs, - R.string.site_monitoring_tab_title_web_server_logs - ) + ) { padding -> + Column(modifier = Modifier.padding(padding)) { + SiteMonitorTabHeader { clickTab -> + selectedTab = clickTab + } + SiteMonitorTabNavigation(selectedTab) { selectedTab -> + val item = enumValues().find { + it.route == selectedTab + } ?: SiteMonitorTabItem.Metrics - Column(modifier = modifier.fillMaxWidth()) { - TabRow( - selectedTabIndex = tabIndex, - backgroundColor = MaterialTheme.colors.surface, - contentColor = MaterialTheme.colors.onSurface, - ) { - tabs.forEachIndexed { index, title -> - Tab(text = { Text(stringResource(id = title)) }, - selected = tabIndex == index, - onClick = { tabIndex = index } + SiteMonitorFragmentContainer( + modifier = Modifier.fillMaxSize(), + commit = getCommitFunction( + SiteMonitorTabFragment.newInstance(item.urlTemplate, item.siteMonitorType, getSite()), + item.route + ) ) } } - when (tabIndex) { - 0 -> SiteMonitoringWebView() - 1 -> SiteMonitoringWebView() - 2 -> SiteMonitoringWebView() - } } } - @Composable - fun SiteMonitoringWebView(){ - Text(text = "SiteMonitoringWebView") + private fun getCommitFunction( + fragment : Fragment, + tag: String + ): FragmentTransaction.(containerId: Int) -> Unit = + { + saveAndRetrieveFragment(supportFragmentManager, it, fragment) + replace(it, fragment, tag) + } + + private fun saveAndRetrieveFragment( + supportFragmentManager: FragmentManager, + tabId: Int, + fragment: Fragment + ) { + val currentFragment = supportFragmentManager.findFragmentById(currentSelectItemId) + if (currentFragment != null) { + savedStateSparseArray.put( + currentSelectItemId, + supportFragmentManager.saveFragmentInstanceState(currentFragment) + ) + } + currentSelectItemId = tabId + fragment.setInitialSavedState(savedStateSparseArray[currentSelectItemId]) } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/sitemonitor/SiteMonitorParentViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/sitemonitor/SiteMonitorParentViewModel.kt deleted file mode 100644 index 00c337746eed..000000000000 --- a/WordPress/src/main/java/org/wordpress/android/ui/sitemonitor/SiteMonitorParentViewModel.kt +++ /dev/null @@ -1,28 +0,0 @@ -package org.wordpress.android.ui.sitemonitor - -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.CoroutineDispatcher -import org.wordpress.android.analytics.AnalyticsTracker -import org.wordpress.android.fluxc.model.SiteModel -import org.wordpress.android.modules.BG_THREAD -import org.wordpress.android.util.analytics.AnalyticsTrackerWrapper -import org.wordpress.android.viewmodel.ScopedViewModel -import javax.inject.Inject -import javax.inject.Named - -@HiltViewModel -class SiteMonitorParentViewModel @Inject constructor( - @param:Named(BG_THREAD) private val bgDispatcher: CoroutineDispatcher, - private val analyticsTrackerWrapper: AnalyticsTrackerWrapper -) : ScopedViewModel(bgDispatcher) { - private lateinit var site: SiteModel - - fun start(site: SiteModel) { - this.site = site - trackActivityLaunched() - } - - private fun trackActivityLaunched() { - analyticsTrackerWrapper.track(AnalyticsTracker.Stat.SITE_MONITORING_SCREEN_SHOWN) - } -} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/sitemonitor/SiteMonitorTabFragment.kt b/WordPress/src/main/java/org/wordpress/android/ui/sitemonitor/SiteMonitorTabFragment.kt new file mode 100644 index 000000000000..8fb61cc8ed03 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/sitemonitor/SiteMonitorTabFragment.kt @@ -0,0 +1,184 @@ +package org.wordpress.android.ui.sitemonitor + +import android.annotation.SuppressLint +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.webkit.WebView +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import dagger.hilt.android.AndroidEntryPoint +import org.wordpress.android.WordPress +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.ui.WPWebViewActivity +import org.wordpress.android.ui.compose.utils.uiStringText + +@AndroidEntryPoint +class SiteMonitorTabFragment : Fragment(), SiteMonitorWebViewClient.SiteMonitorWebViewClientListener { + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View = ComposeView(requireContext()).apply { + setContent { + SiteMonitorTabContent() + } + } + + private val viewModel: SiteMonitorTabViewModel by viewModels() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + initViewModel(getSiteMonitorType(), getUrlTemplate(), getSite()) + } + + @Suppress("DEPRECATION") + private fun getSite(): SiteModel { + return requireNotNull(arguments?.getSerializable(WordPress.SITE)) as SiteModel + } + + private fun getUrlTemplate(): String { + return requireNotNull(arguments?.getString(KEY_URL_TEMPLATE)) + } + + @Suppress("DEPRECATION") + private fun getSiteMonitorType(): SiteMonitorType { + return requireNotNull(arguments?.getSerializable(KEY_SITE_MONITOR_TYPE)) as SiteMonitorType + } + + private fun initViewModel(type: SiteMonitorType, urlTemplate: String, site: SiteModel) { + viewModel.start(type, urlTemplate, site) + } + + override fun onWebViewPageLoaded(url: String) = viewModel.onUrlLoaded() + + override fun onWebViewReceivedError(url: String) = viewModel.onWebViewError() + + companion object { + const val KEY_URL_TEMPLATE = "KEY_URL" + const val KEY_SITE_MONITOR_TYPE = "KEY_SITE_MONITOR_TYPE" + fun newInstance(url: String, type: SiteMonitorType, site: SiteModel): Fragment { + val fragment = SiteMonitorTabFragment() + val argument = Bundle() + argument.putString(KEY_URL_TEMPLATE, url) + argument.putSerializable(KEY_SITE_MONITOR_TYPE, type) + argument.putSerializable(WordPress.SITE, site) + fragment.arguments = argument + return fragment + } + } + + @Composable + private fun SiteMonitorTabContent() { + val uiState by viewModel.uiState.collectAsState() + when (uiState) { + is SiteMonitorUiState.Preparing -> LoadingState() + is SiteMonitorUiState.Prepared, is SiteMonitorUiState.Loaded -> SiteMonitorWebView(uiState) + is SiteMonitorUiState.Error -> SiteMonitorError(uiState as SiteMonitorUiState.Error) + } + } + + @Composable + fun SiteMonitorError(error: SiteMonitorUiState.Error) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier + .padding(20.dp) + .fillMaxWidth() + .fillMaxHeight(), + ) { + androidx.compose.material.Text( + text = uiStringText(uiString = error.title), + style = androidx.compose.material.MaterialTheme.typography.h5, + textAlign = TextAlign.Center + ) + androidx.compose.material.Text( + text = uiStringText(uiString = error.description), + style = androidx.compose.material.MaterialTheme.typography.body1, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 8.dp) + ) + if (error.button != null) { + Button( + modifier = Modifier.padding(top = 8.dp), + onClick = error.button.click + ) { + androidx.compose.material.Text(text = uiStringText(uiString = error.button.text)) + } + } + } + } + + @SuppressLint("SetJavaScriptEnabled") + @Composable + private fun SiteMonitorWebView(uiState: SiteMonitorUiState) { + var webView: WebView? by remember { mutableStateOf(null) } + + if (uiState is SiteMonitorUiState.Prepared) { + val model = uiState.model + LaunchedEffect(true) { + webView = WebView(requireContext()).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + scrollBarStyle = View.SCROLLBARS_INSIDE_OVERLAY + settings.userAgentString = model.userAgent + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + webViewClient = SiteMonitorWebViewClient(this@SiteMonitorTabFragment) + postUrl(WPWebViewActivity.WPCOM_LOGIN_URL, model.addressToLoad.toByteArray()) + } + } + } + + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + if (uiState is SiteMonitorUiState.Prepared) { + LoadingState() + } else { + webView?.let { theWebView -> + AndroidView( + factory = { theWebView }, + modifier = Modifier.fillMaxSize() + ) + } + } + } + } +} + +@Composable +fun LoadingState(modifier: Modifier = Modifier) { + Box( + contentAlignment = Alignment.Center, + modifier = modifier.fillMaxSize() + ) { + CircularProgressIndicator() + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/sitemonitor/SiteMonitorTabHeader.kt b/WordPress/src/main/java/org/wordpress/android/ui/sitemonitor/SiteMonitorTabHeader.kt new file mode 100644 index 000000000000..1bf493f7c49d --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/sitemonitor/SiteMonitorTabHeader.kt @@ -0,0 +1,51 @@ +package org.wordpress.android.ui.sitemonitor + +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.sp + +@Composable +fun SiteMonitorTabHeader(navController: (String) -> Unit) { + var selectedTabIndex by rememberSaveable { mutableIntStateOf(0) } + val tabs = listOf( + SiteMonitorTabItem.Metrics, + SiteMonitorTabItem.PHPLogs, + SiteMonitorTabItem.WebServerLogs + ) + TabRow( + selectedTabIndex = selectedTabIndex, + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.onSurface, + ) { + tabs.forEachIndexed { index, item -> + Tab( + text = { + Column (horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = stringResource(item.title), + fontSize = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + }, + selected = selectedTabIndex == index, + onClick = { + selectedTabIndex = index + navController(item.route) + }, + ) + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/sitemonitor/SiteMonitorTabItem.kt b/WordPress/src/main/java/org/wordpress/android/ui/sitemonitor/SiteMonitorTabItem.kt new file mode 100644 index 000000000000..5940fd224375 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/sitemonitor/SiteMonitorTabItem.kt @@ -0,0 +1,34 @@ +package org.wordpress.android.ui.sitemonitor + +import androidx.annotation.StringRes +import org.wordpress.android.R + +const val METRICS_URL_TEMPLATE = "https://wordpress.com/site-monitoring/{blog}" +const val PHPLOGS_URL_TEMPLATE = "https://wordpress.com/site-monitoring/{blog}/php" +const val WEBSERVERLOGS_URL_TEMPLATE = "https://wordpress.com/site-monitoring/{blog}/web" + +enum class SiteMonitorTabItem( + val route: String, + @StringRes val title: Int, + val urlTemplate: String, + val siteMonitorType: SiteMonitorType +) { + Metrics( + "metrics", + R.string.site_monitoring_tab_title_metrics, + METRICS_URL_TEMPLATE, + SiteMonitorType.METRICS + ), + PHPLogs( + "phplogs", + R.string.site_monitoring_tab_title_php_logs, + PHPLOGS_URL_TEMPLATE, + SiteMonitorType.PHP_LOGS + ), + WebServerLogs( + "webserverlogs", + R.string.site_monitoring_tab_title_web_server_logs, + WEBSERVERLOGS_URL_TEMPLATE, + SiteMonitorType.WEB_SERVER_LOGS + ); +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/sitemonitor/SiteMonitorTabNavigation.kt b/WordPress/src/main/java/org/wordpress/android/ui/sitemonitor/SiteMonitorTabNavigation.kt new file mode 100644 index 000000000000..023e651a66d5 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/sitemonitor/SiteMonitorTabNavigation.kt @@ -0,0 +1,20 @@ +package org.wordpress.android.ui.sitemonitor + +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.saveable.rememberSaveableStateHolder +import androidx.compose.ui.Modifier + +@Composable +fun SiteMonitorTabNavigation ( + currentScreen: T, + modifier: Modifier = Modifier, + content: @Composable (T) -> Unit +) { + val saveableStateHolder = rememberSaveableStateHolder() + Box(modifier) { + saveableStateHolder.SaveableStateProvider(currentScreen) { + content(currentScreen) + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/sitemonitor/SiteMonitorTabViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/sitemonitor/SiteMonitorTabViewModel.kt new file mode 100644 index 000000000000..aaae3507f424 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/sitemonitor/SiteMonitorTabViewModel.kt @@ -0,0 +1,121 @@ +package org.wordpress.android.ui.sitemonitor + +import android.text.TextUtils +import android.util.Log +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.store.AccountStore +import org.wordpress.android.fluxc.store.SiteStore +import org.wordpress.android.modules.BG_THREAD +import org.wordpress.android.util.NetworkUtilsWrapper +import org.wordpress.android.viewmodel.ScopedViewModel +import javax.inject.Inject +import javax.inject.Named + +@HiltViewModel +class SiteMonitorTabViewModel @Inject constructor( + @param:Named(BG_THREAD) private val bgDispatcher: CoroutineDispatcher, + private val networkUtilsWrapper: NetworkUtilsWrapper, + private val accountStore: AccountStore, + private val mapper: SiteMonitorMapper, + private val siteMonitorUtils: SiteMonitorUtils, + private val siteStore: SiteStore, +) : ScopedViewModel(bgDispatcher) { + private lateinit var site: SiteModel + private lateinit var siteMonitorType: SiteMonitorType + private lateinit var urlTemplate: String + + private val _uiState = MutableStateFlow(SiteMonitorUiState.Preparing) + val uiState: StateFlow = _uiState + + fun start(type: SiteMonitorType, urlTemplate: String, site: SiteModel) { + Log.i("Track", "TheViewModel start with $urlTemplate and $type") + this.siteMonitorType = type + this.urlTemplate = urlTemplate + this.site = site + + loadView() + } + + private fun loadView() { + postUiState(SiteMonitorUiState.Preparing) + + if (!checkForInternetConnectivityAndPostErrorIfNeeded()) return + + if (!validateAndPostErrorIfNeeded()) return + + assembleAndShowSiteMonitor() + } + + private fun checkForInternetConnectivityAndPostErrorIfNeeded() : Boolean { + if (networkUtilsWrapper.isNetworkAvailable()) return true + postUiState(mapper.toNoNetworkError(this@SiteMonitorTabViewModel::loadView)) + return false + } + + private fun validateAndPostErrorIfNeeded(): Boolean { + if (accountStore.account.userName.isNullOrEmpty() || accountStore.accessToken.isNullOrEmpty()) { + postUiState(mapper.toGenericError(this@SiteMonitorTabViewModel::loadView)) + return false + } + return true + } + + private fun assembleAndShowSiteMonitor() { + val sanitizedUrl = siteMonitorUtils.sanitizeSiteUrl(site.url) + val url = urlTemplate.replace("{blog}", sanitizedUrl) + + val addressToLoad = prepareAddressToLoad(url) + postUiState(mapper.toPrepared(url, addressToLoad, siteMonitorType)) + } + + private fun prepareAddressToLoad(url: String): String { + val username = accountStore.account.userName + val accessToken = accountStore.accessToken + + var addressToLoad = url + + // Custom domains are not properly authenticated due to a server side(?) issue, so this gets around that + if (!addressToLoad.contains(WPCOM_DOMAIN)) { + val wpComSites: List = siteStore.wPComSites + for (siteModel in wpComSites) { + // Only replace the url if we know the unmapped url and if it's a custom domain + if (!TextUtils.isEmpty(siteModel.unmappedUrl) + && !siteModel.url.contains(WPCOM_DOMAIN) + ) { + addressToLoad = addressToLoad.replace(siteModel.url, siteModel.unmappedUrl) + } + } + } + return siteMonitorUtils.getAuthenticationPostData( + WPCOM_LOGIN_URL, + addressToLoad, + username, + "", + accessToken?:"" + ) + } + + private fun postUiState(state: SiteMonitorUiState) { + launch { + _uiState.value = state + } + } + + fun onUrlLoaded() { + siteMonitorUtils.trackTabLoaded(siteMonitorType) + postUiState(SiteMonitorUiState.Loaded) + } + + fun onWebViewError() { + postUiState(mapper.toGenericError(this@SiteMonitorTabViewModel::loadView)) + } + + companion object { + const val WPCOM_LOGIN_URL = "https://wordpress.com/wp-login.php" + const val WPCOM_DOMAIN = ".wordpress.com" + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/sitemonitor/SiteMonitorUiState.kt b/WordPress/src/main/java/org/wordpress/android/ui/sitemonitor/SiteMonitorUiState.kt new file mode 100644 index 000000000000..50bca3c0dec8 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/sitemonitor/SiteMonitorUiState.kt @@ -0,0 +1,58 @@ +package org.wordpress.android.ui.sitemonitor + +import org.wordpress.android.R +import org.wordpress.android.ui.utils.UiString + +sealed class SiteMonitorUiState { + object Preparing : SiteMonitorUiState() + + data class Prepared( + val model: SiteMonitorModel + ) : SiteMonitorUiState() + + object Loaded : SiteMonitorUiState() + + open class Error( + val title: UiString, + val description: UiString, + val button: ErrorButton? = null + ) : SiteMonitorUiState() { + data class ErrorButton( + val text: UiString, + val click: () -> Unit + ) + } + + data class NoNetworkError(val buttonClick: () -> Unit): Error( + title = UiString.UiStringRes(R.string.campaign_detail_no_network_error_title), + description = UiString.UiStringRes(R.string.campaign_detail_error_description), + button = ErrorButton( + text = UiString.UiStringRes(R.string.campaign_detail_error_button_text), + click = buttonClick + ) + ) + + data class GenericError(val buttonClick: () -> Unit): Error( + title = UiString.UiStringRes(R.string.campaign_detail_error_title), + description = UiString.UiStringRes(R.string.campaign_detail_error_description), + button = ErrorButton( + text = UiString.UiStringRes(R.string.campaign_detail_error_button_text), + click = buttonClick + ) + ) +} + +data class SiteMonitorModel( + val siteMonitorType: SiteMonitorType, + val enableJavascript: Boolean = true, + val enableDomStorage: Boolean = true, + val enableChromeClient: Boolean = true, + val userAgent: String = "", + val url: String, + val addressToLoad: String +) +enum class SiteMonitorType { + METRICS, + PHP_LOGS, + WEB_SERVER_LOGS +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/sitemonitor/SiteMonitorUtils.kt b/WordPress/src/main/java/org/wordpress/android/ui/sitemonitor/SiteMonitorUtils.kt new file mode 100644 index 000000000000..f4e7fe8c098d --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/sitemonitor/SiteMonitorUtils.kt @@ -0,0 +1,47 @@ +package org.wordpress.android.ui.sitemonitor + +import android.util.Log +import org.wordpress.android.WordPress +import org.wordpress.android.analytics.AnalyticsTracker +import org.wordpress.android.ui.WPWebViewActivity +import org.wordpress.android.util.analytics.AnalyticsTrackerWrapper +import javax.inject.Inject + +class SiteMonitorUtils @Inject constructor( + private val analyticsTrackerWrapper: AnalyticsTrackerWrapper +) { + fun getUserAgent() = WordPress.getUserAgent() + + fun getAuthenticationPostData(authenticationUrl: String, + urlToLoad: String, + username: String, + password: String, + token: String): String = + WPWebViewActivity.getAuthenticationPostData(authenticationUrl, urlToLoad, username, password, token) + + + fun trackActivityLaunched() { + analyticsTrackerWrapper.track(AnalyticsTracker.Stat.SITE_MONITORING_SCREEN_SHOWN) + } + + fun sanitizeSiteUrl(url: String?) = url?.replace(Regex(HTTP_PATTERN), "") ?: "" + + fun urlToType(url: String): SiteMonitorType { + return when { + url.contains(PHP_LOGS_PATTERN) -> SiteMonitorType.PHP_LOGS + url.contains(WEB_SERVER_LOGS_PATTERN) -> SiteMonitorType.WEB_SERVER_LOGS + else -> SiteMonitorType.METRICS + } + } + + fun trackTabLoaded(siteMonitorType: SiteMonitorType) { + // todo: need to set this up properly with track events + Log.i(javaClass.simpleName, "track TabLoaded with $siteMonitorType") + } + + companion object { + const val HTTP_PATTERN = "(https?://)" + const val PHP_LOGS_PATTERN = "/php" + const val WEB_SERVER_LOGS_PATTERN = "/web" + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/sitemonitor/SiteMonitorWebViewClient.kt b/WordPress/src/main/java/org/wordpress/android/ui/sitemonitor/SiteMonitorWebViewClient.kt new file mode 100644 index 000000000000..29305e0d2ea0 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/sitemonitor/SiteMonitorWebViewClient.kt @@ -0,0 +1,46 @@ +package org.wordpress.android.ui.sitemonitor + +import android.graphics.Bitmap +import android.webkit.WebResourceError +import android.webkit.WebResourceRequest +import android.webkit.WebView +import android.webkit.WebViewClient + +class SiteMonitorWebViewClient( + private val listener: SiteMonitorWebViewClientListener +) : WebViewClient() { + private var errorReceived = false + private var requestedUrl: String? = null + interface SiteMonitorWebViewClientListener { + fun onWebViewPageLoaded(url: String) + fun onWebViewReceivedError(url: String) + } + override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean { + return false + } + + override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { + super.onPageStarted(view, url, favicon) + errorReceived = false + requestedUrl = url + } + + override fun onPageFinished(view: WebView, url: String?) { + super.onPageFinished(view, url) + if (!errorReceived) { + url?.let { listener.onWebViewPageLoaded(it) } + } + } + + override fun onReceivedError(view: WebView?, request: WebResourceRequest?, error: WebResourceError?) { + super.onReceivedError(view, request, error) + // From the documentation: + // > These errors usually indicate inability to connect to the server. + // > will be called for any resource (iframe, image, etc.), not just for the main page. + // > Thus, it is recommended to perform minimum required work in this callback. + if (request?.isForMainFrame == true && requestedUrl == request.url.toString()) { + errorReceived = true + listener.onWebViewReceivedError(request.url.toString()) + } + } +} diff --git a/WordPress/src/test/java/org/wordpress/android/ui/sitemonitor/SiteMonitorParentViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/sitemonitor/SiteMonitorParentViewModelTest.kt deleted file mode 100644 index 7f2031d8b1c5..000000000000 --- a/WordPress/src/test/java/org/wordpress/android/ui/sitemonitor/SiteMonitorParentViewModelTest.kt +++ /dev/null @@ -1,36 +0,0 @@ -package org.wordpress.android.ui.sitemonitor - -import kotlinx.coroutines.ExperimentalCoroutinesApi -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.mockito.Mock -import org.mockito.junit.MockitoJUnitRunner -import org.mockito.kotlin.mock -import org.mockito.kotlin.verify -import org.wordpress.android.BaseUnitTest -import org.wordpress.android.analytics.AnalyticsTracker -import org.wordpress.android.fluxc.model.SiteModel -import org.wordpress.android.util.analytics.AnalyticsTrackerWrapper - -@ExperimentalCoroutinesApi -@RunWith(MockitoJUnitRunner::class) -class SiteMonitorParentViewModelTest: BaseUnitTest(){ - @Mock - private lateinit var analyticsTrackerWrapper: AnalyticsTrackerWrapper - - private lateinit var viewModel: SiteMonitorParentViewModel - - @Before - fun setUp() { - viewModel = SiteMonitorParentViewModel(testDispatcher(), analyticsTrackerWrapper) - } - - @Test - fun `when viewmodel is started, then screen shown tracking is done`() { - val site = mock() - viewModel.start(site) - - verify(analyticsTrackerWrapper).track(AnalyticsTracker.Stat.SITE_MONITORING_SCREEN_SHOWN) - } -}