diff --git a/WordPress/src/main/java/org/wordpress/android/ui/mysite/personalization/PersonalizationActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/mysite/personalization/PersonalizationActivity.kt index 94e60930694d..ac9ab5de738d 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/mysite/personalization/PersonalizationActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/mysite/personalization/PersonalizationActivity.kt @@ -107,7 +107,7 @@ class PersonalizationActivity : AppCompatActivity() { contentColor = MaterialTheme.colors.onSurface, ) { tabs.forEachIndexed { index, title -> - Tab(text = { Text(stringResource(id = title)) }, + Tab(text = { Text(stringResource(id = title).uppercase()) }, selected = tabIndex == index, onClick = { tabIndex = index } ) 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 deleted file mode 100644 index eb5eea65c8a1..000000000000 --- a/WordPress/src/main/java/org/wordpress/android/ui/sitemonitor/SiteMonitorFragmentContainer.kt +++ /dev/null @@ -1,76 +0,0 @@ -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/SiteMonitorParentActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/sitemonitor/SiteMonitorParentActivity.kt index 9a9c52b4920b..c1773d48c83e 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 @@ -1,62 +1,121 @@ package org.wordpress.android.ui.sitemonitor import android.annotation.SuppressLint +import android.os.Build import android.os.Bundle import android.util.SparseArray +import android.view.View +import android.view.ViewGroup +import android.webkit.WebView import androidx.activity.compose.setContent +import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity +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.foundation.lazy.LazyColumn +import androidx.compose.material.Button +import androidx.compose.material.MaterialTheme import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Surface +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.TabRowDefaults +import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView 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 import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.ui.WPWebViewActivity import org.wordpress.android.ui.compose.components.MainTopAppBar import org.wordpress.android.ui.compose.components.NavigationIcons import org.wordpress.android.ui.compose.theme.AppTheme +import org.wordpress.android.ui.compose.utils.uiStringText import org.wordpress.android.util.extensions.getSerializableExtraCompat import javax.inject.Inject +@SuppressLint("SetJavaScriptEnabled") @AndroidEntryPoint -class SiteMonitorParentActivity: AppCompatActivity() { +class SiteMonitorParentActivity : AppCompatActivity(), SiteMonitorWebViewClient.SiteMonitorWebViewClientListener { @Inject lateinit var siteMonitorUtils: SiteMonitorUtils private var savedStateSparseArray = SparseArray() private var currentSelectItemId = 0 + private val siteMonitorParentViewModel: SiteMonitorParentViewModel by viewModels() + + private val metricsWebView by lazy { + commonWebView(SiteMonitorType.METRICS) + } + + private val phpLogsWebView by lazy { + commonWebView(SiteMonitorType.PHP_LOGS) + } + + private val webServerLogsWebView by lazy { + commonWebView(SiteMonitorType.WEB_SERVER_LOGS) + } + + private fun commonWebView( + siteMonitorType: SiteMonitorType + ) = WebView(this@SiteMonitorParentActivity).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + scrollBarStyle = View.SCROLLBARS_INSIDE_OVERLAY + settings.userAgentString = siteMonitorUtils.getUserAgent() + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + webViewClient = SiteMonitorWebViewClient(this@SiteMonitorParentActivity, siteMonitorType) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + // not sure about this one, double check if this works as expected + settings.isAlgorithmicDarkeningAllowed = true + } + } + @Suppress("DEPRECATION") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - siteMonitorUtils.trackActivityLaunched() - if (savedInstanceState != null) { savedStateSparseArray = savedInstanceState.getSparseParcelableArray( SAVED_STATE_CONTAINER_KEY ) ?: savedStateSparseArray currentSelectItemId = savedInstanceState.getInt(SAVED_STATE_CURRENT_TAB_KEY) + siteMonitorParentViewModel.loadData() + } else { + siteMonitorParentViewModel.start(getSite()) + currentSelectItemId = getInitialTab() } setContent { AppTheme { Surface( modifier = Modifier.fillMaxSize(), ) { - SiteMonitorScreen() + SiteMonitorScreen(initialTab = currentSelectItemId) } } } @@ -72,9 +131,14 @@ class SiteMonitorParentActivity: AppCompatActivity() { return requireNotNull(intent.getSerializableExtraCompat(WordPress.SITE)) as SiteModel } - private fun getInitialTab(): SiteMonitorType { - return intent?.getSerializableExtraCompat(ARG_SITE_MONITOR_TYPE_KEY) as SiteMonitorType? + private fun getInitialTab(): Int { + val tab = intent?.getSerializableExtraCompat(ARG_SITE_MONITOR_TYPE_KEY) as SiteMonitorType? ?: SiteMonitorType.METRICS + return when (tab) { + SiteMonitorType.METRICS -> 0 + SiteMonitorType.PHP_LOGS -> 1 + SiteMonitorType.WEB_SERVER_LOGS -> 2 + } } companion object { @@ -85,8 +149,7 @@ class SiteMonitorParentActivity: AppCompatActivity() { @Composable @SuppressLint("UnusedMaterialScaffoldPaddingParameter") - fun SiteMonitorScreen() { - var selectedTab by rememberSaveable { mutableStateOf(SiteMonitorTabItem.Metrics.route) } + fun SiteMonitorScreen(initialTab: Int, modifier: Modifier = Modifier) { Scaffold( topBar = { MainTopAppBar( @@ -94,59 +157,171 @@ class SiteMonitorParentActivity: AppCompatActivity() { navigationIcon = NavigationIcons.BackIcon, onNavigationIconClick = onBackPressedDispatcher::onBackPressed, ) + }, + content = { + SiteMonitorHeader(initialTab, modifier = modifier) } - ) { padding -> - Column(modifier = Modifier.padding(padding)) { - SiteMonitorTabHeader { clickTab -> - selectedTab = clickTab + ) + } + + @Composable + @SuppressLint("UnusedMaterialScaffoldPaddingParameter") + fun SiteMonitorHeader(initialTab: Int, modifier: Modifier = Modifier) { + var tabIndex by remember { mutableStateOf(initialTab) } + + val tabs = SiteMonitorTabItem.entries + + LaunchedEffect(true) { + siteMonitorUtils.trackTabLoaded(tabs[initialTab].siteMonitorType) + } + + Column(modifier = modifier.fillMaxWidth()) { + TabRow( + selectedTabIndex = tabIndex, + containerColor = MaterialTheme.colors.surface, + contentColor = MaterialTheme.colors.onSurface, + indicator = { tabPositions -> + // Customizing the indicator color and style + TabRowDefaults.Indicator( + Modifier.tabIndicatorOffset(tabPositions[tabIndex]), + color = MaterialTheme.colors.onSurface, + height = 2.0.dp + ) } - SiteMonitorTabNavigation(selectedTab) { selectedTab -> - val item = enumValues().find { - it.route == selectedTab - } ?: initialItem(getInitialTab()) - - siteMonitorUtils.trackTabLoaded(item.siteMonitorType) - - SiteMonitorFragmentContainer( - modifier = Modifier.fillMaxSize(), - commit = getCommitFunction( - SiteMonitorTabFragment.newInstance(item.urlTemplate, item.siteMonitorType, getSite()), - item.route - ) + ) { + tabs.forEachIndexed { index, item -> + Tab( + text = { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = stringResource(item.title).uppercase(), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + }, + selected = tabIndex == index, + onClick = { + siteMonitorUtils.trackTabLoaded(tabs[index].siteMonitorType) + tabIndex = index + }, ) } } + when (tabIndex) { + 0 -> SiteMonitorTabContent(SiteMonitorType.METRICS) + 1 -> SiteMonitorTabContent(SiteMonitorType.PHP_LOGS) + 2 -> SiteMonitorTabContent(SiteMonitorType.WEB_SERVER_LOGS) + } } } - private fun initialItem(type: SiteMonitorType): SiteMonitorTabItem { - return enumValues().find { - it.siteMonitorType == type - } ?: SiteMonitorTabItem.Metrics + @Composable + private fun SiteMonitorTabContent(tabType: SiteMonitorType, modifier: Modifier = Modifier) { + val uiState by remember(key1 = tabType) { + siteMonitorParentViewModel.getUiState(tabType) + } + LazyColumn { + item { + when (uiState) { + is SiteMonitorUiState.Preparing -> LoadingState(modifier) + is SiteMonitorUiState.Prepared, is SiteMonitorUiState.Loaded -> + SiteMonitorWebView(uiState, tabType, modifier) + is SiteMonitorUiState.Error -> SiteMonitorError(uiState as SiteMonitorUiState.Error, modifier) + } + } + } + } + @Composable + fun LoadingState(modifier: Modifier = Modifier) { + Box( + contentAlignment = Alignment.Center, + modifier = modifier.fillMaxSize() + ) { + CircularProgressIndicator() + } } - private fun getCommitFunction( - fragment : Fragment, - tag: String - ): FragmentTransaction.(containerId: Int) -> Unit = - { - saveAndRetrieveFragment(supportFragmentManager, it, fragment) - replace(it, fragment, tag) + @Composable + fun SiteMonitorError(error: SiteMonitorUiState.Error, modifier: Modifier = Modifier) { + 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 + ) { + Text(text = uiStringText(uiString = error.button.text)) + } + } } + } - private fun saveAndRetrieveFragment( - supportFragmentManager: FragmentManager, - tabId: Int, - fragment: Fragment + @SuppressLint("SetJavaScriptEnabled") + @Composable + private fun SiteMonitorWebView( + uiState: SiteMonitorUiState, + tabType: SiteMonitorType, + modifier: Modifier = Modifier ) { - val currentFragment = supportFragmentManager.findFragmentById(currentSelectItemId) - if (currentFragment != null) { - savedStateSparseArray.put( - currentSelectItemId, - supportFragmentManager.saveFragmentInstanceState(currentFragment) - ) + // retrieve the webview from the actvity + var webView = when (tabType) { + SiteMonitorType.METRICS -> metricsWebView + SiteMonitorType.PHP_LOGS -> phpLogsWebView + SiteMonitorType.WEB_SERVER_LOGS -> webServerLogsWebView } - currentSelectItemId = tabId - fragment.setInitialSavedState(savedStateSparseArray[currentSelectItemId]) + + if (uiState is SiteMonitorUiState.Prepared) { + webView.postUrl(WPWebViewActivity.WPCOM_LOGIN_URL, uiState.model.addressToLoad.toByteArray()) + } + + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + if (uiState is SiteMonitorUiState.Prepared) { + LoadingState() + } else { + webView.let { theWebView -> + AndroidView( + factory = { theWebView }, + update = { webView = it }, + modifier = Modifier.fillMaxWidth() + ) + } + } + } + } + + override fun onDestroy() { + super.onDestroy() + metricsWebView.destroy() + phpLogsWebView.destroy() + webServerLogsWebView.destroy() + } + + override fun onWebViewPageLoaded(url: String, tabType: SiteMonitorType) = + siteMonitorParentViewModel.onUrlLoaded(tabType) + + override fun onWebViewReceivedError(url: String, tabType: SiteMonitorType) { + siteMonitorParentViewModel.onWebViewError(tabType) + siteMonitorUtils.trackTabLoadingError(tabType) } } 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 new file mode 100644 index 000000000000..2a18f07e1fb2 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/sitemonitor/SiteMonitorParentViewModel.kt @@ -0,0 +1,95 @@ +package org.wordpress.android.ui.sitemonitor + +import androidx.compose.runtime.State +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineDispatcher +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.modules.BG_THREAD +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 siteMonitorUtils: SiteMonitorUtils, + private val metricsViewModel: SiteMonitorTabViewModelSlice, + private val phpLogViewModel: SiteMonitorTabViewModelSlice, + private val webServerViewModel: SiteMonitorTabViewModelSlice +) : ScopedViewModel(bgDispatcher) { + private lateinit var site: SiteModel + + init { + metricsViewModel.initialize(viewModelScope) + phpLogViewModel.initialize(viewModelScope) + webServerViewModel.initialize(viewModelScope) + } + + fun start(site: SiteModel) { + this.site = site + siteMonitorUtils.trackActivityLaunched() + loadData() + } + + fun loadData() { + metricsViewModel.start(SiteMonitorType.METRICS, SiteMonitorTabItem.Metrics.urlTemplate, site) + phpLogViewModel.start(SiteMonitorType.PHP_LOGS, SiteMonitorTabItem.PHPLogs.urlTemplate, site) + webServerViewModel.start(SiteMonitorType.WEB_SERVER_LOGS, SiteMonitorTabItem.WebServerLogs.urlTemplate, site) + } + + fun getUiState(siteMonitorType: SiteMonitorType): State { + return when (siteMonitorType) { + SiteMonitorType.METRICS -> { + metricsViewModel.uiState + } + + SiteMonitorType.PHP_LOGS -> { + phpLogViewModel.uiState + } + + SiteMonitorType.WEB_SERVER_LOGS -> { + webServerViewModel.uiState + } + } + } + + fun onUrlLoaded(siteMonitorType: SiteMonitorType) { + when (siteMonitorType) { + SiteMonitorType.METRICS -> { + metricsViewModel.onUrlLoaded() + } + + SiteMonitorType.PHP_LOGS -> { + phpLogViewModel.onUrlLoaded() + } + + SiteMonitorType.WEB_SERVER_LOGS -> { + webServerViewModel.onUrlLoaded() + } + } + } + + fun onWebViewError(siteMonitorType: SiteMonitorType) { + when (siteMonitorType) { + SiteMonitorType.METRICS -> { + metricsViewModel.onWebViewError() + } + + SiteMonitorType.PHP_LOGS -> { + phpLogViewModel.onWebViewError() + } + + SiteMonitorType.WEB_SERVER_LOGS -> { + webServerViewModel.onWebViewError() + } + } + } + + override fun onCleared() { + super.onCleared() + metricsViewModel.onCleared() + phpLogViewModel.onCleared() + webServerViewModel.onCleared() + } +} 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 deleted file mode 100644 index 8fb61cc8ed03..000000000000 --- a/WordPress/src/main/java/org/wordpress/android/ui/sitemonitor/SiteMonitorTabFragment.kt +++ /dev/null @@ -1,184 +0,0 @@ -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 deleted file mode 100644 index 7a15059a31fc..000000000000 --- a/WordPress/src/main/java/org/wordpress/android/ui/sitemonitor/SiteMonitorTabHeader.kt +++ /dev/null @@ -1,61 +0,0 @@ -package org.wordpress.android.ui.sitemonitor - -import androidx.compose.foundation.layout.Column -import androidx.compose.material.MaterialTheme -import androidx.compose.material3.Tab -import androidx.compose.material3.TabRow -import androidx.compose.material3.TabRowDefaults -import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset -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.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp - -@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.colors.surface, - contentColor = MaterialTheme.colors.onSurface, - indicator = { tabPositions -> - // Customizing the indicator color and style - TabRowDefaults.Indicator( - Modifier.tabIndicatorOffset(tabPositions[selectedTabIndex]), - color = MaterialTheme.colors.onSurface, - height = 2.0.dp - ) - } - ) { - tabs.forEachIndexed { index, item -> - Tab( - text = { - Column (horizontalAlignment = Alignment.CenterHorizontally) { - Text( - text = stringResource(item.title), - 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/SiteMonitorTabNavigation.kt b/WordPress/src/main/java/org/wordpress/android/ui/sitemonitor/SiteMonitorTabNavigation.kt deleted file mode 100644 index 023e651a66d5..000000000000 --- a/WordPress/src/main/java/org/wordpress/android/ui/sitemonitor/SiteMonitorTabNavigation.kt +++ /dev/null @@ -1,20 +0,0 @@ -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/SiteMonitorTabViewModelSlice.kt similarity index 77% rename from WordPress/src/main/java/org/wordpress/android/ui/sitemonitor/SiteMonitorTabViewModel.kt rename to WordPress/src/main/java/org/wordpress/android/ui/sitemonitor/SiteMonitorTabViewModelSlice.kt index 275c65087676..45ec9a56d76f 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/sitemonitor/SiteMonitorTabViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/sitemonitor/SiteMonitorTabViewModelSlice.kt @@ -1,34 +1,36 @@ package org.wordpress.android.ui.sitemonitor import android.text.TextUtils -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch 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, +class SiteMonitorTabViewModelSlice @Inject constructor( 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 scope: CoroutineScope + 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 + private val _uiState = mutableStateOf(SiteMonitorUiState.Preparing) + val uiState: State = _uiState + + fun initialize(scope: CoroutineScope) { + this.scope = scope + } fun start(type: SiteMonitorType, urlTemplate: String, site: SiteModel) { this.siteMonitorType = type @@ -50,13 +52,13 @@ class SiteMonitorTabViewModel @Inject constructor( private fun checkForInternetConnectivityAndPostErrorIfNeeded() : Boolean { if (networkUtilsWrapper.isNetworkAvailable()) return true - postUiState(mapper.toNoNetworkError(this@SiteMonitorTabViewModel::loadView)) + postUiState(mapper.toNoNetworkError(::loadView)) return false } private fun validateAndPostErrorIfNeeded(): Boolean { if (accountStore.account.userName.isNullOrEmpty() || accountStore.accessToken.isNullOrEmpty()) { - postUiState(mapper.toGenericError(this@SiteMonitorTabViewModel::loadView)) + postUiState(mapper.toGenericError(this::loadView)) return false } return true @@ -98,17 +100,23 @@ class SiteMonitorTabViewModel @Inject constructor( } private fun postUiState(state: SiteMonitorUiState) { - launch { + scope.launch { _uiState.value = state } } fun onUrlLoaded() { - postUiState(SiteMonitorUiState.Loaded) + if (uiState.value is SiteMonitorUiState.Prepared){ + postUiState(SiteMonitorUiState.Loaded) + } } fun onWebViewError() { - postUiState(mapper.toGenericError(this@SiteMonitorTabViewModel::loadView)) + postUiState(mapper.toGenericError(::loadView)) + } + + fun onCleared() { + scope.cancel() } companion object { 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 index a4b25ed2a1c7..a64e92029030 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/sitemonitor/SiteMonitorUtils.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/sitemonitor/SiteMonitorUtils.kt @@ -33,6 +33,14 @@ class SiteMonitorUtils @Inject constructor( )) } + fun trackTabLoadingError(siteMonitorType: SiteMonitorType) { + analyticsTrackerWrapper.track( + AnalyticsTracker.Stat.SITE_MONITORING_TAB_LOADING_ERROR, + mapOf( + TAB_TRACK_KEY to siteMonitorType.analyticsDescription + )) + } + companion object { const val HTTP_PATTERN = "(https?://)" const val PHP_LOGS_PATTERN = "/php" 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 index 29305e0d2ea0..af0717e8481b 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/sitemonitor/SiteMonitorWebViewClient.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/sitemonitor/SiteMonitorWebViewClient.kt @@ -7,13 +7,15 @@ import android.webkit.WebView import android.webkit.WebViewClient class SiteMonitorWebViewClient( - private val listener: SiteMonitorWebViewClientListener + private val listener: SiteMonitorWebViewClientListener, + private val tabType: SiteMonitorType ) : WebViewClient() { private var errorReceived = false private var requestedUrl: String? = null + interface SiteMonitorWebViewClientListener { - fun onWebViewPageLoaded(url: String) - fun onWebViewReceivedError(url: String) + fun onWebViewPageLoaded(url: String, tabType: SiteMonitorType) + fun onWebViewReceivedError(url: String, tabType: SiteMonitorType) } override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean { return false @@ -28,7 +30,7 @@ class SiteMonitorWebViewClient( override fun onPageFinished(view: WebView, url: String?) { super.onPageFinished(view, url) if (!errorReceived) { - url?.let { listener.onWebViewPageLoaded(it) } + url?.let { listener.onWebViewPageLoaded(it, tabType) } } } @@ -40,7 +42,7 @@ class SiteMonitorWebViewClient( // > 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()) + listener.onWebViewReceivedError(request.url.toString(), tabType) } } } diff --git a/WordPress/src/test/java/org/wordpress/android/ui/sitemonitor/SiteMonitorMapperTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/sitemonitor/SiteMonitorMapperTest.kt new file mode 100644 index 000000000000..d9b349bc2873 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/sitemonitor/SiteMonitorMapperTest.kt @@ -0,0 +1,58 @@ +package org.wordpress.android.ui.sitemonitor + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito.mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.whenever +import org.wordpress.android.BaseUnitTest + +@ExperimentalCoroutinesApi +@RunWith(MockitoJUnitRunner::class) +class SiteMonitorMapperTest : BaseUnitTest() { + @Mock + lateinit var siteMonitorUtils: SiteMonitorUtils + + private lateinit var siteMonitorMapper: SiteMonitorMapper + + @Before + fun setup() { + siteMonitorMapper = SiteMonitorMapper(siteMonitorUtils) + } + + @Test + fun `given prepared request, when mapper is called, then site monitor model is created`() { + whenever(siteMonitorUtils.getUserAgent()).thenReturn(USER_AGENT) + + val state = siteMonitorMapper.toPrepared(URL, ADDRESS_TO_LOAD, SiteMonitorType.METRICS) + + assertThat(state.model.siteMonitorType).isEqualTo(SiteMonitorType.METRICS) + assertThat(state.model.url).isEqualTo(URL) + assertThat(state.model.addressToLoad).isEqualTo(ADDRESS_TO_LOAD) + assertThat(state.model.userAgent).isEqualTo(USER_AGENT) + } + + @Test + fun `given network error, when mapper is called, then NoNetwork error is created`() { + val state = siteMonitorMapper.toNoNetworkError(mock()) + + assertThat(state).isInstanceOf(SiteMonitorUiState.NoNetworkError::class.java) + } + + @Test + fun `given generic error error, when mapper is called, then Generic error is created`() { + val state = siteMonitorMapper.toGenericError(mock()) + + assertThat(state).isInstanceOf(SiteMonitorUiState.GenericError::class.java) + } + + companion object { + const val USER_AGENT = "user_agent" + const val URL = "url" + const val ADDRESS_TO_LOAD = "address_to_load" + } +} 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 new file mode 100644 index 000000000000..411f72a9a442 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/sitemonitor/SiteMonitorParentViewModelTest.kt @@ -0,0 +1,170 @@ +package org.wordpress.android.ui.sitemonitor + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.assertj.core.api.Assertions.assertThat +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.any +import org.mockito.kotlin.clearInvocations +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.BaseUnitTest +import org.wordpress.android.fluxc.model.SiteModel + +@ExperimentalCoroutinesApi +@RunWith(MockitoJUnitRunner::class) +class SiteMonitorParentViewModelTest: BaseUnitTest(){ + @Mock + private lateinit var siteMonitorUtils: SiteMonitorUtils + @Mock + private lateinit var metricsViewModel: SiteMonitorTabViewModelSlice + @Mock + private lateinit var phpLogViewModel: SiteMonitorTabViewModelSlice + @Mock + private lateinit var webServerViewModel: SiteMonitorTabViewModelSlice + + private lateinit var viewModel: SiteMonitorParentViewModel + + @Before + fun setUp() { + viewModel = SiteMonitorParentViewModel( + testDispatcher(), + siteMonitorUtils, + metricsViewModel, + phpLogViewModel, + webServerViewModel + ) + } + + @Test + fun `when viewmodel is started, then track screen shown`() { + val site = mock() + viewModel.start(site) + + verify(siteMonitorUtils).trackActivityLaunched() + } + + @Test + fun `when viewmodel is created, then view model slices are initialized`() { + verify(metricsViewModel).initialize(any()) + verify(phpLogViewModel).initialize(any()) + verify(webServerViewModel).initialize(any()) + } + + @Test + fun `when start is invoked, then view models are started with the correct tab item`() { + val site = mock() + viewModel.start(site) + + verify(metricsViewModel).start(SiteMonitorType.METRICS, SiteMonitorTabItem.Metrics.urlTemplate, site) + verify(phpLogViewModel).start(SiteMonitorType.PHP_LOGS, SiteMonitorTabItem.PHPLogs.urlTemplate, site) + verify(webServerViewModel).start( + SiteMonitorType.WEB_SERVER_LOGS, + SiteMonitorTabItem.WebServerLogs.urlTemplate, + site + ) + } + + @Test + fun `when loadData is invoked, then view models are started with the correct tab item`() { + val site = mock() + viewModel.start(site) + + clearInvocations(metricsViewModel, phpLogViewModel, webServerViewModel) + + viewModel.loadData() + + verify(metricsViewModel).start(SiteMonitorType.METRICS, SiteMonitorTabItem.Metrics.urlTemplate, site) + verify(phpLogViewModel).start(SiteMonitorType.PHP_LOGS, SiteMonitorTabItem.PHPLogs.urlTemplate, site) + verify(webServerViewModel).start( + SiteMonitorType.WEB_SERVER_LOGS, + SiteMonitorTabItem.WebServerLogs.urlTemplate, + site + ) + } + + @Test + fun `given metrics, when getUiState is invoked, then ui state is returned`() { + whenever(metricsViewModel.uiState).thenReturn(mock()) + val site = mock() + viewModel.start(site) + + advanceUntilIdle() + + val state = viewModel.getUiState(SiteMonitorType.METRICS) + + assertThat(state).isNotNull + } + + @Test + fun `given phplogs, when getUiState is invoked, then ui state is returned`() { + whenever(phpLogViewModel.uiState).thenReturn(mock()) + val site = mock() + viewModel.start(site) + + advanceUntilIdle() + + val state = viewModel.getUiState(SiteMonitorType.PHP_LOGS) + + assertThat(state).isNotNull + } + + @Test + fun `given webserver logs, when getUiState is invoked, then ui state is returned`() { + whenever(webServerViewModel.uiState).thenReturn(mock()) + val site = mock() + viewModel.start(site) + + advanceUntilIdle() + + val state = viewModel.getUiState(SiteMonitorType.WEB_SERVER_LOGS) + + assertThat(state).isNotNull + } + + @Test + fun `given metrics, when onUrlLoaded is invoked, then metric vm slice onUrlLoaded is invoked`() { + viewModel.onUrlLoaded(SiteMonitorType.METRICS) + + verify(metricsViewModel).onUrlLoaded() + } + + @Test + fun `given php logs, when onUrlLoaded is invoked, then php logs vm slice onUrlLoaded is invoked`() { + viewModel.onUrlLoaded(SiteMonitorType.PHP_LOGS) + + verify(phpLogViewModel).onUrlLoaded() + } + + @Test + fun `given webserver logs, when onUrlLoaded is invoked, then webserver logs vm slice onUrlLoaded is invoked`() { + viewModel.onUrlLoaded(SiteMonitorType.WEB_SERVER_LOGS) + + verify(webServerViewModel).onUrlLoaded() + } + + @Test + fun `given metrics, when onWebViewError is invoked, then metric vm slice onWebViewError is invoked`() { + viewModel.onWebViewError(SiteMonitorType.METRICS) + + verify(metricsViewModel).onWebViewError() + } + + @Test + fun `given php logs, when onWebViewError is invoked, then php logs vm slice onWebViewError is invoked`() { + viewModel.onWebViewError(SiteMonitorType.PHP_LOGS) + + verify(phpLogViewModel).onWebViewError() + } + + @Test + fun `given webserver logs, when onWebViewError is invoked, then webserver vm slice onWebViewError is invoked`() { + viewModel.onWebViewError(SiteMonitorType.WEB_SERVER_LOGS) + + verify(webServerViewModel).onWebViewError() + } +} diff --git a/WordPress/src/test/java/org/wordpress/android/ui/sitemonitor/SiteMonitorTabViewModelSliceTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/sitemonitor/SiteMonitorTabViewModelSliceTest.kt new file mode 100644 index 000000000000..3fe113c67fb7 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/sitemonitor/SiteMonitorTabViewModelSliceTest.kt @@ -0,0 +1,127 @@ +package org.wordpress.android.ui.sitemonitor + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.mockito.Mock +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.wordpress.android.BaseUnitTest +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.util.NetworkUtilsWrapper + +@ExperimentalCoroutinesApi +class SiteMonitorTabViewModelSliceTest : BaseUnitTest() { + @Mock + private lateinit var networkUtilsWrapper: NetworkUtilsWrapper + @Mock + private lateinit var accountStore: AccountStore + @Mock + private lateinit var mapper: SiteMonitorMapper + @Mock + private lateinit var siteMonitorUtils: SiteMonitorUtils + @Mock + private lateinit var siteStore: SiteStore + + private lateinit var viewModel: SiteMonitorTabViewModelSlice + + val site = mock() + + @Before + fun setUp() = test { + viewModel = SiteMonitorTabViewModelSlice( + networkUtilsWrapper, + accountStore, + mapper, + siteMonitorUtils, + siteStore + ) + + whenever(accountStore.account).thenReturn(mock()) + whenever(accountStore.account.userName).thenReturn(USER_NAME) + whenever(accountStore.accessToken).thenReturn(ACCESS_TOKEN) + + whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(true) + whenever(mapper.toGenericError(any())).thenReturn(mock()) + whenever(mapper.toNoNetworkError(any())).thenReturn(mock()) + whenever(mapper.toPrepared(any(), any(), any())).thenReturn(mock()) + + whenever(site.url).thenReturn(URL) + whenever(siteMonitorUtils.sanitizeSiteUrl(any())).thenReturn(URL) + whenever(siteMonitorUtils.getAuthenticationPostData(any(), any(), any(), any(), any())).thenReturn(URL) + + viewModel.initialize(testScope()) + } + + @Test + fun `when slice is instantiated, then uiState is in preparing`() { + assertThat(viewModel.uiState.value).isEqualTo(SiteMonitorUiState.Preparing) + } + + @Test + fun `given loadView(), when slice is started, then uiState is in prepared`() = test { + viewModel.start(SiteMonitorType.METRICS, SiteMonitorTabItem.Metrics.urlTemplate, site) + + assertThat(viewModel.uiState.value).isInstanceOf(SiteMonitorUiState.Prepared::class.java) + } + + @Test + fun `given null username, when slice is started, then uiState is in toGenericError`() { + whenever(accountStore.account.userName).thenReturn(null) + + viewModel.start(SiteMonitorType.METRICS, SiteMonitorTabItem.Metrics.urlTemplate, site) + + assertThat(viewModel.uiState.value).isInstanceOf(SiteMonitorUiState.GenericError::class.java) + } + + @Test + fun `given null accessToken, when slice is started, then uiState is in toGenericError`() { + whenever(accountStore.accessToken).thenReturn(null) + + viewModel.start(SiteMonitorType.METRICS, SiteMonitorTabItem.Metrics.urlTemplate, site) + + assertThat(viewModel.uiState.value).isInstanceOf(SiteMonitorUiState.GenericError::class.java) + } + + @Test + fun `given no network, when slice is started, then uiState is in error`() { + whenever(networkUtilsWrapper.isNetworkAvailable()).thenReturn(false) + + viewModel.start(SiteMonitorType.METRICS, SiteMonitorTabItem.Metrics.urlTemplate, site) + + assertThat(viewModel.uiState.value).isInstanceOf(SiteMonitorUiState.NoNetworkError::class.java) + } + + @Test + fun `given prepared state, when url is loaded, then uiState loaded is posted`() = test { + viewModel.start(SiteMonitorType.METRICS, SiteMonitorTabItem.Metrics.urlTemplate, site) + advanceUntilIdle() + viewModel.onUrlLoaded() + + assertThat(viewModel.uiState.value).isInstanceOf(SiteMonitorUiState.Loaded::class.java) + } + + @Test + fun `given preparing state, when url is loaded, then uiState loaded is not posted`() = test { + viewModel.onUrlLoaded() + + assertThat(viewModel.uiState.value).isInstanceOf(SiteMonitorUiState.Preparing::class.java) + } + + @Test + fun `when web view error, then error state is posted`() = test { + viewModel.onWebViewError() + + assertThat(viewModel.uiState.value).isInstanceOf(SiteMonitorUiState.GenericError::class.java) + } + + companion object { + const val USER_NAME = "user_name" + const val ACCESS_TOKEN = "access_token" + const val URL = "test.wordpress.com" + } +} diff --git a/WordPress/src/test/java/org/wordpress/android/ui/sitemonitor/SiteMonitorUtilsTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/sitemonitor/SiteMonitorUtilsTest.kt new file mode 100644 index 000000000000..0bf5076d32d5 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/sitemonitor/SiteMonitorUtilsTest.kt @@ -0,0 +1,92 @@ +package org.wordpress.android.ui.sitemonitor + +import junit.framework.TestCase.assertEquals +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.verify +import org.wordpress.android.analytics.AnalyticsTracker +import org.wordpress.android.util.analytics.AnalyticsTrackerWrapper + +@RunWith(MockitoJUnitRunner::class) +class SiteMonitorUtilsTest { + @Mock + lateinit var analyticsTrackerWrapper: AnalyticsTrackerWrapper + + private lateinit var siteMonitorUtils: SiteMonitorUtils + + @Before + fun setup() { + siteMonitorUtils = SiteMonitorUtils(analyticsTrackerWrapper) + } + + @Test + fun `when activity is launched, then event is tracked`() { + siteMonitorUtils.trackActivityLaunched() + + verify(analyticsTrackerWrapper).track(AnalyticsTracker.Stat.SITE_MONITORING_SCREEN_SHOWN) + } + + @Test + fun `given url matches pattern, when sanitize is requested, then url is sanitized`() { + val result = siteMonitorUtils.sanitizeSiteUrl("http://example.com") + + assertEquals("example.com", result) + } + + @Test + fun `given url is null, when sanitize is requested, then url is empty`() { + val result = siteMonitorUtils.sanitizeSiteUrl(null) + + assertEquals("", result) + } + + @Test + fun `given url does not match pattern, when sanitize is requested, then url is not sanitized`() { + val url = "gibberish" + val result = siteMonitorUtils.sanitizeSiteUrl(url) + + assertEquals(url, result) + } + + @Test + fun `when metrics tab is launched, then event is tracked`() { + siteMonitorUtils.trackTabLoaded(SiteMonitorType.METRICS) + + // Verify that the correct method was called on the analyticsTrackerWrapper + verify(analyticsTrackerWrapper).track( + AnalyticsTracker.Stat.SITE_MONITORING_TAB_SHOWN, + mapOf( + SiteMonitorUtils.TAB_TRACK_KEY to SiteMonitorType.METRICS.analyticsDescription + ) + ) + } + + @Test + fun `when php logs tab is launched, then event is tracked`() { + siteMonitorUtils.trackTabLoaded(SiteMonitorType.PHP_LOGS) + + // Verify that the correct method was called on the analyticsTrackerWrapper + verify(analyticsTrackerWrapper).track( + AnalyticsTracker.Stat.SITE_MONITORING_TAB_SHOWN, + mapOf( + SiteMonitorUtils.TAB_TRACK_KEY to SiteMonitorType.PHP_LOGS.analyticsDescription + ) + ) + } + + @Test + fun `when web server logs tab is launched, then event is tracked`() { + siteMonitorUtils.trackTabLoaded(SiteMonitorType.WEB_SERVER_LOGS) + + // Verify that the correct method was called on the analyticsTrackerWrapper + verify(analyticsTrackerWrapper).track( + AnalyticsTracker.Stat.SITE_MONITORING_TAB_SHOWN, + mapOf( + SiteMonitorUtils.TAB_TRACK_KEY to SiteMonitorType.WEB_SERVER_LOGS.analyticsDescription + ) + ) + } +} diff --git a/WordPress/src/test/java/org/wordpress/android/ui/sitemonitor/SiteMonitorWebViewClientTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/sitemonitor/SiteMonitorWebViewClientTest.kt new file mode 100644 index 000000000000..38b11dbd998d --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/sitemonitor/SiteMonitorWebViewClientTest.kt @@ -0,0 +1,70 @@ +package org.wordpress.android.ui.sitemonitor + +import android.net.Uri +import android.webkit.WebResourceRequest +import android.webkit.WebView +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.junit.Before +import org.junit.Test +import org.mockito.Mock +import org.mockito.MockitoAnnotations +import org.mockito.kotlin.any +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.wordpress.android.BaseUnitTest +import android.webkit.WebResourceError +import org.mockito.ArgumentMatchers.anyString +import org.mockito.Mockito.mock +import org.mockito.kotlin.never + +@ExperimentalCoroutinesApi +class SiteMonitorWebViewClientTest : BaseUnitTest() { + @Mock + private lateinit var mockListener: SiteMonitorWebViewClient.SiteMonitorWebViewClientListener + + @Mock + private lateinit var uri: Uri + + private lateinit var webViewClient: SiteMonitorWebViewClient + + @Before + fun setup() { + MockitoAnnotations.openMocks(this) + webViewClient = SiteMonitorWebViewClient(mockListener, SiteMonitorType.METRICS) + } + + @Test + fun `when onPageFinished, then should invoke on web view page loaded`() { + webViewClient.onPageFinished(mock(WebView::class.java), "https://example.com") + + verify(mockListener).onWebViewPageLoaded("https://example.com", SiteMonitorType.METRICS) + } + + @Test + fun `when onReceivedError, then should invoke on web view error received`() { + val mockRequest = mock(WebResourceRequest::class.java) + whenever(mockRequest.isForMainFrame).thenReturn(true) + val url = "https://some.domain" + whenever(uri.toString()).thenReturn(url) + whenever(mockRequest.url).thenReturn(uri) + + webViewClient.onPageStarted(mock(WebView::class.java), url, null) + webViewClient.onReceivedError( + mock(WebView::class.java), + mockRequest, + mock(WebResourceError::class.java) + ) + + verify(mockListener).onWebViewReceivedError(url, SiteMonitorType.METRICS) + } + + @Test + fun `when onPageFinished, then should not invoke OnReceivedError`() { + val url = "https://some.domain" + + webViewClient.onPageFinished(mock(WebView::class.java), url) + + verify(mockListener, never()).onWebViewReceivedError(anyString(), any()) + } +} + diff --git a/libs/analytics/src/main/java/org/wordpress/android/analytics/AnalyticsTracker.java b/libs/analytics/src/main/java/org/wordpress/android/analytics/AnalyticsTracker.java index 4ecc68844c66..9c93b7293a73 100644 --- a/libs/analytics/src/main/java/org/wordpress/android/analytics/AnalyticsTracker.java +++ b/libs/analytics/src/main/java/org/wordpress/android/analytics/AnalyticsTracker.java @@ -1104,6 +1104,7 @@ public enum Stat { SITE_MONITORING_SCREEN_SHOWN, OPENED_SITE_MONITORING, SITE_MONITORING_TAB_SHOWN, + SITE_MONITORING_TAB_LOADING_ERROR } private static final List TRACKERS = new ArrayList<>(); diff --git a/libs/analytics/src/main/java/org/wordpress/android/analytics/AnalyticsTrackerNosara.java b/libs/analytics/src/main/java/org/wordpress/android/analytics/AnalyticsTrackerNosara.java index 6bc51ec5bdbf..5e5fa351b389 100644 --- a/libs/analytics/src/main/java/org/wordpress/android/analytics/AnalyticsTrackerNosara.java +++ b/libs/analytics/src/main/java/org/wordpress/android/analytics/AnalyticsTrackerNosara.java @@ -2701,6 +2701,8 @@ public static String getEventNameForStat(AnalyticsTracker.Stat stat) { return "opened_site_monitoring"; case SITE_MONITORING_TAB_SHOWN: return "site_monitoring_tab_shown"; + case SITE_MONITORING_TAB_LOADING_ERROR: + return "site_monitoring_tab_loading_error"; } return null; }