From 1156f584d25e6274619f882fde4f7cd9c369785b Mon Sep 17 00:00:00 2001 From: Aayush Gupta Date: Fri, 21 Jul 2023 00:02:30 +0530 Subject: [PATCH] AppDetailsFragment: Drop dependency upon kovenant Signed-off-by: Aayush Gupta --- .../view/ui/details/AppDetailsFragment.kt | 333 +++++++----------- .../viewmodel/details/AppDetailsViewModel.kt | 159 +++++++++ app/src/main/res/values/strings.xml | 1 + 3 files changed, 287 insertions(+), 206 deletions(-) create mode 100644 app/src/main/java/com/aurora/store/viewmodel/details/AppDetailsViewModel.kt diff --git a/app/src/main/java/com/aurora/store/view/ui/details/AppDetailsFragment.kt b/app/src/main/java/com/aurora/store/view/ui/details/AppDetailsFragment.kt index 2ac3a7956..7c5f8f482 100644 --- a/app/src/main/java/com/aurora/store/view/ui/details/AppDetailsFragment.kt +++ b/app/src/main/java/com/aurora/store/view/ui/details/AppDetailsFragment.kt @@ -35,11 +35,12 @@ import android.view.View import android.widget.LinearLayout import android.widget.RelativeLayout import android.widget.Toast -import androidx.activity.addCallback import androidx.activity.result.contract.ActivityResultContracts import androidx.core.content.ContextCompat import androidx.core.text.HtmlCompat +import androidx.fragment.app.viewModels import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs import com.airbnb.epoxy.EpoxyRecyclerView @@ -53,15 +54,12 @@ import com.aurora.extensions.runOnUiThread import com.aurora.extensions.share import com.aurora.extensions.show import com.aurora.extensions.showDialog -import com.aurora.extensions.stackTraceToString import com.aurora.extensions.toast import com.aurora.gplayapi.data.models.App import com.aurora.gplayapi.data.models.AuthData import com.aurora.gplayapi.data.models.Review import com.aurora.gplayapi.data.models.StreamBundle import com.aurora.gplayapi.data.models.StreamCluster -import com.aurora.gplayapi.helpers.AppDetailsHelper -import com.aurora.gplayapi.helpers.ReviewsHelper import com.aurora.store.R import com.aurora.store.State import com.aurora.store.data.ViewState @@ -70,9 +68,6 @@ import com.aurora.store.data.downloader.getGroupId import com.aurora.store.data.event.BusEvent import com.aurora.store.data.event.InstallerEvent import com.aurora.store.data.installer.AppInstaller -import com.aurora.store.data.model.ExodusReport -import com.aurora.store.data.model.Report -import com.aurora.store.data.network.HttpClient import com.aurora.store.data.providers.AuthProvider import com.aurora.store.data.service.AppMetadataStatusListener import com.aurora.store.data.service.UpdateService @@ -81,7 +76,6 @@ import com.aurora.store.databinding.LayoutDetailsBetaBinding import com.aurora.store.databinding.LayoutDetailsDescriptionBinding import com.aurora.store.databinding.LayoutDetailsDevBinding import com.aurora.store.databinding.LayoutDetailsPermissionsBinding -import com.aurora.store.databinding.LayoutDetailsPrivacyBinding import com.aurora.store.databinding.LayoutDetailsReviewBinding import com.aurora.store.util.CommonUtil import com.aurora.store.util.Log @@ -99,6 +93,7 @@ import com.aurora.store.view.ui.commons.BaseFragment import com.aurora.store.view.ui.sheets.InstallErrorDialogSheet import com.aurora.store.view.ui.sheets.ManualDownloadSheet import com.aurora.store.view.ui.sheets.PermissionBottomSheet +import com.aurora.store.viewmodel.details.AppDetailsViewModel import com.aurora.store.viewmodel.details.DetailsClusterViewModel import com.bumptech.glide.load.resource.bitmap.RoundedCorners import com.google.android.material.bottomsheet.BottomSheetBehavior @@ -111,15 +106,12 @@ import com.tonyodev.fetch2.FetchGroup import com.tonyodev.fetch2.FetchGroupListener import com.tonyodev.fetch2.Status import com.tonyodev.fetch2core.DownloadBlock -import nl.komponents.kovenant.task -import nl.komponents.kovenant.ui.failUi -import nl.komponents.kovenant.ui.successUi import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode -import org.json.JSONObject import java.io.File import java.util.Locale +import kotlinx.coroutines.launch class AppDetailsFragment : BaseFragment(R.layout.fragment_details) { @@ -127,13 +119,12 @@ class AppDetailsFragment : BaseFragment(R.layout.fragment_details) { private val binding: FragmentDetailsBinding get() = _binding!! + private val viewModel: AppDetailsViewModel by viewModels() + private val args: AppDetailsFragmentArgs by navArgs() private lateinit var bottomSheetBehavior: BottomSheetBehavior - private val exodusBaseUrl = "https://reports.exodus-privacy.eu.org/api/search/" - private val exodusApiKey = "Token bbe6ebae4ad45a9cbacb17d69739799b8df2c7ae" - private val startForStorageManagerResult = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { if (isRAndAbove() && Environment.isExternalStorageManager()) { @@ -254,16 +245,119 @@ class AppDetailsFragment : BaseFragment(R.layout.fragment_details) { super.onViewCreated(view, savedInstanceState) _binding = FragmentDetailsBinding.bind(view) + // TODO: Move to viewModel + authData = AuthProvider.with(view.context).getAuthData() + if (args.app != null) { app = args.app!! isInstalled = PackageUtil.isInstalled(requireContext(), app.packageName) inflatePartialApp() - fetchCompleteApp() } else { isExternal = true app = App(args.packageName) - fetchCompleteApp() + } + + // App Details + viewModel.fetchAppDetails(view.context, app.packageName) + viewLifecycleOwner.lifecycleScope.launch { + viewModel.app.collect { + if (app.packageName.isNotBlank()) { + if (isExternal) { + app = it + inflatePartialApp() + } + inflateExtraDetails(it) + viewModel.fetchAppReviews(view.context, app.packageName) + } else { + toast("Failed to fetch app details") + } + } + } + + // Reviews + viewLifecycleOwner.lifecycleScope.launch { + viewModel.reviews.collect { + binding.layoutDetailsReview.epoxyRecycler.withModels { + it.take(4).forEach { add(ReviewViewModel_().id(it.timeStamp).review(it)) } + } + } + } + + viewLifecycleOwner.lifecycleScope.launch { + viewModel.userReview.collect { + if (it.timeStamp == 0L) { + binding.layoutDetailsReview.userStars.rating = it.rating.toFloat() + Toast.makeText( + requireContext(), + getString(R.string.toast_rated_success), + Toast.LENGTH_SHORT + ).show() + } else { + Toast.makeText( + requireContext(), + getString(R.string.toast_rated_failed), + Toast.LENGTH_SHORT + ).show() + } + } + } + + // Report (Exodus Privacy) + viewLifecycleOwner.lifecycleScope.launch { + viewModel.report.collect { report -> + if (report == null) { + binding.layoutDetailsPrivacy.txtStatus.text = + getString(R.string.failed_to_fetch_report) + return@collect + } + + if (report.trackers.isNotEmpty()) { + binding.layoutDetailsPrivacy.txtStatus.apply { + setTextColor( + ContextCompat.getColor( + requireContext(), + if (report.trackers.size > 4) + R.color.colorRed + else + R.color.colorOrange + ) + ) + text = "${report.trackers.size} ${getString(R.string.exodus_substring)} ${report.version}" + } + + binding.layoutDetailsPrivacy.headerPrivacy.addClickListener { + findNavController().navigate(AppDetailsFragmentDirections + .actionAppDetailsFragmentToDetailsExodusFragment(report) + ) + } + } else { + binding.layoutDetailsPrivacy.txtStatus.apply { + setTextColor(ContextCompat.getColor(requireContext(), R.color.colorGreen)) + text = getString(R.string.exodus_no_tracker) + } + } + } + } + + // Beta program + viewLifecycleOwner.lifecycleScope.launch { + viewModel.testingProgramStatus.collect { + if (it != null) { + binding.layoutDetailsBeta.btnBetaAction.isEnabled = true + if (it.subscribed) { + updateBetaActions(binding.layoutDetailsBeta, true) + } + if (it.unsubscribed) { + updateBetaActions(binding.layoutDetailsBeta, false) + } + } else { + app.testingProgram?.let { testingProgram -> + updateBetaActions(binding.layoutDetailsBeta, testingProgram.isSubscribed) + toast(getString(R.string.details_beta_delay)) + } + } + } } // Toolbar @@ -367,16 +461,7 @@ class AppDetailsFragment : BaseFragment(R.layout.fragment_details) { if (apkFiles.size > 1 && preferredInstaller == 1) { showDialog(R.string.title_installer, R.string.dialog_desc_native_split) } else { - task { - AppInstaller.getInstance(requireContext()) - .getPreferredInstaller() - .install( - app.packageName, - apkFiles.map { it.file } - ) - } fail { - Log.e(it.stackTraceToString()) - } + viewModel.install(requireContext(), app.packageName, apkFiles.map { it.file }) runOnUiThread { binding.layoutDetailsInstall.btnDownload.setText(getString(R.string.action_installing)) @@ -386,34 +471,14 @@ class AppDetailsFragment : BaseFragment(R.layout.fragment_details) { @Synchronized private fun uninstallApp() { - task { - AppInstaller.getInstance(requireContext()) - .getPreferredInstaller() - .uninstall(app.packageName) - } + AppInstaller.getInstance(requireContext()).getPreferredInstaller() + .uninstall(app.packageName) } private fun attachWhiteListStatus() { } - private fun fetchCompleteApp() { - task { - authData = AuthProvider.with(requireContext()).getAuthData() - return@task AppDetailsHelper(authData) - .using(HttpClient.getPreferredClient()) - .getAppByPackageName(app.packageName) - } successUi { - if (isExternal) { - app = it - inflatePartialApp() - } - inflateExtraDetails(it) - } failUi { - toast("Failed to fetch app details") - } - } - private fun inflatePartialApp() { if (::app.isInitialized) { attachWhiteListStatus() @@ -467,7 +532,7 @@ class AppDetailsFragment : BaseFragment(R.layout.fragment_details) { inflateAppDescription(binding.layoutDetailDescription, app) inflateAppRatingAndReviews(binding.layoutDetailsReview, app) inflateAppDevInfo(binding.layoutDetailsDev, app) - inflateAppPrivacy(binding.layoutDetailsPrivacy, app) + inflateAppPrivacy(app) inflateAppPermission(binding.layoutDetailsPermissions, app) if (!authData.isAnonymous) { @@ -957,7 +1022,7 @@ class AppDetailsFragment : BaseFragment(R.layout.fragment_details) { if (authData.isAnonymous) { toast(R.string.toast_anonymous_restriction) } else { - addOrUpdateReview(B, app, Review().apply { + addOrUpdateReview(app, Review().apply { title = authData.userProfile!!.name rating = B.userStars.rating.toInt() comment = B.inputReview.text.toString() @@ -973,66 +1038,10 @@ class AppDetailsFragment : BaseFragment(R.layout.fragment_details) { ) ) } - - task { - fetchReviewSummary(app) - } successUi { - B.epoxyRecycler.withModels { - it.take(4) - .forEach { - add( - ReviewViewModel_() - .id(it.timeStamp) - .review(it) - ) - } - } - } failUi { - - } - } - private fun inflateAppPrivacy(B: LayoutDetailsPrivacyBinding, app: App) { - - task { - fetchReport(app.packageName) - } successUi { report -> - if (report.trackers.isNotEmpty()) { - B.txtStatus.apply { - setTextColor( - ContextCompat.getColor( - requireContext(), - if (report.trackers.size > 4) - R.color.colorRed - else - R.color.colorOrange - ) - ) - text = - ("${report.trackers.size} ${getString(R.string.exodus_substring)} ${report.version}") - } - - B.headerPrivacy.addClickListener { - findNavController().navigate( - AppDetailsFragmentDirections - .actionAppDetailsFragmentToDetailsExodusFragment(report) - ) - } - } else { - B.txtStatus.apply { - setTextColor( - ContextCompat.getColor( - requireContext(), - R.color.colorGreen - ) - ) - text = getString(R.string.exodus_no_tracker) - } - } - } failUi { - B.txtStatus.text = it.message - } + private fun inflateAppPrivacy(app: App) { + viewModel.fetchAppReport(app.packageName) } private fun inflateAppDevInfo(B: LayoutDetailsDevBinding, app: App) { @@ -1079,26 +1088,13 @@ class AppDetailsFragment : BaseFragment(R.layout.fragment_details) { } B.btnBetaAction.setOnClickListener { - val authData = AuthProvider.with(requireContext()).getAuthData() - task { - B.btnBetaAction.text = getString(R.string.action_pending) - B.btnBetaAction.isEnabled = false - AppDetailsHelper(authData).testingProgram( - app.packageName, - !betaProgram.isSubscribed - ) - } successUi { - B.btnBetaAction.isEnabled = true - if (it.subscribed) { - updateBetaActions(B, true) - } - if (it.unsubscribed) { - updateBetaActions(B, false) - } - } failUi { - updateBetaActions(B, betaProgram.isSubscribed) - toast(getString(R.string.details_beta_delay)) - } + B.btnBetaAction.text = getString(R.string.action_pending) + B.btnBetaAction.isEnabled = false + viewModel.fetchTestingProgramStatus( + requireContext(), + app.packageName, + !betaProgram.isSubscribed + ) } } else { B.root.hide() @@ -1190,82 +1186,7 @@ class AppDetailsFragment : BaseFragment(R.layout.fragment_details) { return RatingView(requireContext(), number, max.toInt(), rating.toInt()) } - private fun addOrUpdateReview( - B: LayoutDetailsReviewBinding, - app: App, - review: Review, - isBeta: Boolean = false - ) { - task { - val authData = AuthProvider.with(requireContext()).getAuthData() - ReviewsHelper(authData) - .using(HttpClient.getPreferredClient()) - .addOrEditReview( - app.packageName, - review.title, - review.comment, - review.rating, - isBeta - ) - }.successUi { - it?.let { - B.userStars.rating = it.rating.toFloat() - Toast.makeText( - requireContext(), - getString(R.string.toast_rated_success), - Toast.LENGTH_SHORT - ).show() - } - }.failUi { - Toast.makeText( - requireContext(), - getString(R.string.toast_rated_failed), - Toast.LENGTH_SHORT - ).show() - } - } - - private fun fetchReviewSummary(app: App): List { - val authData = AuthProvider - .with(requireContext()) - .getAuthData() - val reviewsHelper = ReviewsHelper(authData) - .using(HttpClient.getPreferredClient()) - return reviewsHelper.getReviewSummary(app.packageName) - } - - /* App Privacy Helpers */ - - private fun parseResponse(response: String, packageName: String): List { - try { - val jsonObject = JSONObject(response) - val exodusObject = jsonObject.getJSONObject(packageName) - val exodusReport: ExodusReport = gson.fromJson( - exodusObject.toString(), - ExodusReport::class.java - ) - return exodusReport.reports - } catch (e: Exception) { - throw Exception("No reports found") - } - } - - private fun fetchReport(packageName: String): Report { - val headers: MutableMap = mutableMapOf() - headers["Content-Type"] = "application/json" - headers["Accept"] = "application/json" - headers["Authorization"] = exodusApiKey - - val url = exodusBaseUrl + packageName - - val playResponse = HttpClient - .getPreferredClient() - .get(url, headers) - - if (playResponse.isSuccessful) { - return parseResponse(String(playResponse.responseBytes), packageName)[0] - } else { - throw Exception("Failed to fetch report") - } + private fun addOrUpdateReview(app: App, review: Review, isBeta: Boolean = false) { + viewModel.postAppReview(requireContext(), app.packageName, review, isBeta) } } diff --git a/app/src/main/java/com/aurora/store/viewmodel/details/AppDetailsViewModel.kt b/app/src/main/java/com/aurora/store/viewmodel/details/AppDetailsViewModel.kt new file mode 100644 index 000000000..e9cc77f50 --- /dev/null +++ b/app/src/main/java/com/aurora/store/viewmodel/details/AppDetailsViewModel.kt @@ -0,0 +1,159 @@ +package com.aurora.store.viewmodel.details + +import android.content.Context +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.aurora.gplayapi.data.models.App +import com.aurora.gplayapi.data.models.Review +import com.aurora.gplayapi.data.models.details.TestingProgramStatus +import com.aurora.gplayapi.helpers.AppDetailsHelper +import com.aurora.gplayapi.helpers.ReviewsHelper +import com.aurora.store.data.installer.AppInstaller +import com.aurora.store.data.model.ExodusReport +import com.aurora.store.data.model.Report +import com.aurora.store.data.network.HttpClient +import com.aurora.store.data.providers.AuthProvider +import com.google.gson.GsonBuilder +import java.lang.reflect.Modifier +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch +import org.json.JSONObject + +class AppDetailsViewModel : ViewModel() { + + private val TAG = AppDetailsViewModel::class.java.simpleName + + private val exodusBaseUrl = "https://reports.exodus-privacy.eu.org/api/search/" + private val exodusApiKey = "Token bbe6ebae4ad45a9cbacb17d69739799b8df2c7ae" + + private val _app = MutableSharedFlow() + val app = _app.asSharedFlow() + + private val _reviews = MutableSharedFlow>() + val reviews = _reviews.asSharedFlow() + + private val _userReview = MutableSharedFlow() + val userReview = _userReview.asSharedFlow() + + private val _report = MutableSharedFlow() + val report = _report.asSharedFlow() + + private val _testingProgramStatus = MutableSharedFlow() + val testingProgramStatus = _testingProgramStatus.asSharedFlow() + + fun fetchAppDetails(context: Context, packageName: String) { + viewModelScope.launch(Dispatchers.IO) { + try { + val authData = AuthProvider.with(context).getAuthData() + _app.emit( + AppDetailsHelper(authData).using(HttpClient.getPreferredClient()) + .getAppByPackageName(packageName) + ) + } catch (exception: Exception) { + Log.e(TAG, "Failed to fetch app details", exception) + _app.emit(App("")) + } + } + } + + fun fetchAppReviews(context: Context, packageName: String) { + viewModelScope.launch(Dispatchers.IO) { + try { + val authData = AuthProvider.with(context).getAuthData() + _reviews.emit(ReviewsHelper(authData).using(HttpClient.getPreferredClient()) + .getReviewSummary(packageName)) + } catch (exception: Exception) { + Log.e(TAG, "Failed to fetch app reviews", exception) + _reviews.emit(emptyList()) + } + } + } + + fun postAppReview(context: Context, packageName: String, review: Review, isBeta: Boolean) { + viewModelScope.launch(Dispatchers.IO) { + try { + val authData = AuthProvider.with(context).getAuthData() + _userReview.emit(ReviewsHelper(authData) + .using(HttpClient.getPreferredClient()) + .addOrEditReview( + packageName, + review.title, + review.comment, + review.rating, + isBeta + )!!) + } catch (exception: Exception) { + Log.e(TAG, "Failed to post review", exception) + _userReview.emit(Review()) + } + } + } + + + fun fetchAppReport(packageName: String) { + viewModelScope.launch(Dispatchers.IO) { + try { + val headers: MutableMap = mutableMapOf() + headers["Content-Type"] = "application/json" + headers["Accept"] = "application/json" + headers["Authorization"] = exodusApiKey + + val url = exodusBaseUrl + packageName + val playResponse = HttpClient.getPreferredClient().get(url, headers) + + _report.emit(parseResponse(String(playResponse.responseBytes), packageName)[0]) + } catch (exception: Exception) { + Log.e(TAG, "Failed to fetch privacy report", exception) + _report.emit(null) + } + } + } + + fun fetchTestingProgramStatus(context: Context, packageName: String, subscribe: Boolean) { + viewModelScope.launch(Dispatchers.IO) { + try { + val authData = AuthProvider.with(context).getAuthData() + _testingProgramStatus.emit( + AppDetailsHelper(authData).testingProgram( + packageName, + subscribe + ) + ) + } catch (exception: Exception) { + Log.e(TAG, "Failed to fetch testing program status", exception) + _testingProgramStatus.emit(null) + } + } + } + + @Synchronized + fun install(context: Context, packageName: String, files: List) { + try { + viewModelScope.launch(Dispatchers.IO) { + AppInstaller.getInstance(context).getPreferredInstaller() + .install(packageName, files) + } + } catch (exception: Exception) { + Log.e(TAG, "Failed to install app", exception) + } + } + + private fun parseResponse(response: String, packageName: String): List { + try { + val gson = GsonBuilder().excludeFieldsWithModifiers(Modifier.TRANSIENT, Modifier.STATIC) + .create() + + val jsonObject = JSONObject(response) + val exodusObject = jsonObject.getJSONObject(packageName) + val exodusReport: ExodusReport = gson.fromJson( + exodusObject.toString(), ExodusReport::class.java + ) + return exodusReport.reports + } catch (e: Exception) { + throw Exception("No reports found") + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9fc0647b6..7e555e137 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -365,4 +365,5 @@ Checking for updates Automated updates check Automatically check & notify for new app updates periodically + Failed to fetch privacy report