diff --git a/app/src/main/java/com/naviapp/app/NaviApplication.kt b/app/src/main/java/com/naviapp/app/NaviApplication.kt index 0f12df121e..0d2fd97ca5 100644 --- a/app/src/main/java/com/naviapp/app/NaviApplication.kt +++ b/app/src/main/java/com/naviapp/app/NaviApplication.kt @@ -26,8 +26,7 @@ import com.navi.alfred.utils.log import com.navi.analytics.utils.NaviTrackEvent import com.navi.base.sharedpref.PreferenceManager import com.navi.base.utils.AppLaunchUtils -import com.navi.base.utils.QaReleaseLogUtil -import com.navi.base.utils.QaReleaseLogUtil.buildQaReleaseLogMessage +import com.navi.base.utils.NetWatchManger import com.navi.chat.base.ChatBaseActivity import com.navi.common.ui.activity.BaseActivity import com.navi.common.uitron.util.UiTronDependencyProvider @@ -50,8 +49,10 @@ import com.naviapp.analytics.utils.NaviSDKHelper import com.naviapp.common.transformer.AppLoadTimerMapper import com.naviapp.home.activity.NewDashboardActivity import com.naviapp.home.common.setup.NotificationManager +import com.naviapp.releaselog.utils.NetWatchUtil import com.naviapp.utils.Constants import com.naviapp.utils.DEV +import com.naviapp.utils.QA import com.naviapp.utils.isDifferentPackage import dagger.hilt.android.HiltAndroidApp import timber.log.Timber @@ -103,8 +104,12 @@ open class NaviApplication : MultiDexApplication(), Application.ActivityLifecycl PreferenceManager.init(this) NaviSDKHelper.init(naviApplication = this) registerActivityLifecycleCallbacks(this) - QaReleaseLogUtil.init(context = applicationContext, flavour = BuildConfig.FLAVOR) - + if (TextUtils.equals(BuildConfig.FLAVOR, QA) && !BuildConfig.DEBUG) { + NetWatchManger.init( + context = applicationContext, + flavour = BuildConfig.FLAVOR + ) + } // Dumping anr data to click stream ANRWatchDog(WATCHDOG_ANR_TIMEOUT).setIgnoreDebugger(true).setReportMainThreadOnly().setANRListener { if (it.cause?.stackTrace.isNullOrEmpty()) { @@ -127,10 +132,6 @@ open class NaviApplication : MultiDexApplication(), Application.ActivityLifecycl anrEventProperties ) - buildQaReleaseLogMessage( - data = anrEventProperties, - logType = QaReleaseLogUtil.ReleaseLogType.ANR_LOG.name - ) if (isDifferentPackage.not()) { anrEventProperties[STACK_TRACE] = it.cause?.stackTrace?.get(0).toString() AlfredManager.handleAnrEvent(anrEventProperties) @@ -155,10 +156,6 @@ open class NaviApplication : MultiDexApplication(), Application.ActivityLifecycl NaviTrackEvent.trackEventOnClickStream( GLOBAL_APP_CRASH, crashEventProperties ) - buildQaReleaseLogMessage( - data = crashEventProperties, - logType = QaReleaseLogUtil.ReleaseLogType.CRASH_LOG.name - ) if (isDifferentPackage.not()) { exception.stackTrace[0]?.let { stackTraceElement -> crashEventProperties[STACK_TRACE] = stackTraceElement.toString() @@ -196,6 +193,9 @@ open class NaviApplication : MultiDexApplication(), Application.ActivityLifecycl if (appForegroundCounter > 0) { startAlfredRecording(activity) + if (TextUtils.equals(BuildConfig.FLAVOR, QA) && !BuildConfig.DEBUG){ + NetWatchUtil.addUniversalButtonToActivity(activity, applicationContext) + } } } diff --git a/app/src/main/java/com/naviapp/home/activity/NewDashboardActivity.kt b/app/src/main/java/com/naviapp/home/activity/NewDashboardActivity.kt index 1d70accf03..a5dbcdcb52 100644 --- a/app/src/main/java/com/naviapp/home/activity/NewDashboardActivity.kt +++ b/app/src/main/java/com/naviapp/home/activity/NewDashboardActivity.kt @@ -62,7 +62,6 @@ import com.navi.ap.utils.constants.APP_PLATFORM_VERTICAL_TYPE import com.navi.ap.utils.constants.CUSTOMER_CAPITAL import com.navi.ap.utils.constants.REDIRECT_STATUS import com.navi.base.deeplink.util.DeeplinkConstants -import com.navi.base.deeplink.util.DeeplinkConstants.RELEASE_LOG import com.navi.base.model.ActionData import com.navi.base.model.CtaData import com.navi.base.model.GenericAnalytics @@ -76,7 +75,6 @@ import com.navi.base.utils.BaseUtils import com.navi.base.utils.BaseUtils.isUserLoggedIn import com.navi.base.utils.ConnectivityObserver import com.navi.base.utils.DateUtils -import com.navi.base.utils.QaReleaseLogUtil.isQaRelease import com.navi.base.utils.isNotNull import com.navi.base.utils.isNotNullAndNotEmpty import com.navi.base.utils.isNull @@ -1751,19 +1749,6 @@ class NewDashboardActivity : screen = intent.getStringExtra(Constants.REDIRECT_STATUS) ?: HomeFragment.TAG, bundle = intent.extras ) - if (isQaRelease) { - binding.profileScreen.btnLogs.apply { - setVisibilityState(View.VISIBLE) - setOnClickListener { - NaviDeepLinkNavigator.navigate( - activity = this@NewDashboardActivity, - ctaData = CtaData(url = RELEASE_LOG) - ) - } - } - } else { - binding.profileScreen.btnLogs.apply { setVisibilityState(View.GONE) } - } } private fun fetchBottomNavigationBar() { diff --git a/app/src/main/java/com/naviapp/network/retrofit/ResponseCallback.kt b/app/src/main/java/com/naviapp/network/retrofit/ResponseCallback.kt index 7678888d3b..7796ddf0e3 100644 --- a/app/src/main/java/com/naviapp/network/retrofit/ResponseCallback.kt +++ b/app/src/main/java/com/naviapp/network/retrofit/ResponseCallback.kt @@ -8,13 +8,14 @@ package com.naviapp.network.retrofit import com.navi.common.R as CommonR +import android.text.TextUtils import com.google.firebase.crashlytics.FirebaseCrashlytics import com.google.gson.Gson import com.google.gson.reflect.TypeToken import com.navi.analytics.utils.NaviTrackEvent import com.navi.base.utils.BaseUtils -import com.navi.base.utils.QaReleaseLogUtil -import com.navi.base.utils.QaReleaseLogUtil.buildQaReleaseLogMessage +import com.navi.base.utils.NetWatchManger +import com.navi.base.utils.NetWatchManger.buildLogMessage import com.navi.common.CommonLibManager import com.navi.common.network.ApiConstants import com.navi.common.network.ApiConstants.API_CODE_ERROR @@ -29,6 +30,7 @@ import com.navi.common.network.models.ErrorMessage import com.navi.common.network.models.GenericErrorResponse import com.navi.common.network.models.GenericResponse import com.navi.common.network.models.RepoResult +import com.naviapp.BuildConfig import com.naviapp.R import com.naviapp.app.NaviApplication import com.naviapp.models.RedirectPageStatus @@ -38,9 +40,12 @@ import com.naviapp.network.ApiConstants.API_SUCCESS_CODE import com.naviapp.network.ApiConstants.API_SUCCESS_CODE_201 import com.naviapp.network.ApiConstants.API_SUCCESS_CODE_204 import com.naviapp.network.ApiConstants.E_OFFER_EXPIRED +import com.naviapp.releaselog.utils.ErrorLog import com.naviapp.utils.Constants import com.naviapp.utils.Constants.API_401 import com.naviapp.utils.Constants.LOAN_OFFER_EXPIRED +import com.naviapp.utils.Constants.SYSTEM_UNDER_MAINTENANCE +import com.naviapp.utils.QA import com.naviapp.utils.handleError import com.naviapp.utils.isNetworkAvailable import java.net.ConnectException @@ -62,14 +67,18 @@ abstract class ResponseCallback { private fun handleResponse(response: Response>): RepoResult { addApiUrlInErrorResponse(response.body()?.errors, response.raw().request.url.toString()) - buildQaReleaseLogMessage( - responseData = response.body()?.data, - statusCode = response.body()?.statusCode.toString(), - request = response.raw().request, - logType = QaReleaseLogUtil.ReleaseLogType.NETWORK_LOG.name, - response = response.raw(), - requestMethod = response.raw().request.method - ) + if (TextUtils.equals(BuildConfig.FLAVOR, QA) && !BuildConfig.DEBUG) { + buildLogMessage( + responseData = response.body()?.data, + request = response.raw().request, + logType = NetWatchManger.ReleaseLogType.NETWORK_LOG.name, + response = response.raw(), + errorMessage = ErrorLog( + statusCode = response.code().toString(), + message = response.message() + ) + ) + } response.body()?.let { if (it.errors?.firstOrNull()?.code == E_OFFER_EXPIRED) { handleError(it.errors, RedirectPageStatus(rejectReason = LOAN_OFFER_EXPIRED)) diff --git a/app/src/main/java/com/naviapp/releaselog/activity/ReleaseLogActivity.kt b/app/src/main/java/com/naviapp/releaselog/activity/ReleaseLogActivity.kt index d5dc096b26..31507eda08 100644 --- a/app/src/main/java/com/naviapp/releaselog/activity/ReleaseLogActivity.kt +++ b/app/src/main/java/com/naviapp/releaselog/activity/ReleaseLogActivity.kt @@ -1,12 +1,21 @@ package com.naviapp.releaselog.activity +import android.os.Build import android.os.Bundle import androidx.activity.compose.setContent import androidx.activity.viewModels +import androidx.annotation.RequiresApi import androidx.compose.ui.platform.LocalContext +import androidx.core.content.ContextCompat +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController import com.navi.common.model.ModuleNameV2 import com.navi.common.ui.activity.BaseActivity -import com.naviapp.releaselog.screens.ReleaseLogScreen +import com.navi.design.theme.NaviMaterialTheme +import com.naviapp.R +import com.naviapp.releaselog.screens.LogListScreen +import com.naviapp.releaselog.screens.TabScreen import com.naviapp.releaselog.viewmodel.ReleaseLogViewModel import dagger.hilt.android.AndroidEntryPoint @@ -15,10 +24,26 @@ class ReleaseLogActivity : BaseActivity() { private val viewModel by viewModels() + @RequiresApi(Build.VERSION_CODES.TIRAMISU) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { - ReleaseLogScreen(LocalContext.current, viewModel) + window.statusBarColor = ContextCompat.getColor(this, R.color.colorBlueSeekbar) + NaviMaterialTheme { + val navController = rememberNavController() + NavHost( + navController = navController, startDestination = "listScreen" + ) { + composable("listScreen") { + LogListScreen(LocalContext.current, viewModel, navController) + } + composable( + "tabScreen" + ) { + TabScreen(viewModel, navController, context = LocalContext.current) + } + } + } } } @@ -26,6 +51,6 @@ class ReleaseLogActivity : BaseActivity() { override val moduleName: ModuleNameV2 get() = ModuleNameV2.COMMON companion object { - const val TAG = "RELEASE_LOG" + const val TAG = "NET_WATCH_LOG" } } diff --git a/app/src/main/java/com/naviapp/releaselog/screens/ReleaseLogScreen.kt b/app/src/main/java/com/naviapp/releaselog/screens/ReleaseLogScreen.kt index 3d6977af00..b005e9d9b3 100644 --- a/app/src/main/java/com/naviapp/releaselog/screens/ReleaseLogScreen.kt +++ b/app/src/main/java/com/naviapp/releaselog/screens/ReleaseLogScreen.kt @@ -1,15 +1,12 @@ package com.naviapp.releaselog.screens + import android.content.ClipData -import android.content.ClipboardManager import android.content.Context import android.widget.Toast -import androidx.appcompat.app.AppCompatActivity -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -20,41 +17,72 @@ 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.shape.RoundedCornerShape -import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll -import androidx.compose.material.LocalTextStyle +import androidx.compose.material.AlertDialog +import androidx.compose.material.Button +import androidx.compose.material.Divider +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.Tab +import androidx.compose.material.TabRow import androidx.compose.material.Text -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Search +import androidx.compose.material.TextField +import androidx.compose.material.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf 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.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.text.input.ImeAction +import android.content.ClipboardManager +import androidx.compose.material.Checkbox +import androidx.compose.material.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.core.content.ContextCompat.getSystemService +import androidx.navigation.NavController +import androidx.navigation.NavHostController +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import com.navi.base.db.model.LogsEvent +import com.navi.base.utils.isNotNullAndNotEmpty +import com.navi.design.font.FontWeightEnum +import com.navi.design.theme.getFontWeight +import com.navi.design.theme.ttComposeFontFamily +import com.navi.pay.common.theme.color.NaviPayColor +import com.navi.naviwidgets.R as WidgetsR import com.naviapp.dashboard.menu.customersupport.screens.NaviErrorScreen import com.naviapp.dashboard.menu.customersupport.screens.NaviLoadingScreen +import com.naviapp.releaselog.utils.NetWatchUtil.colorKeysAndValues import com.naviapp.releaselog.viewmodel.ReleaseLogViewModel +import org.json.JSONObject @Composable -fun ReleaseLogScreen(context: Context, viewModel: ReleaseLogViewModel) { +fun LogListScreen( + context: Context, viewModel: ReleaseLogViewModel, navHostController: NavController +) { val logsDataState by viewModel.logsData.collectAsState() + val isLogDeleted by viewModel.logsDataDeleted.collectAsState() LaunchedEffect(Unit) { viewModel.readLogFile(context = context) @@ -71,60 +99,69 @@ fun ReleaseLogScreen(context: Context, viewModel: ReleaseLogViewModel) { is ReleaseLogViewModel.ReleaseLogState.Success -> { val data = (logsDataState as ReleaseLogViewModel.ReleaseLogState.Success).data - ShowUi(data = data, context = context, viewModel = viewModel) + ShowUi(data, context, viewModel, navHostController) } } + if (isLogDeleted) { + ShowUi(emptyList(), context, viewModel, navHostController) + } + } @Composable fun ShowUi( - data: List, context: Context, viewModel: ReleaseLogViewModel + data: List, + context: Context, + viewModel: ReleaseLogViewModel, + navHostController: NavController ) { - val clipboardManager = - context.getSystemService(AppCompatActivity.CLIPBOARD_SERVICE) as ClipboardManager - var searchTextState by remember { mutableStateOf("") } + var logsData by remember { mutableStateOf(data) } + Column( - modifier = Modifier - .fillMaxHeight() - .padding(16.dp) + modifier = Modifier.fillMaxHeight() ) { - SearchBar(searchText = searchTextState, - viewModel = viewModel, - context = context, - onSearchTextChanged = { searchQuery -> - searchTextState = searchQuery - if (searchQuery.isEmpty()) { - viewModel.showOriginalLogs() - } - }) + TopHeader(onDeleteClick = { + viewModel.deleteLogs(context = context) + logsData = emptyList() + viewModel.rawLogs = emptyList() + }, onSearchClick = { searchText -> + if (searchText.isEmpty()) { + viewModel.readLogFile(context = context) + logsData = emptyList() + } + if (searchText.length >= 3) { + val searchedLogs = viewModel.filterLogs(viewModel.rawLogs, searchText) + viewModel.showSearchedLogs(searchedLogs) + } + }, onRefreshClick = { + Toast.makeText(context, "Refreshing...", Toast.LENGTH_SHORT).show() + viewModel.readLogFile(context = context) + logsData = emptyList() + }, + onFilterClick = { + viewModel.readFilteredLogs(context = context, it) + logsData = emptyList() + } + ) + Spacer(modifier = Modifier.height(4.dp)) - Spacer(modifier = Modifier.height(8.dp)) - - if (data.isNotEmpty()) { + if (logsData.isNotEmpty()) { Box( modifier = Modifier .fillMaxSize() .verticalScroll(rememberScrollState()) ) { Column( - modifier = Modifier.fillMaxHeight() + modifier = Modifier + .fillMaxHeight() + .fillMaxWidth() ) { data.forEach { log -> - LogItem(log = log, onLongPress = { - if (log.isNotEmpty()) { - val clipData: ClipData = ClipData.newPlainText("Logs", log) - clipboardManager.setPrimaryClip(clipData) - Toast.makeText( - context, "Copied to Clipboard", Toast.LENGTH_SHORT - ).show() - } - }) - + LogItem(log = log, navHostController, viewModel) } } } - Spacer(modifier = Modifier.height(2.dp)) } else { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center @@ -135,99 +172,654 @@ fun ShowUi( } } + @Composable -fun SearchBar( - searchText: String, - onSearchTextChanged: (String) -> Unit, - viewModel: ReleaseLogViewModel, - context: Context +fun LogItem(log: LogsEvent, navController: NavController, viewModel: ReleaseLogViewModel) { + val statusCodeColor = when { + log.statusCode == "200" -> NaviPayColor.textPrimary + log.statusCode?.startsWith("4") == true -> Color(0xFFECBD48) + log.statusCode?.startsWith("5") == true -> Color.Red + log.statusCode?.startsWith("2") == true && log.statusCode?.length!! < 3 -> Color(0xFFECBD48) + else -> NaviPayColor.textPrimary + } + + Row(modifier = Modifier + .fillMaxWidth() + .padding(vertical = 10.dp, horizontal = 16.dp) + .clickable { + viewModel.setSelectedLog(log) + navController.navigate("tabScreen") + }) { + Text( + text = if (log.statusCode != "null") log.statusCode.toString() else " !!! ", + fontSize = 16.sp, + fontFamily = ttComposeFontFamily, + fontWeight = getFontWeight(FontWeightEnum.TT_MEDIUM), + color = statusCodeColor, + modifier = Modifier + .width(40.dp) + .align(Alignment.CenterVertically), + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.size(16.dp)) + Column( + modifier = Modifier.weight(3f) + ) { + Text( + text = log.method.toString() + " " + log.endPoint.toString(), + fontSize = 16.sp, + fontFamily = ttComposeFontFamily, + fontWeight = getFontWeight(FontWeightEnum.TT_MEDIUM), + color = statusCodeColor + ) + Text( + modifier = Modifier.fillMaxWidth(), + text = log.baseUrl.toString(), + fontSize = 14.sp, + fontFamily = ttComposeFontFamily, + fontWeight = getFontWeight(FontWeightEnum.TT_REGULAR), + color = statusCodeColor + ) + Row { + Text( + modifier = Modifier.weight(1f), + text = log.requestTime.toString(), + fontSize = 14.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontFamily = ttComposeFontFamily, + fontWeight = getFontWeight(FontWeightEnum.TT_REGULAR), + color = statusCodeColor + ) + Text( + modifier = Modifier.weight(1f), + text = log.latency.toString(), + fontSize = 14.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontFamily = ttComposeFontFamily, + fontWeight = getFontWeight(FontWeightEnum.TT_REGULAR), + color = statusCodeColor + ) + Text( + modifier = Modifier.weight(1f), + text = log.requestSize.toString(), + fontSize = 14.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontFamily = ttComposeFontFamily, + fontWeight = getFontWeight(FontWeightEnum.TT_REGULAR), + color = statusCodeColor + ) + } + } + } + Divider( + Modifier + .fillMaxWidth() + .width(1.dp) + ) +} + + +@Composable +fun TabScreenHeader( + title: String, + onBackClick: () -> Unit, + onShareCurlIconClick: () -> Unit, + onShareAsTextIconClick: () -> Unit ) { - Box( + var expanded by remember { mutableStateOf(false) } + + Row( modifier = Modifier .fillMaxWidth() - .background(Color.Gray) - .padding(8.dp) - .height(30.dp) - .clip(RoundedCornerShape(8.dp)) + .height(60.dp) + .background(color = Color(0xFF3A8EE7)), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween ) { - Row( - modifier = Modifier - .fillMaxSize() - .background(Color.White), - verticalAlignment = Alignment.CenterVertically - ) { - BasicTextField( - value = searchText, - onValueChange = { - if (it != searchText) { - onSearchTextChanged(it) - } - }, - textStyle = LocalTextStyle.current.copy( - color = Color.Black, fontSize = 16.sp, letterSpacing = 0.15.sp - ), - modifier = Modifier - .weight(1f) - .background(Color.White) - .padding(4.dp), - singleLine = true, - keyboardOptions = KeyboardOptions.Default.copy( - imeAction = ImeAction.Done - ), - keyboardActions = KeyboardActions(onDone = { - if (searchText.length < 3) { - if (searchText.isEmpty()) { - viewModel.showOriginalLogs() - } - Toast.makeText(context, "Min Three Characters", Toast.LENGTH_SHORT).show() - } else { - viewModel.scrollToMatchedText(searchText = searchText) - } - }) + + IconButton(onClick = { + onBackClick() + }) { + Icon( + painter = painterResource(id = WidgetsR.drawable.ic_black_back_arrow_svg), + contentDescription = "Back" ) - SearchIcon( - imageVector = Icons.Default.Search, - contentDescription = "Search", - onClick = { - if (searchText.length < 3) { - if (searchText.isEmpty()) { - viewModel.showOriginalLogs() - } - Toast.makeText(context, "Min Three Characters", Toast.LENGTH_SHORT).show() - } else { - viewModel.scrollToMatchedText(searchText = searchText) - } - }) + } + + Text( + text = title, + fontWeight = FontWeight.Bold, + fontSize = 18.sp, + color = Color.White, + textAlign = TextAlign.Center, + maxLines = 2, + modifier = Modifier.weight(1f) + ) + + Box( + modifier = Modifier + .size(40.dp) + .padding(end = 8.dp), + contentAlignment = Alignment.Center + ) { + Icon(painter = painterResource(id = WidgetsR.drawable.ic_share), + contentDescription = null, + modifier = Modifier + .size(16.dp) + .clickable { + expanded = !expanded + }) + + if (expanded) { + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + DropdownMenuItem(onClick = { + expanded = false + onShareCurlIconClick() + }, text = { Text("Share Curl Command") }) + + DropdownMenuItem(onClick = { + expanded = false + onShareAsTextIconClick() + }, text = { Text("Share as Text") }) + } + } } } } @Composable -fun SearchIcon( - imageVector: ImageVector, contentDescription: String?, onClick: () -> Unit -) { - val density = LocalDensity.current.density - Image(imageVector = imageVector, - contentDescription = contentDescription, - modifier = Modifier - .clickable { onClick() } - .padding(8.dp) - .size(8.dp * density)) +fun TabScreen(viewModel: ReleaseLogViewModel, navController: NavHostController, context: Context) { + val tabTitles = listOf("OVERVIEW", "REQUEST", "RESPONSE") + var selectedTab by remember { mutableIntStateOf(0) } + val logsData by viewModel.selectedLog.collectAsState() + val scrollState = rememberScrollState() + val overViewData = setOverViewFields(logsData) + + Column( + modifier = Modifier.fillMaxSize() + ) { + TabScreenHeader(title = logsData?.method + " " + logsData?.endPoint, + onBackClick = { navController.popBackStack() }, + onShareCurlIconClick = { + viewModel.generateCurlCommand( + logsData?.method.toString(), + logsData?.apiUrl.toString(), + logsData?.requestHeader.toString(), + logsData?.requestBody + ).let { + viewModel.copyToClipboard(it, context) + Toast.makeText(context, "Copied to clipboard", Toast.LENGTH_SHORT).show() + } + }, + onShareAsTextIconClick = { + copyContentsToClipboard( + overViewData.toString(), + logsData?.responseHeader.toString(), + logsData?.responseBody.toString(), + context = context + ) + Toast.makeText(context, "Copied to clipboard", Toast.LENGTH_SHORT).show() + } + ) + TabRow( + selectedTabIndex = selectedTab, + backgroundColor = Color(0xFF3A8EE7), + ) { + tabTitles.forEachIndexed { index, title -> + Tab(text = { Text(text = title) }, + selected = selectedTab == index, + onClick = { selectedTab = index }) + } + } + + when (selectedTab) { + 0 -> { + DisplayOverView(overViewData) + } + + 1 -> { + Column(modifier = Modifier.verticalScroll(scrollState)) { + DisplayHeader(logsData?.requestHeader.toString()) + logsData?.requestBody.let { + if (!it.isNullOrEmpty()) { + DisplayJson(it) + } else { + Text( + text = "(Empty Body)", + color = Color.Gray, + modifier = Modifier.padding(start = 16.dp) + ) + } + } + } + } + + 2 -> { + + Column(modifier = Modifier.verticalScroll(scrollState)) { + DisplayHeader(logsData?.responseHeader.toString()) + logsData?.responseBody.let { + if (!it.isNullOrEmpty() && it != "null") { + DisplayJson(it) + } else { + if (logsData?.errorMessage.isNotNullAndNotEmpty() && logsData?.errorMessage != "null") { + Text( + text = logsData?.errorMessage.toString(), + color = Color.Red, + modifier = Modifier.padding(start = 16.dp) + ) + } else { + Text( + text = "(Empty Body)", + color = Color.Gray, + modifier = Modifier.padding(start = 16.dp) + ) + } + } + } + } + } + } + } +} + + +fun copyContentsToClipboard(overview: String?, header: String?, body: String?, context: Context) { + val clipboardManager = getSystemService(context, ClipboardManager::class.java) + + val combinedText = buildAnnotatedString { + overview?.let { overview -> + append("$overview\n\n") + } + header?.let { header -> + if (header != "null" && header.isNotEmpty()) { + withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { + append("------------------Request Body --------------------:\n") + } + append("$header\n\n") + } + } + body?.let { body -> + if (body != "null" && body.isNotEmpty()) { + withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { + append("------------------Response Body------------------:\n") + } + append(body) + } + } + } + val clip: ClipData = ClipData.newPlainText("json", combinedText) + clipboardManager?.setPrimaryClip(clip) + Toast.makeText(context, "Copied to clipboard", Toast.LENGTH_SHORT).show() } -@OptIn(ExperimentalFoundationApi::class) @Composable -fun LogItem(log: String, onLongPress: () -> Unit) { - Box( +fun DisplayJson(jsonString: String) { + if (jsonString.isNotNullAndNotEmpty() && jsonString != "null") { + val jsonObject = JSONObject(jsonString) + val formattedJson = jsonObject.toString(4) + val annotatedString = buildAnnotatedString { + append(formattedJson) + colorKeysAndValues(formattedJson) + } + Text(text = annotatedString, modifier = Modifier.padding(horizontal = 16.dp)) + } +} + +fun determineValueType(valueString: String): JsonValueType { + return when { + valueString.matches("\".*\"".toRegex()) -> JsonValueType.STRING + valueString.equals("true", ignoreCase = true) || valueString.equals( + "false", ignoreCase = true + ) -> JsonValueType.BOOLEAN + + try { + valueString.toInt() + true + } catch (e: NumberFormatException) { + try { + valueString.toDouble() + true + } catch (e: NumberFormatException) { + false + } + } -> JsonValueType.NUMBER + + else -> JsonValueType.UNKNOWN + } +} + + +enum class JsonValueType { + STRING, BOOLEAN, NUMBER, UNKNOWN +} + + +fun setOverViewFields(logsEvent: LogsEvent?): HashMap { + val dataMap = HashMap() + logsEvent?.let { + dataMap["URL"] = logsEvent.apiUrl.toString() + dataMap["Method"] = logsEvent.method.toString() + dataMap["Status"] = + if (logsEvent.statusCode.toString() != "null") logsEvent.statusCode.toString() else "Failed" + dataMap["Request size"] = logsEvent.requestSize.toString() + dataMap["Response size"] = logsEvent.responseSize.toString() + dataMap["Total size"] = logsEvent.totalSize.toString() + dataMap["Request time"] = logsEvent.requestTime.toString() + dataMap["Response time"] = logsEvent.responseTime.toString() + dataMap["Duration"] = logsEvent.latency.toString() + "" + dataMap["Protocol"] = logsEvent.protocol.toString() + } + return dataMap +} + +@Composable +fun DisplayOverView(data: HashMap) { + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 16.dp)) { + for ((key, value) in data.entries) { + Row { + Text( + text = key, color = Color.DarkGray, modifier = Modifier.width(130.dp) + ) + Text( + text = value.toString(), color = Color.Gray + ) + } + Spacer(modifier = Modifier.size(1.dp)) + } + } +} + +@Composable +fun TopHeader( + onDeleteClick: () -> Unit = {}, + onSearchClick: (String) -> Unit = {}, + onRefreshClick: () -> Unit = {}, + onFilterClick: (List) -> Unit = {} +) { + var showDeleteConfirmationDialog by remember { mutableStateOf(false) } + var isSearchMode by remember { mutableStateOf(false) } + var searchText by remember { mutableStateOf("") } + var showFilterDialog by remember { mutableStateOf(false) } + + Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 10.dp) - .combinedClickable(onClick = {}, onLongClick = onLongPress, onDoubleClick = {}) + .height(60.dp) + .background(Color(0xFF3A8EE7)), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween ) { - Text( - text = log, modifier = Modifier, maxLines = 1, overflow = TextOverflow.Ellipsis + if (isSearchMode) { + Box(modifier = Modifier + .size(32.dp) + .padding(start = 8.dp) + .clickable { + isSearchMode = false + searchText = "" + } + .padding(vertical = 8.dp)) { + Icon(painter = painterResource(id = WidgetsR.drawable.ic_black_back_arrow_svg), + contentDescription = null, + modifier = Modifier + .size(16.dp) + .clickable { + isSearchMode = false + searchText = "" + }) + } + Spacer(modifier = Modifier.size(16.dp)) + + Box( + modifier = Modifier + .weight(1f) + .padding(vertical = 4.dp) + ) { + TextField( + value = searchText, + onValueChange = { + searchText = it + onSearchClick(searchText) + }, + placeholder = { Text("Search") }, + colors = TextFieldDefaults.textFieldColors( + backgroundColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + textColor = Color.White, + ), + maxLines = 1, + modifier = Modifier.fillMaxWidth(), + keyboardActions = KeyboardActions(onDone = { + if (searchText.length > 2) { + onSearchClick(searchText) + } + }) + ) + + } + + if (searchText.length > 1) { + Icon(painter = painterResource(id = WidgetsR.drawable.ic_cross), + contentDescription = null, + modifier = Modifier + .size(16.dp) + .clickable { + searchText = "" + onSearchClick(searchText) + }) + } + + Spacer(modifier = Modifier.size(8.dp)) + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(end = 16.dp) + ) { + Icon( + painter = painterResource(id = WidgetsR.drawable.ic_bold_search_svg), + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + Icon(painter = painterResource(id = WidgetsR.drawable.ic_delete_outlined_24dp), + contentDescription = null, + modifier = Modifier + .size(16.dp) + .clickable { + showDeleteConfirmationDialog = true + }) + } + } else { + Column(modifier = Modifier.padding(start = 16.dp)) { + Text( + text = "NetWatch", + fontWeight = FontWeight.Bold, + color = Color.White, + fontSize = 18.sp + ) + Spacer(modifier = Modifier.size(2.dp)) + Text(text = "Navi QA", color = Color.White, fontSize = 14.sp) + } + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(end = 16.dp) + ) { + Icon(painter = painterResource(id = WidgetsR.drawable.ic_bold_search_svg), + contentDescription = null, + modifier = Modifier + .size(16.dp) + .clickable { + isSearchMode = true + }) + Icon(painter = painterResource(id = WidgetsR.drawable.ic_filter), + contentDescription = null, + modifier = Modifier + .size(16.dp) + .clickable { + showFilterDialog = true + }) + Icon(painter = painterResource(id = WidgetsR.drawable.ic_refresh_black), + contentDescription = null, + modifier = Modifier + .size(16.dp) + .clickable { + onRefreshClick() + }) + + Icon(painter = painterResource(id = WidgetsR.drawable.ic_delete_outlined_24dp), + contentDescription = null, + modifier = Modifier + .size(16.dp) + .clickable { + showDeleteConfirmationDialog = true + }) + } + } + } + + if (showFilterDialog) { + FilterDialog( + filterItems = listOf("Pulse"), + onFilterSelected = { + onFilterClick(it) + }, + onDismiss = { + showFilterDialog = false + } ) } + + if (showDeleteConfirmationDialog) { + AlertDialog(onDismissRequest = { + showDeleteConfirmationDialog = false + }, + title = { Text("Confirmation") }, + text = { Text("Are you sure you want to delete logs?") }, + confirmButton = { + Button(onClick = { + onDeleteClick() + showDeleteConfirmationDialog = false + }) { + Text("Yes") + } + }, + dismissButton = { + Button(onClick = { + showDeleteConfirmationDialog = false + }) { + Text("No") + } + }) + } +} + +@Composable +fun FilterDialog( + filterItems: List, + onFilterSelected: (List) -> Unit, + onDismiss: () -> Unit +) { + var selectedFilters by remember { mutableStateOf(emptyList()) } + + Dialog( + onDismissRequest = { + onDismiss() + }, + properties = DialogProperties(dismissOnBackPress = true, dismissOnClickOutside = true), + content = { + Box( + modifier = Modifier + .width(300.dp) + .height(200.dp) + .background(MaterialTheme.colorScheme.surface) + .padding(16.dp) + ) { + Column( + modifier = Modifier.fillMaxSize() + ) { + Text("Select Filters", fontWeight = FontWeight.Bold) + Spacer(modifier = Modifier.height(8.dp)) + + filterItems.forEach { filter -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + ) { + Checkbox( + checked = selectedFilters.contains(filter), + onCheckedChange = { + selectedFilters = if (it) { + selectedFilters + filter + } else { + selectedFilters - filter + } + }, + modifier = Modifier.padding(end = 8.dp) + ) + Text(text = filter) + } + } + + Spacer(modifier = Modifier.height(26.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Button( + onClick = { + onDismiss() + }, + modifier = Modifier.weight(1f) + ) { + Text("Cancel") + } + + Spacer(modifier = Modifier.width(8.dp)) + + Button( + onClick = { + onFilterSelected(selectedFilters) + onDismiss() + }, + modifier = Modifier.weight(1f) + ) { + Text("Apply") + } + } + } + } + } + ) +} + + +@Composable +fun DisplayHeader(data: String) { + val map: HashMap = Gson().fromJson( + data, object : TypeToken>() {}.type + ) + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 16.dp)) { + for ((key, value) in map) { + Row { + Text( + text = key, color = Color.DarkGray, modifier = Modifier.width(180.dp) + ) + Text( + text = value.toString(), color = Color.Gray + ) + } + Spacer(modifier = Modifier.size(1.dp)) + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/naviapp/releaselog/utils/ErrorLog.kt b/app/src/main/java/com/naviapp/releaselog/utils/ErrorLog.kt new file mode 100644 index 0000000000..26306cd36a --- /dev/null +++ b/app/src/main/java/com/naviapp/releaselog/utils/ErrorLog.kt @@ -0,0 +1,6 @@ +package com.naviapp.releaselog.utils + +data class ErrorLog( + val statusCode: String? = null, + val message: String? = null +) diff --git a/app/src/main/java/com/naviapp/releaselog/utils/NetWatchUtil.kt b/app/src/main/java/com/naviapp/releaselog/utils/NetWatchUtil.kt new file mode 100644 index 0000000000..10b105e3ae --- /dev/null +++ b/app/src/main/java/com/naviapp/releaselog/utils/NetWatchUtil.kt @@ -0,0 +1,102 @@ +package com.naviapp.releaselog.utils + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.Context +import android.graphics.PixelFormat +import android.view.Gravity +import android.view.WindowManager +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.core.content.res.ResourcesCompat +import com.navi.base.deeplink.util.DeeplinkConstants +import com.navi.base.model.CtaData +import com.navi.design.utils.dpToPxInInt +import com.navi.naviwidgets.extensions.hexToColor +import com.navi.naviwidgets.R as WidgetsR +import com.naviapp.common.navigator.NaviDeepLinkNavigator +import com.naviapp.releaselog.activity.ReleaseLogActivity +import com.naviapp.releaselog.screens.JsonValueType +import com.naviapp.releaselog.screens.determineValueType +import org.json.JSONObject + +object NetWatchUtil { + + fun addUniversalButtonToActivity( + activity: Activity, context: Context + ) { + if (activity is ReleaseLogActivity) { + return + } + val button = UniversalButton(context, activity) + val layoutParams = WindowManager.LayoutParams( + dpToPxInInt(30), + dpToPxInInt(30), + WindowManager.LayoutParams.TYPE_APPLICATION, + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL, + PixelFormat.TRANSLUCENT + ) + layoutParams.gravity = Gravity.TOP or Gravity.END + layoutParams.x = dpToPxInInt(25) + layoutParams.y = dpToPxInInt(46) + button.layoutParams = layoutParams + val windowManager = activity.getSystemService(Context.WINDOW_SERVICE) as WindowManager + windowManager.addView(button, layoutParams) + } + + @SuppressLint("ViewConstructor") + class UniversalButton( + context: Context, + activity: Activity + ) : + androidx.appcompat.widget.AppCompatImageView(context) { + init { + background = ResourcesCompat.getDrawable(resources, WidgetsR.drawable.glowing_bulb, null) + isClickable = true + setOnClickListener { + NaviDeepLinkNavigator.navigate( + activity = activity, + ctaData = CtaData(url = DeeplinkConstants.RELEASE_LOG), + finish = false + ) + } + } + } + + fun AnnotatedString.Builder.colorKeysAndValues(jsonString: String) { + + val jsonObject = JSONObject(jsonString) + val keys = jsonObject.keys() + + for (key in keys) { + val keyIndex = jsonString.indexOf("\"$key\"") + val valueIndex = jsonString.indexOf(':', keyIndex + key.length + 1) + + if (keyIndex != -1 && valueIndex != -1) { + addStyle( + style = SpanStyle(color = Color.Red, fontWeight = FontWeight.SemiBold), + start = keyIndex, + end = keyIndex + key.length + 2 + ) + val valueStartIndex = valueIndex + 1 + val valueEndIndex = jsonString.indexOfAny(charArrayOf(',', '}'), valueStartIndex) + val valueString = jsonString.substring(valueStartIndex, valueEndIndex) + val valueType = determineValueType(valueString) + + val color = when (valueType) { + JsonValueType.STRING -> Color.Blue + JsonValueType.BOOLEAN -> Color.Green + JsonValueType.NUMBER -> hexToColor("#8FFF5732") + else -> Color.Gray + } + + addStyle( + style = SpanStyle(color = color), start = valueStartIndex, end = valueEndIndex + ) + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/naviapp/releaselog/viewmodel/ReleaseLogViewModel.kt b/app/src/main/java/com/naviapp/releaselog/viewmodel/ReleaseLogViewModel.kt index cf76232e48..a6c9ca529b 100644 --- a/app/src/main/java/com/naviapp/releaselog/viewmodel/ReleaseLogViewModel.kt +++ b/app/src/main/java/com/naviapp/releaselog/viewmodel/ReleaseLogViewModel.kt @@ -1,17 +1,18 @@ package com.naviapp.releaselog.viewmodel +import android.content.ClipData +import android.content.ClipboardManager import android.content.Context +import androidx.core.content.ContextCompat import androidx.lifecycle.viewModelScope -import com.navi.base.utils.QA_RELEASE_LOGS_FILE_NAME -import com.navi.base.utils.QA_RELEASE_LOGS_FOLDER_NAME +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import com.navi.base.db.NetWatchDatabaseHelper +import com.navi.base.db.model.LogsEvent import com.navi.common.utils.log import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import java.io.BufferedReader -import java.io.File -import java.io.FileInputStream -import java.io.InputStreamReader import com.navi.common.viewmodel.BaseVM import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers @@ -24,39 +25,25 @@ class ReleaseLogViewModel @Inject constructor() : BaseVM() { ReleaseLogState.Loading ) val logsData = _logsData.asStateFlow() - private var rawLogs: List = emptyList() + + private val _logsDataDeleted = MutableStateFlow(false) + val logsDataDeleted = _logsDataDeleted.asStateFlow() + + var rawLogs: List = emptyList() + + private val _selectedLog = MutableStateFlow(null) + val selectedLog = _selectedLog.asStateFlow() fun readLogFile(context: Context) { viewModelScope.launch(Dispatchers.IO) { try { _logsData.emit(ReleaseLogState.Loading) - val logDirectoryName = QA_RELEASE_LOGS_FOLDER_NAME - val logFileName = QA_RELEASE_LOGS_FILE_NAME - val logFile = File(context.filesDir, "$logDirectoryName/$logFileName") - val logs = mutableListOf() - if (logFile.exists()) { - val fileInputStream = FileInputStream(logFile) - val bufferedReader = BufferedReader(InputStreamReader(fileInputStream)) - var line: String? - val lines = mutableListOf() - val linesToReadFromBottom = 150 - - while (bufferedReader.readLine().also { line = it } != null) { - line?.let { - if (it.isNotEmpty()) { - lines.add(it) - } - } - } - val startIndex = maxOf(0, lines.size - linesToReadFromBottom) - for (i in lines.size - 2 downTo startIndex) { - logs.add(lines[i]) - } - bufferedReader.close() - fileInputStream.close() - rawLogs = logs - _logsData.emit(ReleaseLogState.Success(logs)) - } + rawLogs = emptyList() + val db = NetWatchDatabaseHelper.getLogsDatabase(context) + val logDao = db.logDao() + val logEvents = logDao.fetchLogs(THRESHOLD_VALUE) + rawLogs = logEvents + _logsData.emit(ReleaseLogState.Success(logEvents)) } catch (e: Exception) { e.log() _logsData.emit(ReleaseLogState.Error) @@ -64,31 +51,87 @@ class ReleaseLogViewModel @Inject constructor() : BaseVM() { } } - - fun showOriginalLogs() { + fun readFilteredLogs(context: Context, moduleName: List = emptyList()) { viewModelScope.launch(Dispatchers.IO) { - _logsData.emit(ReleaseLogState.Success(rawLogs)) + try { + _logsData.emit(ReleaseLogState.Loading) + val db = NetWatchDatabaseHelper.getLogsDatabase(context) + val logDao = db.logDao() + val logEvents = logDao.fetchFilteredLogs(moduleName, THRESHOLD_VALUE) + _logsData.emit(ReleaseLogState.Success(logEvents)) + } catch (e: Exception) { + e.log() + _logsData.emit(ReleaseLogState.Error) + } } } + fun setSelectedLog(log: LogsEvent) { + _selectedLog.value = log + } - fun scrollToMatchedText( - searchText: String - ) { + fun showSearchedLogs(searchedLogs: List) { viewModelScope.launch(Dispatchers.IO) { - _logsData.emit(ReleaseLogState.Success(rawLogs.filter { - it.contains( - searchText, ignoreCase = true - ) - })) + _logsData.emit(ReleaseLogState.Success(searchedLogs)) } } + fun deleteLogs(context: Context) { + viewModelScope.launch(Dispatchers.IO) { + try { + val db = NetWatchDatabaseHelper.getLogsDatabase(context) + val logDao = db.logDao() + logDao.deleteLogs() + _logsDataDeleted.emit(true) + } catch (e: Exception) { + e.log() + _logsDataDeleted.emit(false) + } + } + } + + fun generateCurlCommand( + method: String, url: String, headers: String, requestBody: String? + ): String { + val curlCommand = StringBuilder("curl -X $method '$url'") + + val headerMap: Map = Gson().fromJson( + headers, object : TypeToken>() {}.type + ) + + for ((key, value) in headerMap) { + curlCommand.append(" -H '$key: $value'") + } + + requestBody?.let { + val escapedBody = it.replace("'", "'\\''") + curlCommand.append(" -d '$escapedBody'") + } + + return curlCommand.toString() + } + + fun copyToClipboard(text: String, context: Context) { + val clipboard = ContextCompat.getSystemService(context, ClipboardManager::class.java) + val clip: ClipData = ClipData.newPlainText("json", text) + clipboard?.setPrimaryClip(clip) + } + + fun filterLogs(logs: List, searchText: String): List { + return logs.filter { log -> + log.endPoint.toString().contains(searchText, ignoreCase = true) + } + } sealed class ReleaseLogState { - object Loading : ReleaseLogState() - data class Success(val data: List) : ReleaseLogState() - object Error : ReleaseLogState() + data object Loading : ReleaseLogState() + data class Success(val data: List) : ReleaseLogState() + data object Error : ReleaseLogState() + } + + + companion object{ + private const val THRESHOLD_VALUE:Int = 150 } diff --git a/app/src/main/res/layout/fragment_profile_ln.xml b/app/src/main/res/layout/fragment_profile_ln.xml index 31036bebdf..a9d2473950 100644 --- a/app/src/main/res/layout/fragment_profile_ln.xml +++ b/app/src/main/res/layout/fragment_profile_ln.xml @@ -38,21 +38,6 @@ - - - + + @Query("SELECT * FROM LogsEvent WHERE moduleName not in (:moduleNames) LIMIT :thresholdValue") + fun fetchFilteredLogs(moduleNames: List, thresholdValue: Int): List + + @Query("DELETE FROM LogsEvent ") fun deleteLogs() +} diff --git a/navi-base/src/main/java/com/navi/base/db/model/LogEntity.kt b/navi-base/src/main/java/com/navi/base/db/model/LogEntity.kt new file mode 100644 index 0000000000..1db42e8011 --- /dev/null +++ b/navi-base/src/main/java/com/navi/base/db/model/LogEntity.kt @@ -0,0 +1,38 @@ +/* + * + * * Copyright © 2023 by Navi Technologies Limited + * * All rights reserved. Strictly confidential + * + */ + +package com.navi.base.db.model + +import androidx.room.* +import java.io.Serializable + +@Entity +data class LogsEvent( + @ColumnInfo(name = "time") val time: Long?, + @ColumnInfo(name = "statusCode") val statusCode: String?, + @ColumnInfo(name = "endPoint") val endPoint: String?, + @ColumnInfo(name = "baseUrl") val baseUrl: String?, + @ColumnInfo(name = "method") val method: String?, + @ColumnInfo(name = "latency") val latency: String?, + @ColumnInfo(name = "requestSize") val requestSize: String?, + @ColumnInfo(name = "requestHeader") val requestHeader: String?, + @ColumnInfo(name = "requestBody") val requestBody: String?, + @ColumnInfo(name = "responseHeader") val responseHeader: String?, + @ColumnInfo(name = "responseBody") val responseBody: String?, + @ColumnInfo(name = "responseSize") val responseSize: String?, + @ColumnInfo(name = "moduleName") val moduleName: String?, + @ColumnInfo(name = "logType") val logType: String?, + @ColumnInfo(name = "protocol") val protocol: String?, + @ColumnInfo(name = "requestTime") val requestTime: String?, + @ColumnInfo(name = "responseTime") val responseTime: String?, + @ColumnInfo(name = "totalSize") val totalSize: String?, + @ColumnInfo(name = "scheme") val scheme: String?, + @ColumnInfo(name = "errorMessage") val errorMessage: String?, + @ColumnInfo(name = "apiUrl") val apiUrl: String?, +) : Serializable { + @PrimaryKey(autoGenerate = true) var id: Int = 0 +} diff --git a/navi-base/src/main/java/com/navi/base/utils/Constants.kt b/navi-base/src/main/java/com/navi/base/utils/Constants.kt index 9c579db3f1..f73de19889 100644 --- a/navi-base/src/main/java/com/navi/base/utils/Constants.kt +++ b/navi-base/src/main/java/com/navi/base/utils/Constants.kt @@ -34,5 +34,3 @@ const val SKIP_LOADER = "skipLoader" const val PAN_VERIFY_POLLING_FAIL = "PAN_VERIFY_POLLING_FAIL" const val APPLICATION_JSON = "application/json" const val EXCLUDE_FROM_HASH_ENCRYPTION = "excludeFromHashEncryption" -const val QA_RELEASE_LOGS_FILE_NAME = "NAVI_QA_RELEASE_LOGS.txt" -const val QA_RELEASE_LOGS_FOLDER_NAME = "NAVI_QA_RELEASE_LOGS_FOLDER" diff --git a/navi-base/src/main/java/com/navi/base/utils/NetWatchManger.kt b/navi-base/src/main/java/com/navi/base/utils/NetWatchManger.kt new file mode 100644 index 0000000000..77b01384c0 --- /dev/null +++ b/navi-base/src/main/java/com/navi/base/utils/NetWatchManger.kt @@ -0,0 +1,204 @@ +/* + * + * * Copyright © 2024 by Navi Technologies Limited + * * All rights reserved. Strictly confidential + * + */ + +package com.navi.base.utils + +import android.content.Context +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import com.navi.base.BuildConfig +import com.navi.base.db.NetWatchDatabase +import com.navi.base.db.NetWatchDatabaseHelper +import com.navi.base.db.dao.LogDao +import com.navi.base.db.model.LogsEvent +import java.io.IOException +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.concurrent.Executors +import kotlinx.coroutines.asCoroutineDispatcher +import okhttp3.Request +import okhttp3.Response +import okio.Buffer + +object NetWatchManger { + + private lateinit var applicationContext: Context + private val coroutineDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher() + private var isQaRelease: Boolean = false + private lateinit var netWatchDatabase: NetWatchDatabase + private lateinit var logDao: LogDao + + fun init(context: Context, flavour: String) { + applicationContext = context + isQaRelease = flavour == QA && !BuildConfig.DEBUG + netWatchDatabase = NetWatchDatabaseHelper.getLogsDatabase(applicationContext) + this.logDao = netWatchDatabase.logDao() + } + + fun buildLogMessage( + responseData: Any? = null, + logType: String, + request: Request? = null, + response: Response? = null, + moduleName: String = EMPTY, + errorMessage: Any? = EMPTY + ) { + if (!isQaRelease) { + return + } + + generateAndStoreLogMessage( + responseData = responseData, + logType = logType, + request = request, + response = response, + moduleName = moduleName, + errorMessage = errorMessage + ) + } + + private fun generateAndStoreLogMessage( + responseData: Any? = null, + logType: String, + request: Request? = null, + response: Response? = null, + moduleName: String = EMPTY, + errorMessage: Any? = EMPTY + ) { + coroutineDispatcher.executor.execute { + val baseUrl = request?.url?.toUrl()?.host + val endPoint = request?.url?.toUrl()?.path + val scheme = request?.url?.scheme + val requestMethod = request?.method + val responseJsonString = + try { + GsonBuilder().setPrettyPrinting().create().toJson(responseData) + } catch (e: Exception) { + "" + } + val requestJsonString = bodyToString(request) + val requestHeaderMap: HashMap = hashMapOf() + request?.headers?.forEach { requestHeaderMap[it.first] = it.second } + val requestHeaderJsonString = + try { + Gson().toJson(requestHeaderMap) + } catch (e: Exception) { + EMPTY + } + + val responseHeaderMap: HashMap = hashMapOf() + response?.headers?.forEach { responseHeaderMap[it.first] = it.second } + + val responseHeaderJsonString = + try { + Gson().toJson(responseHeaderMap) + } catch (e: Exception) { + EMPTY + } + val latency = + response + ?.receivedResponseAtMillis + .orZero() + .minus(response?.sentRequestAtMillis.orZero()) + + val latencyWithUnit = + if (latency < 1000) { + "$latency ms" + } else { + "${String.format("%.2f", latency / 1000.0).toDouble()} sec" + } + + val requestTime = response?.sentRequestAtMillis?.let { convertMillisToDateTime(it) } + + val responseTime = + response?.receivedResponseAtMillis?.let { convertMillisToDateTime(it) } + + val requestSize = request?.body?.contentLength() ?: 0 + val requestSizeWithUnit = + when { + requestSize < 1024 -> "$requestSize B" + else -> "${String.format("%.2f", requestSize / 1024.0)} kB" + } + + val responseSize = response?.body?.contentLength() ?: 0 + val isChunked = + "chunked".equals(response?.headers?.get("Transfer-Encoding"), ignoreCase = true) + val responseSizeWithUnit = + when { + isChunked -> "chunked" + responseSize >= 0 -> { + when { + responseSize < 1024 -> "$responseSize B" + else -> "${String.format("%.2f", responseSize / 1024.0)} kB" + } + } + else -> "unknown" + } + + val totalSize = requestSize + responseSize + val totalSizeWithUnit = + when { + isChunked -> "Chunked Encoding" + totalSize >= 0 -> { + when { + totalSize < 1024 -> "$totalSize B" + else -> "${String.format("%.2f", totalSize / 1024.0)} kB" + } + } + else -> "Unknown Size" + } + + val logs = + LogsEvent( + time = System.currentTimeMillis(), + statusCode = response?.code.toString(), + endPoint = endPoint, + baseUrl = baseUrl, + logType = logType, + method = requestMethod.toString(), + responseBody = responseJsonString, + responseHeader = responseHeaderJsonString, + requestHeader = requestHeaderJsonString, + moduleName = moduleName, + latency = latencyWithUnit, + requestBody = requestJsonString, + requestSize = requestSizeWithUnit, + responseSize = responseSizeWithUnit, + protocol = response?.protocol?.name.toString(), + requestTime = requestTime, + responseTime = responseTime, + totalSize = totalSizeWithUnit, + scheme = scheme, + apiUrl = request?.url.toString(), + errorMessage = errorMessage.toString() + ) + logDao.insertLogs(logs = logs) + } + } + + private fun bodyToString(request: Request?): String { + return try { + val copy = request?.newBuilder()?.build() + val buffer = Buffer() + copy?.body?.writeTo(buffer) + buffer.readUtf8() + } catch (e: IOException) { + "did not work" + } + } + + private fun convertMillisToDateTime(time: Long): String { + val dateFormat = SimpleDateFormat("hh:mm:ss a", Locale.getDefault()) + val date = Date(time) + return dateFormat.format(date) + } + + enum class ReleaseLogType { + NETWORK_LOG + } +} diff --git a/navi-base/src/main/java/com/navi/base/utils/QaReleaseLogUtil.kt b/navi-base/src/main/java/com/navi/base/utils/QaReleaseLogUtil.kt deleted file mode 100644 index b5813dd888..0000000000 --- a/navi-base/src/main/java/com/navi/base/utils/QaReleaseLogUtil.kt +++ /dev/null @@ -1,189 +0,0 @@ -/* - * - * * Copyright © 2023-2024 by Navi Technologies Limited - * * All rights reserved. Strictly confidential - * - */ - -package com.navi.base.utils - -import android.content.Context -import com.google.gson.Gson -import com.google.gson.JsonObject -import com.google.gson.JsonParser -import com.navi.base.BuildConfig -import java.io.File -import java.io.FileOutputStream -import java.io.OutputStreamWriter -import java.net.URL -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale -import java.util.concurrent.Executors -import kotlinx.coroutines.asCoroutineDispatcher -import okhttp3.Request -import okhttp3.Response - -object QaReleaseLogUtil { - - private lateinit var applicationContext: Context - private val coroutineDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher() - var isQaRelease: Boolean = false - - fun init(context: Context, flavour: String) { - applicationContext = context - isQaRelease = flavour == QA && !BuildConfig.DEBUG - } - - fun buildQaReleaseLogMessage( - statusCode: String? = null, - responseData: Any? = null, - logType: String, - request: Request? = null, - data: Map? = null, - response: Response? = null, - requestMethod: String? = null - ) { - if (!isQaRelease) { - return - } - coroutineDispatcher.executor.execute { - val logData = hashMapOf() - val timestamp = - SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) - .format(Date()) - .toString() - var endPoint = "" - when (logType) { - ReleaseLogType.NETWORK_LOG.name -> { - val networkData: HashMap = hashMapOf() - endPoint = URL(request?.url.toString()).path - - val responseJsonString = - try { - Gson().toJson(responseData) - } catch (e: Exception) { - "" - } - val responseObject = - try { - JsonParser.parseString(responseJsonString).asJsonObject - } catch (e: Exception) { - JsonObject() - } - networkData["RESPONSE_BODY"] = responseObject - networkData["RESPONSE_CODE"] = statusCode.toString() - - val requestJsonString = - try { - Gson().toJson(responseData) - } catch (e: Exception) { - "" - } - val requestObject = - try { - JsonParser.parseString(requestJsonString).asJsonObject - } catch (e: Exception) { - JsonObject() - } - networkData["REQUEST_BODY"] = requestObject - networkData["URL"] = request?.url.toString() - - val requestHeaderMap: HashMap = hashMapOf() - request?.headers?.forEach { requestHeaderMap[it.first] = it.second } - - val requestHeaderJsonString = - try { - Gson().toJson(requestHeaderMap) - } catch (e: Exception) { - EMPTY - } - val requestHeaderObject = - try { - JsonParser.parseString(requestHeaderJsonString).asJsonObject - } catch (e: Exception) { - JsonObject() - } - networkData["REQUEST_HEADER"] = requestHeaderObject - - val responseHeaderMap: HashMap = hashMapOf() - response?.headers?.forEach { responseHeaderMap[it.first] = it.second } - - val responseHeaderJsonString = - try { - Gson().toJson(responseHeaderMap) - } catch (e: Exception) { - EMPTY - } - val responseHeaderObject = - try { - JsonParser.parseString(responseHeaderJsonString).asJsonObject - } catch (e: Exception) { - JsonObject() - } - networkData["RESPONSE_HEADER"] = responseHeaderObject - networkData["REQUEST_METHOD"] = requestMethod.toString() - - logData[timestamp] = networkData - } - ReleaseLogType.ANR_LOG.name, - ReleaseLogType.CRASH_LOG.name -> { - logData[timestamp] = data as HashMap - } - else -> {} - } - val logMessage = Gson().toJson(logData) - writeLogToFile( - logMessage = logMessage, - logType, - timestamp, - endPoint, - statusCode, - requestMethod - ) - } - } - - private fun writeLogToFile( - logMessage: String, - logType: String, - timeStamp: String, - url: String, - statusCode: String?, - requestMethod: String? - ) { - val logDirectoryName = QA_RELEASE_LOGS_FOLDER_NAME - val logFileName = QA_RELEASE_LOGS_FILE_NAME - val appInternalStorageDir = applicationContext.filesDir - val logDirectory = File(appInternalStorageDir, logDirectoryName) - if (!logDirectory.exists()) { - logDirectory.mkdirs() - } - val logFile = File(logDirectory, logFileName) - try { - val fileOutputStream = FileOutputStream(logFile, true) - val outputStreamWriter = OutputStreamWriter(fileOutputStream) - - outputStreamWriter.append("\r\n") - if (logType == ReleaseLogType.NETWORK_LOG.name) { - outputStreamWriter.append("\r\n---$statusCode $requestMethod $url $timeStamp------") - } else { - outputStreamWriter.append("\r\n--- $logType $timeStamp------") - } - outputStreamWriter.append("\r\n") - outputStreamWriter.append(logMessage) - outputStreamWriter.flush() - - outputStreamWriter.close() - fileOutputStream.close() - } catch (e: Exception) { - e.printStackTrace() - } - } - - enum class ReleaseLogType { - NETWORK_LOG, - CRASH_LOG, - ANR_LOG - } -} diff --git a/navi-widgets/src/main/res/drawable/ic_filter.xml b/navi-widgets/src/main/res/drawable/ic_filter.xml new file mode 100644 index 0000000000..863fe09938 --- /dev/null +++ b/navi-widgets/src/main/res/drawable/ic_filter.xml @@ -0,0 +1,13 @@ + + + diff --git a/pulse/src/main/java/com/navi/pulse/network/PulseNetworkRepository.kt b/pulse/src/main/java/com/navi/pulse/network/PulseNetworkRepository.kt index c43615b1ab..92de09933c 100644 --- a/pulse/src/main/java/com/navi/pulse/network/PulseNetworkRepository.kt +++ b/pulse/src/main/java/com/navi/pulse/network/PulseNetworkRepository.kt @@ -7,20 +7,20 @@ package com.navi.pulse.network -import com.navi.base.utils.QaReleaseLogUtil -import com.navi.base.utils.QaReleaseLogUtil.buildQaReleaseLogMessage +import com.navi.base.utils.NetWatchManger +import com.navi.base.utils.NetWatchManger.buildLogMessage import retrofit2.Response class PulseNetworkRepository { suspend fun sendEvents(url: String, pulseRequest: PulseRequest): Response { val response = PulseRetrofitProvider.getApiService().sendEvents(url, pulseRequest) - buildQaReleaseLogMessage( + buildLogMessage( responseData = response.body(), - statusCode = response.body()?.code.toString(), request = response.raw().request, - logType = QaReleaseLogUtil.ReleaseLogType.NETWORK_LOG.name, + logType = NetWatchManger.ReleaseLogType.NETWORK_LOG.name, response = response.raw(), - requestMethod = response.raw().request.method + moduleName = "Pulse", + errorMessage = response.errorBody().toString() ) return response }