compose: installed: Migrate installed apps logic to compose

Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
This commit is contained in:
Aayush Gupta
2025-12-16 17:03:38 +08:00
parent 38e2e938e2
commit 368dfb4ea1
9 changed files with 166 additions and 341 deletions

View File

@@ -27,6 +27,7 @@ import com.aurora.store.compose.ui.dev.DevProfileScreen
import com.aurora.store.compose.ui.dispenser.DispenserScreen
import com.aurora.store.compose.ui.downloads.DownloadsScreen
import com.aurora.store.compose.ui.favourite.FavouriteScreen
import com.aurora.store.compose.ui.installed.InstalledScreen
import com.aurora.store.compose.ui.onboarding.OnboardingScreen
import com.aurora.store.compose.ui.preferences.installation.InstallerScreen
import com.aurora.store.compose.ui.search.SearchScreen
@@ -145,6 +146,15 @@ fun NavDisplay(startDestination: NavKey) {
entry<Screen.Installer> {
InstallerScreen(onNavigateUp = ::onNavigateUp)
}
entry<Screen.Installed> {
InstalledScreen(
onNavigateUp = ::onNavigateUp,
onNavigateToAppDetails = { packageName ->
backstack.add(Screen.AppDetails(packageName))
}
)
}
}
)
}

View File

@@ -60,4 +60,7 @@ sealed class Screen : NavKey, Parcelable {
@Serializable
data object Installer : Screen()
@Serializable
data object Installed : Screen()
}

View File

@@ -0,0 +1,127 @@
/*
* SPDX-FileCopyrightText: 2025 The Calyx Institute
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.store.compose.ui.installed
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.paging.LoadState
import androidx.paging.PagingData
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems
import androidx.paging.compose.itemKey
import com.aurora.extensions.emptyPagingItems
import com.aurora.gplayapi.data.models.App
import com.aurora.store.R
import com.aurora.store.compose.composable.ContainedLoadingIndicator
import com.aurora.store.compose.composable.Error
import com.aurora.store.compose.composable.TopAppBar
import com.aurora.store.compose.composable.app.LargeAppListItem
import com.aurora.store.compose.preview.AppPreviewProvider
import com.aurora.store.compose.preview.PreviewTemplate
import com.aurora.store.viewmodel.all.InstalledViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlin.random.Random
@Composable
fun InstalledScreen(
onNavigateUp: () -> Unit,
onNavigateToAppDetails: (packageName: String) -> Unit,
viewModel: InstalledViewModel = hiltViewModel()
) {
val apps = viewModel.apps.collectAsLazyPagingItems()
ScreenContent(
apps = apps,
onNavigateUp = onNavigateUp,
onNavigateToAppDetails = onNavigateToAppDetails
)
}
@Composable
private fun ScreenContent(
onNavigateUp: () -> Unit = {},
apps: LazyPagingItems<App> = emptyPagingItems(),
onNavigateToAppDetails: (packageName: String) -> Unit = {}
) {
/*
* For some reason paging3 frequently out-of-nowhere invalidates the list which causes
* the loading animation to play again even if the keys are same causing a glitching effect.
*
* Save the initial loading state to make sure we don't replay the loading animation again.
*/
var initialLoad by rememberSaveable { mutableStateOf(true) }
Scaffold(
topBar = {
TopAppBar(
title = stringResource(R.string.title_apps_games),
onNavigateUp = onNavigateUp
)
}
) { paddingValues ->
Column(
modifier = Modifier
.padding(paddingValues)
.fillMaxSize()
) {
when {
apps.loadState.refresh is LoadState.Loading && initialLoad -> {
ContainedLoadingIndicator()
}
else -> {
initialLoad = false
if (apps.itemCount == 0) {
Error(
modifier = Modifier.padding(paddingValues),
painter = painterResource(R.drawable.ic_apps_outage),
message = stringResource(R.string.no_apps_available)
)
} else {
LazyColumn {
items(
count = apps.itemCount,
key = apps.itemKey { it.packageName }
) { index ->
apps[index]?.let { app ->
LargeAppListItem(
app = app,
onClick = { onNavigateToAppDetails(app.packageName) }
)
}
}
}
}
}
}
}
}
}
@Preview
@Composable
private fun InstalledScreenPreview(@PreviewParameter(AppPreviewProvider::class) app: App) {
PreviewTemplate {
val apps = List(15) { app.copy(packageName = Random.nextInt().toString()) }
val pagedApps = MutableStateFlow(PagingData.from(apps)).collectAsLazyPagingItems()
ScreenContent(apps = pagedApps)
}
}

View File

@@ -1,66 +0,0 @@
/*
* Aurora Store
* Copyright (C) 2021, Rahul Kumar Patel <whyorean@gmail.com>
*
* Aurora Store is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 2 of the License, or
* (at your option) any later version.
*
* Aurora Store is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Aurora Store. If not, see <http://www.gnu.org/licenses/>.
*
*/
package com.aurora.store.view.epoxy.views
import android.content.Context
import android.util.AttributeSet
import coil3.load
import coil3.request.placeholder
import coil3.request.transformations
import coil3.transform.RoundedCornersTransformation
import com.airbnb.epoxy.CallbackProp
import com.airbnb.epoxy.ModelProp
import com.airbnb.epoxy.ModelView
import com.aurora.store.R
import com.aurora.store.data.model.MinimalApp
import com.aurora.store.databinding.ViewPackageBinding
@ModelView(
autoLayout = ModelView.Size.MATCH_WIDTH_WRAP_HEIGHT,
baseModelClass = BaseModel::class
)
class InstalledAppView @JvmOverloads constructor(
context: Context?,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : BaseView<ViewPackageBinding>(context, attrs, defStyleAttr) {
@ModelProp(options = [ModelProp.Option.IgnoreRequireHashCode])
fun packageInfo(app: MinimalApp) {
binding.imgIcon.load(app.icon) {
placeholder(R.drawable.bg_placeholder)
transformations(RoundedCornersTransformation(25F))
}
binding.txtLine1.text = app.displayName
binding.txtLine2.text = app.packageName
binding.txtLine3.text = ("${app.versionName} (${app.versionCode})")
}
@CallbackProp
fun click(onClickListener: OnClickListener?) {
binding.root.setOnClickListener(onClickListener)
}
@CallbackProp
fun longClick(onClickListener: OnLongClickListener?) {
binding.root.setOnLongClickListener(onClickListener)
}
}

View File

@@ -1,169 +0,0 @@
/*
* Aurora Store
* Copyright (C) 2021, Rahul Kumar Patel <whyorean@gmail.com>
*
* Aurora Store is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 2 of the License, or
* (at your option) any later version.
*
* Aurora Store is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Aurora Store. If not, see <http://www.gnu.org/licenses/>.
*
*/
package com.aurora.store.view.ui.all
import android.net.Uri
import android.os.Bundle
import android.text.Editable
import android.text.TextWatcher
import android.view.View
import androidx.activity.result.contract.ActivityResultContracts
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import com.aurora.Constants
import com.aurora.extensions.toast
import com.aurora.gplayapi.data.models.App
import com.aurora.store.AuroraApp
import com.aurora.store.R
import com.aurora.store.data.event.InstallerEvent
import com.aurora.store.data.model.MinimalApp
import com.aurora.store.databinding.FragmentGenericWithSearchBinding
import com.aurora.store.view.epoxy.views.HeaderViewModel_
import com.aurora.store.view.epoxy.views.app.AppListViewModel_
import com.aurora.store.view.epoxy.views.shimmer.AppListViewShimmerModel_
import com.aurora.store.view.ui.commons.BaseFragment
import com.aurora.store.viewmodel.all.InstalledViewModel
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import java.util.Calendar
@AndroidEntryPoint
class AppsGamesFragment : BaseFragment<FragmentGenericWithSearchBinding>() {
private val viewModel: InstalledViewModel by viewModels()
private val startForDocumentExport =
registerForActivityResult(ActivityResultContracts.CreateDocument(Constants.JSON_MIME_TYPE)) {
if (it != null) exportInstalledApps(it) else toast(R.string.toast_fav_export_failed)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewLifecycleOwner.lifecycleScope.launch {
viewModel.apps.collect {
updateController(it)
}
}
viewLifecycleOwner.lifecycleScope.launch {
AuroraApp.events.installerEvent.collect {
when (it) {
is InstallerEvent.Installed,
is InstallerEvent.Uninstalled -> {
viewModel.fetchApps()
}
else -> {}
}
}
}
// Toolbar
binding.toolbar.apply {
inflateMenu(R.menu.menu_import_export)
// TODO: Add support for batch install
menu.findItem(R.id.action_import).isEnabled = false
setNavigationOnClickListener { findNavController().navigateUp() }
setOnMenuItemClickListener {
when (it.itemId) {
R.id.action_export -> {
startForDocumentExport.launch(
"aurora_store_apps_${Calendar.getInstance().time.time}.json"
)
true
}
else -> false
}
}
}
binding.searchBar.addTextChangedListener(object : TextWatcher {
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
if (s.isNullOrEmpty()) {
updateController(viewModel.apps.value)
} else {
val filteredPackages = viewModel.apps.value?.filter {
it.displayName.contains(s, true) || it.packageName.contains(s, true)
}
updateController(filteredPackages)
}
}
override fun afterTextChanged(s: Editable?) {}
override fun beforeTextChanged(
s: CharSequence?,
start: Int,
count: Int,
after: Int
) {
}
})
}
private fun updateController(packages: List<App>?) {
binding.recycler.withModels {
setFilterDuplicates(true)
if (packages == null) {
for (i in 1..10) {
add(
AppListViewShimmerModel_()
.id(i)
)
}
} else {
add(
HeaderViewModel_()
.id("header")
.title(getString(R.string.installed_apps_size, packages.size))
)
packages.forEach { app ->
add(
AppListViewModel_()
.id(app.packageName.hashCode())
.app(app)
.click { _ ->
openDetailsFragment(
app.packageName
)
}
.longClick { _ ->
openAppMenuSheet(
MinimalApp.fromApp(
app
)
)
false
}
)
}
}
}
}
private fun exportInstalledApps(uri: Uri) {
viewModel.exportApps(requireContext(), uri)
toast(R.string.toast_fav_export_success)
}
}

View File

@@ -423,10 +423,10 @@ class MoreDialogFragment : DialogFragment() {
private fun getOptions(): List<Option> {
return listOf(
ViewOption(
ComposeOption(
title = R.string.title_apps_games,
icon = R.drawable.ic_apps,
destinationID = R.id.appsGamesFragment
screen = Screen.Installed
),
ComposeOption(
title = R.string.title_blacklist_manager,

View File

@@ -20,36 +20,38 @@
package com.aurora.store.viewmodel.all
import android.content.Context
import android.net.Uri
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
import androidx.paging.cachedIn
import com.aurora.gplayapi.data.models.App
import com.aurora.gplayapi.helpers.web.WebAppDetailsHelper
import com.aurora.store.data.paging.GenericPagingSource.Companion.manualPager
import com.aurora.store.data.providers.BlacklistProvider
import com.aurora.store.data.room.favourite.Favourite
import com.aurora.store.data.room.favourite.ImportExport
import com.aurora.store.util.PackageUtil
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import javax.inject.Inject
@HiltViewModel
class InstalledViewModel @Inject constructor(
blacklistProvider: BlacklistProvider,
@ApplicationContext private val context: Context,
private val blacklistProvider: BlacklistProvider,
private val json: Json,
private val webAppDetailsHelper: WebAppDetailsHelper
) : ViewModel() {
private val TAG = InstalledViewModel::class.java.simpleName
private val _apps = MutableStateFlow<List<App>?>(null)
private val packages = PackageUtil.getAllValidPackages(context)
private val blacklist = blacklistProvider.blacklist
private val _apps = MutableStateFlow<PagingData<App>>(PagingData.empty())
val apps = _apps.asStateFlow()
init {
@@ -57,37 +59,23 @@ class InstalledViewModel @Inject constructor(
}
fun fetchApps() {
viewModelScope.launch(Dispatchers.IO) {
val pagedPackages = packages
.filterNot { it.packageName in blacklist }
.chunked(20)
manualPager { page ->
try {
val packages = PackageUtil.getAllValidPackages(context)
.filterNot { blacklistProvider.isBlacklisted(it.packageName) }
// Divide the list of packages into chunks of 100 & fetch app details
// 50 is a safe number to avoid hitting the rate limit or package size limit
val chunkedPackages = packages.chunked(50)
val allApps = chunkedPackages.flatMap { chunk ->
webAppDetailsHelper.getAppDetails(chunk.map { it.packageName })
}
_apps.emit(allApps)
webAppDetailsHelper.getAppDetails(
pagedPackages[page].map { it.packageName }
)
} catch (exception: Exception) {
Log.e(TAG, "Failed to fetch apps", exception)
emptyList()
}
}
}.flow.distinctUntilChanged()
.cachedIn(viewModelScope)
.onEach { _apps.value = it }
.launchIn(viewModelScope)
}
fun exportApps(context: Context, uri: Uri) {
viewModelScope.launch(Dispatchers.IO) {
try {
val favourites: List<Favourite> = apps.value!!.map { app ->
Favourite.fromApp(app, Favourite.Mode.IMPORT)
}
context.contentResolver.openOutputStream(uri)?.use {
it.write(json.encodeToString(ImportExport(favourites)).encodeToByteArray())
}
} catch (exception: Exception) {
Log.e(TAG, "Failed to installed apps", exception)
}
}
}
}

View File

@@ -1,63 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Aurora Store
~ Copyright (C) 2021, Rahul Kumar Patel <whyorean@gmail.com>
~
~ Aurora Store is free software: you can redistribute it and/or modify
~ it under the terms of the GNU General Public License as published by
~ the Free Software Foundation, either version 2 of the License, or
~ (at your option) any later version.
~
~ Aurora Store is distributed in the hope that it will be useful,
~ but WITHOUT ANY WARRANTY; without even the implied warranty of
~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
~ GNU General Public License for more details.
~
~ You should have received a copy of the GNU General Public License
~ along with Aurora Store. If not, see <http://www.gnu.org/licenses/>.
~
-->
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingStart="@dimen/padding_normal"
android:paddingTop="@dimen/padding_xsmall"
android:paddingEnd="@dimen/padding_small"
android:paddingBottom="@dimen/padding_xsmall">
<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/img_icon"
android:layout_width="@dimen/icon_size_medium"
android:layout_height="@dimen/icon_size_medium"
android:layout_centerVertical="true" />
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/txt_line1"
style="@style/AuroraTextStyle.Line1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/margin_normal"
android:layout_toEndOf="@id/img_icon" />
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/txt_line2"
style="@style/AuroraTextStyle.Line2"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@+id/txt_line1"
android:layout_alignStart="@id/txt_line1"
android:layout_alignEnd="@id/txt_line1"
android:layout_marginTop="@dimen/margin_xxsmall" />
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/txt_line3"
style="@style/AuroraTextStyle.Line3"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@+id/txt_line2"
android:layout_alignStart="@id/txt_line1"
android:layout_alignEnd="@id/txt_line1"
android:layout_marginTop="@dimen/margin_xxsmall" />
</RelativeLayout>

View File

@@ -56,11 +56,6 @@
android:name="com.aurora.store.view.ui.updates.UpdatesFragment"
android:label="@string/title_updates"
tools:layout="@layout/fragment_updates" />
<fragment
android:id="@+id/appsGamesFragment"
android:name="com.aurora.store.view.ui.all.AppsGamesFragment"
android:label="@string/title_apps_games"
tools:layout="@layout/fragment_generic_with_search" />
<fragment
android:id="@+id/settingsFragment"
android:name="com.aurora.store.view.ui.preferences.SettingsFragment"