diff --git a/app/src/main/java/com/aurora/store/Aliases.kt b/app/src/main/java/com/aurora/store/Aliases.kt index 7b2929f9f..0e3d8b894 100644 --- a/app/src/main/java/com/aurora/store/Aliases.kt +++ b/app/src/main/java/com/aurora/store/Aliases.kt @@ -8,4 +8,5 @@ import com.aurora.gplayapi.helpers.contracts.TopChartsContract typealias TopChartStash = MutableMap> typealias HomeStash = MutableMap -typealias CategoryStash = MutableMap> \ No newline at end of file +typealias CategoryStash = MutableMap> +typealias AppStreamStash = MutableMap \ No newline at end of file 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 498a4a22f..53eccf6bb 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,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() { - 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() { 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() { 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() { 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() { // 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() { } 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() { btnBetaAction.text = getString(R.string.action_pending) btnBetaAction.isEnabled = false viewModel.fetchTestingProgramStatus( - requireContext(), app.packageName, !betaProgram.isSubscribed ) @@ -881,43 +884,34 @@ class AppDetailsFragment : BaseFragment() { } 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() + streamBundle = stash[it] + carouselController.setData(streamBundle) + } + + else -> { + } } } binding.epoxyRecyclerStream.setController(carouselController) - detailsClusterViewModel.getStreamBundle(it) } } @@ -925,7 +919,7 @@ class AppDetailsFragment : BaseFragment() { 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() { } private fun addOrUpdateReview(app: App, review: Review, isBeta: Boolean = false) { - viewModel.postAppReview(requireContext(), app.packageName, review, isBeta) + viewModel.postAppReview(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 index aba8e6453..5ca41fdd6 100644 --- a/app/src/main/java/com/aurora/store/viewmodel/details/AppDetailsViewModel.kt +++ b/app/src/main/java/com/aurora/store/viewmodel/details/AppDetailsViewModel.kt @@ -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 = mutableMapOf() private val _app = MutableSharedFlow() val app = _app.asSharedFlow() + private val reviewsStash = mutableMapOf>() private val _reviews = MutableSharedFlow>() val reviews = _reviews.asSharedFlow() + private val userReviewStash = mutableMapOf() private val _userReview = MutableSharedFlow() val userReview = _userReview.asSharedFlow() - private val _report = MutableSharedFlow() - val report = _report.asSharedFlow() + private val exodusReportStash = mutableMapOf() + private val _exodusReport = MutableSharedFlow() + val exodusReport = _exodusReport.asSharedFlow() + private val testProgramStatusStash = mutableMapOf() private val _testingProgramStatus = MutableSharedFlow() 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 = 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 { + private fun checkFavourite(packageName: String) { + viewModelScope.launch(Dispatchers.IO) { + _favourite.value = favouriteDao.isFavourite(packageName) + } + } + + private fun getLatestExodusReport(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.get(url, headers) + + val report = parseExodusResponse(String(playResponse.responseBytes), packageName) + .firstOrNull() + + return report + } + + private fun parseExodusResponse(response: String, packageName: String): List { 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() } } } diff --git a/app/src/main/java/com/aurora/store/viewmodel/details/DetailsClusterViewModel.kt b/app/src/main/java/com/aurora/store/viewmodel/details/DetailsClusterViewModel.kt index cd5fb7755..3d3d1bb4e 100644 --- a/app/src/main/java/com/aurora/store/viewmodel/details/DetailsClusterViewModel.kt +++ b/app/src/main/java/com/aurora/store/viewmodel/details/DetailsClusterViewModel.kt @@ -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 = 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 + } } diff --git a/app/src/main/java/com/aurora/store/viewmodel/details/DetailsMoreViewModel.kt b/app/src/main/java/com/aurora/store/viewmodel/details/DetailsMoreViewModel.kt index cbb890213..48420a380 100644 --- a/app/src/main/java/com/aurora/store/viewmodel/details/DetailsMoreViewModel.kt +++ b/app/src/main/java/com/aurora/store/viewmodel/details/DetailsMoreViewModel.kt @@ -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>() private val _dependentApps = MutableSharedFlow>() 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()) } }