AppDetailsFragment: Improve data states

This commit is contained in:
Rahul Patel
2024-07-11 04:27:09 +05:30
committed by Aayush Gupta
parent 83d9895f17
commit 660c544f18
5 changed files with 167 additions and 110 deletions

View File

@@ -8,4 +8,5 @@ import com.aurora.gplayapi.helpers.contracts.TopChartsContract
typealias TopChartStash = MutableMap<TopChartsContract.Type, MutableMap<TopChartsContract.Chart, StreamCluster>>
typealias HomeStash = MutableMap<StreamContract.Category, StreamBundle>
typealias CategoryStash = MutableMap<Category.Type, List<Category>>
typealias CategoryStash = MutableMap<Category.Type, List<Category>>
typealias AppStreamStash = MutableMap<String, StreamBundle>

View File

@@ -35,7 +35,7 @@ import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import androidx.core.text.HtmlCompat
import androidx.fragment.app.viewModels
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
@@ -56,6 +56,7 @@ import com.aurora.gplayapi.data.models.Review
import com.aurora.gplayapi.data.models.StreamBundle
import com.aurora.gplayapi.data.models.StreamCluster
import com.aurora.store.AuroraApp
import com.aurora.store.AppStreamStash
import com.aurora.store.R
import com.aurora.store.data.event.BusEvent
import com.aurora.store.data.event.Event
@@ -64,6 +65,7 @@ import com.aurora.store.data.installer.AppInstaller
import com.aurora.store.data.model.DownloadStatus
import com.aurora.store.data.model.State
import com.aurora.store.data.model.ViewState
import com.aurora.store.data.model.ViewState.Loading.getDataAs
import com.aurora.store.data.providers.AuthProvider
import com.aurora.store.databinding.FragmentDetailsBinding
import com.aurora.store.util.CommonUtil
@@ -92,8 +94,8 @@ import javax.inject.Inject
@AndroidEntryPoint
class AppDetailsFragment : BaseFragment<FragmentDetailsBinding>() {
private val viewModel: AppDetailsViewModel by viewModels()
private val detailsClusterViewModel: DetailsClusterViewModel by viewModels()
private val viewModel: AppDetailsViewModel by activityViewModels()
private val detailsClusterViewModel: DetailsClusterViewModel by activityViewModels()
private val args: AppDetailsFragmentArgs by navArgs()
@@ -123,6 +125,8 @@ class AppDetailsFragment : BaseFragment<FragmentDetailsBinding>() {
private lateinit var app: App
private var streamBundle: StreamBundle? = StreamBundle()
private var isExternal = false
private var downloadStatus = DownloadStatus.UNAVAILABLE
private var isUpdatable: Boolean = false
@@ -196,7 +200,7 @@ class AppDetailsFragment : BaseFragment<FragmentDetailsBinding>() {
app.isInstalled = PackageUtil.isInstalled(requireContext(), app.packageName)
// App Details
viewModel.fetchAppDetails(view.context, app.packageName)
viewModel.fetchAppDetails(app.packageName)
viewLifecycleOwner.lifecycleScope.launch {
viewModel.app.collect {
@@ -204,7 +208,7 @@ class AppDetailsFragment : BaseFragment<FragmentDetailsBinding>() {
app = it
inflatePartialApp() // Re-inflate the app details, as web data may vary.
inflateExtraDetails(app)
viewModel.fetchAppReviews(view.context, app.packageName)
viewModel.fetchAppReviews(app.packageName)
} else {
toast("Failed to fetch app details")
}
@@ -266,7 +270,7 @@ class AppDetailsFragment : BaseFragment<FragmentDetailsBinding>() {
// Exodus Privacy Report
viewLifecycleOwner.lifecycleScope.launch {
viewModel.report.collect { report ->
viewModel.exodusReport.collect { report ->
if (report == null) {
binding.layoutDetailsPrivacy.txtStatus.text =
getString(R.string.failed_to_fetch_report)
@@ -801,7 +805,7 @@ class AppDetailsFragment : BaseFragment<FragmentDetailsBinding>() {
}
private fun inflateAppPrivacy(app: App) {
viewModel.fetchAppReport(requireContext(), app.packageName)
viewModel.fetchAppReport(app.packageName)
}
private fun inflateAppDevInfo(app: App) {
@@ -854,7 +858,6 @@ class AppDetailsFragment : BaseFragment<FragmentDetailsBinding>() {
btnBetaAction.text = getString(R.string.action_pending)
btnBetaAction.isEnabled = false
viewModel.fetchTestingProgramStatus(
requireContext(),
app.packageName,
!betaProgram.isSubscribed
)
@@ -881,43 +884,34 @@ class AppDetailsFragment : BaseFragment<FragmentDetailsBinding>() {
}
override fun onClusterScrolled(streamCluster: StreamCluster) {
detailsClusterViewModel.observeCluster(streamCluster)
detailsClusterViewModel.observeCluster(
it,
streamCluster
)
}
override fun onAppClick(app: App) {
openDetailsFragment(app.packageName, app)
}
override fun onAppLongClick(app: App) {
}
override fun onAppLongClick(app: App) {}
})
detailsClusterViewModel.liveData.observe(viewLifecycleOwner) {
when (it) {
is ViewState.Empty -> {
}
is ViewState.Loading -> {
}
is ViewState.Error -> {
}
is ViewState.Status -> {
}
detailsClusterViewModel.liveData.observe(viewLifecycleOwner) { state ->
when (state) {
is ViewState.Success<*> -> {
carouselController.setData(it.data as StreamBundle)
val stash = state.getDataAs<AppStreamStash>()
streamBundle = stash[it]
carouselController.setData(streamBundle)
}
else -> {
}
}
}
binding.epoxyRecyclerStream.setController(carouselController)
detailsClusterViewModel.getStreamBundle(it)
}
}
@@ -925,7 +919,7 @@ class AppDetailsFragment : BaseFragment<FragmentDetailsBinding>() {
private fun inflateAppPermission(app: App) {
binding.layoutDetailsPermissions.apply {
headerPermission.addClickListener {
if (app.permissions.size > 0) {
if (app.permissions.isNotEmpty()) {
findNavController().navigate(
AppDetailsFragmentDirections.actionAppDetailsFragmentToPermissionBottomSheet(
app
@@ -956,6 +950,6 @@ class AppDetailsFragment : BaseFragment<FragmentDetailsBinding>() {
}
private fun addOrUpdateReview(app: App, review: Review, isBeta: Boolean = false) {
viewModel.postAppReview(requireContext(), app.packageName, review, isBeta)
viewModel.postAppReview(app.packageName, review, isBeta)
}
}

View File

@@ -18,6 +18,7 @@ import com.aurora.store.data.room.favourites.FavouriteDao
import com.aurora.store.util.DownloadWorkerUtil
import com.google.gson.GsonBuilder
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
@@ -30,6 +31,7 @@ import javax.inject.Inject
@HiltViewModel
class AppDetailsViewModel @Inject constructor(
@ApplicationContext private val context: Context,
private val downloadWorkerUtil: DownloadWorkerUtil,
private val authProvider: AuthProvider,
private val favouriteDao: FavouriteDao
@@ -40,18 +42,27 @@ class AppDetailsViewModel @Inject constructor(
private val exodusBaseUrl = "https://reports.exodus-privacy.eu.org/api/search/"
private val exodusApiKey = "Token bbe6ebae4ad45a9cbacb17d69739799b8df2c7ae"
private val httpClient = HttpClient.getPreferredClient(context)
private val appDetailsHelper = AppDetailsHelper(authProvider.authData).using(httpClient)
private val reviewsHelper = ReviewsHelper(authProvider.authData).using(httpClient)
private val appStash: MutableMap<String, App> = mutableMapOf()
private val _app = MutableSharedFlow<App>()
val app = _app.asSharedFlow()
private val reviewsStash = mutableMapOf<String, List<Review>>()
private val _reviews = MutableSharedFlow<List<Review>>()
val reviews = _reviews.asSharedFlow()
private val userReviewStash = mutableMapOf<String, Review?>()
private val _userReview = MutableSharedFlow<Review>()
val userReview = _userReview.asSharedFlow()
private val _report = MutableSharedFlow<Report?>()
val report = _report.asSharedFlow()
private val exodusReportStash = mutableMapOf<String, Report?>()
private val _exodusReport = MutableSharedFlow<Report?>()
val exodusReport = _exodusReport.asSharedFlow()
private val testProgramStatusStash = mutableMapOf<String, TestingProgramStatus?>()
private val _testingProgramStatus = MutableSharedFlow<TestingProgramStatus?>()
val testingProgramStatus = _testingProgramStatus.asSharedFlow()
@@ -60,16 +71,16 @@ class AppDetailsViewModel @Inject constructor(
val downloadsList get() = downloadWorkerUtil.downloadsList
fun fetchAppDetails(context: Context, packageName: String) {
fun fetchAppDetails(packageName: String) {
viewModelScope.launch(Dispatchers.IO) {
try {
isFavourite(packageName)
checkFavourite(packageName)
_app.emit(
AppDetailsHelper(authProvider.authData)
.using(HttpClient.getPreferredClient(context))
.getAppByPackageName(packageName)
)
val app: App = appStash.getOrPut(packageName) {
appDetailsHelper.getAppByPackageName(packageName)
}
_app.emit(app)
} catch (exception: Exception) {
Log.e(TAG, "Failed to fetch app details", exception)
_app.emit(App(""))
@@ -77,14 +88,14 @@ class AppDetailsViewModel @Inject constructor(
}
}
fun fetchAppReviews(context: Context, packageName: String) {
fun fetchAppReviews(packageName: String) {
viewModelScope.launch(Dispatchers.IO) {
try {
_reviews.emit(
ReviewsHelper(authProvider.authData)
.using(HttpClient.getPreferredClient(context))
.getReviewSummary(packageName)
)
val reviews = reviewsStash.getOrPut(packageName) {
reviewsHelper.getReviewSummary(packageName)
}
_reviews.emit(reviews)
} catch (exception: Exception) {
Log.e(TAG, "Failed to fetch app reviews", exception)
_reviews.emit(emptyList())
@@ -92,63 +103,60 @@ class AppDetailsViewModel @Inject constructor(
}
}
fun postAppReview(context: Context, packageName: String, review: Review, isBeta: Boolean) {
fun fetchAppReport(packageName: String) {
viewModelScope.launch(Dispatchers.IO) {
try {
_userReview.emit(
ReviewsHelper(authProvider.authData)
.using(HttpClient.getPreferredClient(context))
.addOrEditReview(
packageName,
review.title,
review.comment,
review.rating,
isBeta
)!!
)
} catch (exception: Exception) {
Log.e(TAG, "Failed to post review", exception)
_userReview.emit(Review())
}
}
}
val exodusReport = exodusReportStash.getOrPut(packageName) {
getLatestExodusReport(packageName)
}
fun fetchAppReport(context: Context, packageName: String) {
viewModelScope.launch(Dispatchers.IO) {
try {
val headers: MutableMap<String, String> = mutableMapOf()
headers["Content-Type"] = "application/json"
headers["Accept"] = "application/json"
headers["Authorization"] = exodusApiKey
val url = exodusBaseUrl + packageName
val playResponse = HttpClient.getPreferredClient(context).get(url, headers)
_report.emit(parseResponse(String(playResponse.responseBytes), packageName)[0])
_exodusReport.emit(exodusReport)
} catch (exception: Exception) {
Log.e(TAG, "Failed to fetch privacy report", exception)
_report.emit(null)
exodusReportStash[packageName] = null
_exodusReport.emit(null)
}
}
}
fun fetchTestingProgramStatus(context: Context, packageName: String, subscribe: Boolean) {
fun fetchTestingProgramStatus(packageName: String, subscribe: Boolean) {
viewModelScope.launch(Dispatchers.IO) {
try {
_testingProgramStatus.emit(
AppDetailsHelper(authProvider.authData).testingProgram(
packageName,
subscribe
)
)
val testingProgramStatus = testProgramStatusStash.getOrPut(packageName) {
appDetailsHelper.testingProgram(packageName, subscribe)
}
_testingProgramStatus.emit(testingProgramStatus)
} catch (exception: Exception) {
Log.e(TAG, "Failed to fetch testing program status", exception)
testProgramStatusStash[packageName] = null
_testingProgramStatus.emit(null)
}
}
}
fun postAppReview(packageName: String, review: Review, isBeta: Boolean) {
viewModelScope.launch(Dispatchers.IO) {
try {
val userReview = userReviewStash.getOrPut(packageName) {
reviewsHelper.addOrEditReview(
packageName,
review.title,
review.comment,
review.rating,
isBeta
)
}
if (userReview != null) {
_userReview.emit(userReview)
}
} catch (exception: Exception) {
Log.e(TAG, "Failed to post review", exception)
}
}
}
fun download(app: App) {
viewModelScope.launch { downloadWorkerUtil.enqueueApp(app) }
}
@@ -157,12 +165,6 @@ class AppDetailsViewModel @Inject constructor(
viewModelScope.launch { downloadWorkerUtil.cancelDownload(app.packageName) }
}
private fun isFavourite(packageName: String) {
viewModelScope.launch(Dispatchers.IO) {
_favourite.value = favouriteDao.isFavourite(packageName)
}
}
fun toggleFavourite(app: App) {
viewModelScope.launch(Dispatchers.IO) {
if (favourite.value) {
@@ -183,9 +185,31 @@ class AppDetailsViewModel @Inject constructor(
}
}
private fun parseResponse(response: String, packageName: String): List<Report> {
private fun checkFavourite(packageName: String) {
viewModelScope.launch(Dispatchers.IO) {
_favourite.value = favouriteDao.isFavourite(packageName)
}
}
private fun getLatestExodusReport(packageName: String): Report? {
val headers: MutableMap<String, String> = mutableMapOf()
headers["Content-Type"] = "application/json"
headers["Accept"] = "application/json"
headers["Authorization"] = exodusApiKey
val url = exodusBaseUrl + packageName
val playResponse = httpClient.get(url, headers)
val report = parseExodusResponse(String(playResponse.responseBytes), packageName)
.firstOrNull()
return report
}
private fun parseExodusResponse(response: String, packageName: String): List<Report> {
try {
val gson = GsonBuilder().excludeFieldsWithModifiers(Modifier.TRANSIENT, Modifier.STATIC)
val gson = GsonBuilder()
.excludeFieldsWithModifiers(Modifier.TRANSIENT, Modifier.STATIC)
.create()
val jsonObject = JSONObject(response)
@@ -193,9 +217,10 @@ class AppDetailsViewModel @Inject constructor(
val exodusReport: ExodusReport = gson.fromJson(
exodusObject.toString(), ExodusReport::class.java
)
return exodusReport.reports
} catch (e: Exception) {
throw Exception("No reports found")
return emptyList()
}
}
}

View File

@@ -19,7 +19,6 @@
package com.aurora.store.viewmodel.details
import android.annotation.SuppressLint
import android.content.Context
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
@@ -29,6 +28,7 @@ import com.aurora.gplayapi.data.models.StreamCluster
import com.aurora.gplayapi.helpers.AppDetailsHelper
import com.aurora.gplayapi.helpers.StreamHelper
import com.aurora.gplayapi.helpers.contracts.StreamContract
import com.aurora.store.AppStreamStash
import com.aurora.store.data.model.ViewState
import com.aurora.store.data.network.HttpClient
import com.aurora.store.data.providers.AuthProvider
@@ -41,18 +41,18 @@ import kotlinx.coroutines.supervisorScope
import javax.inject.Inject
@HiltViewModel
@SuppressLint("StaticFieldLeak") // false positive, see https://github.com/google/dagger/issues/3253
class DetailsClusterViewModel @Inject constructor(
@ApplicationContext private val context: Context,
private val authProvider: AuthProvider
authProvider: AuthProvider
) : ViewModel() {
private var appDetailsHelper = AppDetailsHelper(authProvider.authData)
.using(HttpClient.getPreferredClient(context))
private var streamHelper = StreamHelper(authProvider.authData)
val liveData: MutableLiveData<ViewState> = MutableLiveData()
var streamBundle: StreamBundle = StreamBundle()
private val stash: AppStreamStash = mutableMapOf()
lateinit var type: StreamContract.Type
lateinit var category: StreamContract.Category
@@ -60,9 +60,22 @@ class DetailsClusterViewModel @Inject constructor(
fun getStreamBundle(streamUrl: String) {
viewModelScope.launch(Dispatchers.IO) {
supervisorScope {
val bundle = targetBundle(streamUrl)
if (bundle.streamClusters.isNotEmpty()) {
liveData.postValue(ViewState.Success(stash))
}
try {
streamBundle = appDetailsHelper.getDetailsStream(streamUrl)
liveData.postValue(ViewState.Success(streamBundle))
if (!bundle.hasCluster() || bundle.hasNext()) {
val newBundle = appDetailsHelper.getDetailsStream(streamUrl)
bundle.apply {
streamClusters.putAll(newBundle.streamClusters)
streamNextPageUrl = newBundle.streamNextPageUrl
}
liveData.postValue(ViewState.Success(stash))
}
} catch (e: Exception) {
liveData.postValue(ViewState.Error(e.message))
}
@@ -70,15 +83,15 @@ class DetailsClusterViewModel @Inject constructor(
}
}
fun observeCluster(streamCluster: StreamCluster) {
fun observeCluster(url: String, streamCluster: StreamCluster) {
viewModelScope.launch(Dispatchers.IO) {
supervisorScope {
try {
if (streamCluster.hasNext()) {
val newCluster =
streamHelper.getNextStreamCluster(streamCluster.clusterNextPageUrl)
updateCluster(newCluster)
liveData.postValue(ViewState.Success(streamBundle))
updateCluster(url, streamCluster.id, newCluster)
liveData.postValue(ViewState.Success(stash))
} else {
Log.i("End of cluster")
streamCluster.clusterNextPageUrl = String()
@@ -90,10 +103,22 @@ class DetailsClusterViewModel @Inject constructor(
}
}
private fun updateCluster(newCluster: StreamCluster) {
streamBundle.streamClusters[newCluster.id]?.apply {
private fun updateCluster(
url: String,
clusterID: Int,
newCluster: StreamCluster
) {
targetBundle(url).streamClusters[clusterID]?.apply {
clusterAppList.addAll(newCluster.clusterAppList)
clusterNextPageUrl = newCluster.clusterNextPageUrl
}
}
private fun targetBundle(url: String): StreamBundle {
val streamBundle = stash.getOrPut(url) {
StreamBundle()
}
return streamBundle
}
}

View File

@@ -1,12 +1,15 @@
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.helpers.AppDetailsHelper
import com.aurora.store.data.network.HttpClient
import com.aurora.store.data.providers.AuthProvider
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
@@ -15,21 +18,30 @@ import javax.inject.Inject
@HiltViewModel
class DetailsMoreViewModel @Inject constructor(
@ApplicationContext private val context: Context,
private val authProvider: AuthProvider
) : ViewModel() {
private val TAG = DetailsMoreViewModel::class.java.simpleName
private val appDetailsHelper = AppDetailsHelper(authProvider.authData)
.using(HttpClient.getPreferredClient(context))
private val dependantAppsStash = mutableMapOf<String, List<App>>()
private val _dependentApps = MutableSharedFlow<List<App>>()
val dependentApps = _dependentApps.asSharedFlow()
fun fetchDependentApps(app: App) {
viewModelScope.launch(Dispatchers.IO) {
try {
_dependentApps.emit(AppDetailsHelper(authProvider.authData)
.getAppByPackageName(app.dependencies.dependentPackages))
val dependantApps = dependantAppsStash.getOrPut(app.packageName) {
appDetailsHelper.getAppByPackageName(app.dependencies.dependentPackages)
}
_dependentApps.emit(dependantApps)
} catch (exception: Exception) {
Log.e(TAG, "Failed to fetch dependencies", exception)
dependantAppsStash[app.packageName] = emptyList()
_dependentApps.emit(emptyList())
}
}