AppsGamesFragment: Allow exporting list as favourites

- This will allow isers to export there current installed app & then do bulk import as favourites
- Probably allowing a bulk install in favourites later would be a great addition
This commit is contained in:
Rahul Patel
2024-12-16 22:07:47 +05:30
parent fbeee2300c
commit 9540ac0c3b
8 changed files with 125 additions and 18 deletions

View File

@@ -35,7 +35,8 @@ object Constants {
const val SHARE_URL = "https://play.google.com/store/apps/details?id=" const val SHARE_URL = "https://play.google.com/store/apps/details?id="
const val UPDATE_URL_STABLE = "https://gitlab.com/AuroraOSS/AuroraStore/raw/master/updates.json" const val UPDATE_URL_STABLE = "https://gitlab.com/AuroraOSS/AuroraStore/raw/master/updates.json"
const val UPDATE_URL_NIGHTLY = "https://auroraoss.com/downloads/AuroraStore/Feeds/nightly_feed.json" const val UPDATE_URL_NIGHTLY =
"https://auroraoss.com/downloads/AuroraStore/Feeds/nightly_feed.json"
const val NOTIFICATION_CHANNEL_EXPORT = "NOTIFICATION_CHANNEL_EXPORT" const val NOTIFICATION_CHANNEL_EXPORT = "NOTIFICATION_CHANNEL_EXPORT"
const val NOTIFICATION_CHANNEL_INSTALL = "NOTIFICATION_CHANNEL_INSTALL" const val NOTIFICATION_CHANNEL_INSTALL = "NOTIFICATION_CHANNEL_INSTALL"
@@ -56,4 +57,6 @@ object Constants {
const val PAGE_TYPE = "PAGE_TYPE" const val PAGE_TYPE = "PAGE_TYPE"
const val TOP_CHART_TYPE = "TOP_CHART_TYPE" const val TOP_CHART_TYPE = "TOP_CHART_TYPE"
const val TOP_CHART_CATEGORY = "TOP_CHART_CATEGORY" const val TOP_CHART_CATEGORY = "TOP_CHART_CATEGORY"
const val JSON_MIME_TYPE = "application/json"
} }

View File

@@ -3,6 +3,7 @@ package com.aurora.store.data.room.favourite
import android.os.Parcelable import android.os.Parcelable
import androidx.room.Entity import androidx.room.Entity
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import com.aurora.gplayapi.data.models.App
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@Parcelize @Parcelize
@@ -16,6 +17,18 @@ data class Favourite(
val mode: Mode val mode: Mode
) : Parcelable { ) : Parcelable {
companion object {
fun fromApp(app: App, mode: Mode): Favourite {
return Favourite(
packageName = app.packageName,
displayName = app.displayName,
iconURL = app.iconArtwork.url,
added = System.currentTimeMillis(),
mode = mode
)
}
}
enum class Mode { enum class Mode {
MANUAL, MANUAL,
IMPORT IMPORT

View File

@@ -19,15 +19,25 @@
package com.aurora.store.view.ui.all package com.aurora.store.view.ui.all
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.text.Editable import android.text.Editable
import android.text.TextWatcher import android.text.TextWatcher
import android.view.ContextThemeWrapper
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View import android.view.View
import android.widget.PopupMenu
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.content.res.AppCompatResources
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import com.aurora.Constants
import com.aurora.extensions.toast
import com.aurora.gplayapi.data.models.App import com.aurora.gplayapi.data.models.App
import com.aurora.store.AuroraApp import com.aurora.store.AuroraApp
import com.aurora.store.R
import com.aurora.store.data.event.InstallerEvent import com.aurora.store.data.event.InstallerEvent
import com.aurora.store.data.model.MinimalApp import com.aurora.store.data.model.MinimalApp
import com.aurora.store.databinding.FragmentGenericWithSearchBinding import com.aurora.store.databinding.FragmentGenericWithSearchBinding
@@ -38,12 +48,18 @@ import com.aurora.store.view.ui.commons.BaseFragment
import com.aurora.store.viewmodel.all.InstalledViewModel import com.aurora.store.viewmodel.all.InstalledViewModel
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.Calendar
@AndroidEntryPoint @AndroidEntryPoint
class AppsGamesFragment : BaseFragment<FragmentGenericWithSearchBinding>() { class AppsGamesFragment : BaseFragment<FragmentGenericWithSearchBinding>() {
private val viewModel: InstalledViewModel by viewModels() 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?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
@@ -69,9 +85,17 @@ class AppsGamesFragment : BaseFragment<FragmentGenericWithSearchBinding>() {
// Toolbar // Toolbar
binding.layoutToolbarNative.apply { binding.layoutToolbarNative.apply {
imgActionPrimary.visibility = View.VISIBLE imgActionPrimary.visibility = View.VISIBLE
imgActionSecondary.visibility = View.GONE imgActionSecondary.visibility = View.VISIBLE
imgActionPrimary.setOnClickListener { findNavController().navigateUp() } imgActionPrimary.setOnClickListener { findNavController().navigateUp() }
imgActionSecondary.apply {
setImageDrawable(
AppCompatResources.getDrawable(requireContext(), R.drawable.ic_menu)
)
setOnClickListener {
showMenu(it)
}
}
inputSearch.addTextChangedListener(object : TextWatcher { inputSearch.addTextChangedListener(object : TextWatcher {
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
@@ -97,6 +121,35 @@ class AppsGamesFragment : BaseFragment<FragmentGenericWithSearchBinding>() {
} }
} }
private fun showMenu(anchor: View) {
val popupMenu = PopupMenu(
ContextThemeWrapper(
requireContext(),
R.style.AppTheme_PopupMenu
), anchor
)
val inflater: MenuInflater = popupMenu.menuInflater
inflater.inflate(R.menu.menu_import_export, popupMenu.menu)
popupMenu.menu.removeItem(R.id.action_import)
popupMenu.setOnMenuItemClickListener { menuItem: MenuItem ->
when (menuItem.itemId) {
R.id.action_export -> {
startForDocumentExport.launch(
"aurora_store_apps_${Calendar.getInstance().time.time}.json"
)
true
}
else -> false
}
}
popupMenu.show()
}
private fun updateController(packages: List<App>?) { private fun updateController(packages: List<App>?) {
binding.recycler.withModels { binding.recycler.withModels {
setFilterDuplicates(true) setFilterDuplicates(true)
@@ -137,4 +190,9 @@ class AppsGamesFragment : BaseFragment<FragmentGenericWithSearchBinding>() {
} }
} }
} }
private fun exportInstalledApps(uri: Uri) {
viewModel.exportApps(requireContext(), uri)
toast(R.string.toast_fav_export_success)
}
} }

View File

@@ -34,6 +34,7 @@ import androidx.appcompat.content.res.AppCompatResources
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import com.aurora.Constants
import com.aurora.extensions.toast import com.aurora.extensions.toast
import com.aurora.store.AuroraApp import com.aurora.store.AuroraApp
import com.aurora.store.R import com.aurora.store.R
@@ -51,13 +52,12 @@ class BlacklistFragment : BaseFragment<FragmentGenericWithSearchBinding>() {
private val viewModel: BlacklistViewModel by viewModels() private val viewModel: BlacklistViewModel by viewModels()
private val mimeType = "application/json"
private val startForDocumentImport = private val startForDocumentImport =
registerForActivityResult(ActivityResultContracts.OpenDocument()) { registerForActivityResult(ActivityResultContracts.OpenDocument()) {
if (it != null) importBlacklist(it) else toast(R.string.toast_black_import_failed) if (it != null) importBlacklist(it) else toast(R.string.toast_black_import_failed)
} }
private val startForDocumentExport = private val startForDocumentExport =
registerForActivityResult(ActivityResultContracts.CreateDocument(mimeType)) { registerForActivityResult(ActivityResultContracts.CreateDocument(Constants.JSON_MIME_TYPE)) {
if (it != null) exportBlacklist(it) else toast(R.string.toast_black_export_failed) if (it != null) exportBlacklist(it) else toast(R.string.toast_black_export_failed)
} }
@@ -132,7 +132,7 @@ class BlacklistFragment : BaseFragment<FragmentGenericWithSearchBinding>() {
popupMenu.setOnMenuItemClickListener { menuItem: MenuItem -> popupMenu.setOnMenuItemClickListener { menuItem: MenuItem ->
when (menuItem.itemId) { when (menuItem.itemId) {
R.id.action_import -> { R.id.action_import -> {
startForDocumentImport.launch(arrayOf(mimeType)) startForDocumentImport.launch(arrayOf(Constants.JSON_MIME_TYPE))
true true
} }

View File

@@ -26,6 +26,7 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import com.aurora.Constants
import com.aurora.extensions.toast import com.aurora.extensions.toast
import com.aurora.store.R import com.aurora.store.R
import com.aurora.store.data.room.favourite.Favourite import com.aurora.store.data.room.favourite.Favourite
@@ -42,13 +43,12 @@ import java.util.Calendar
class FavouriteFragment : BaseFragment<FragmentFavouriteBinding>() { class FavouriteFragment : BaseFragment<FragmentFavouriteBinding>() {
private val viewModel: FavouriteViewModel by viewModels() private val viewModel: FavouriteViewModel by viewModels()
private val mimeType = "application/json"
private val startForDocumentImport = private val startForDocumentImport =
registerForActivityResult(ActivityResultContracts.OpenDocument()) { registerForActivityResult(ActivityResultContracts.OpenDocument()) {
if (it != null) importFavourites(it) else toast(R.string.toast_fav_import_failed) if (it != null) importFavourites(it) else toast(R.string.toast_fav_import_failed)
} }
private val startForDocumentExport = private val startForDocumentExport =
registerForActivityResult(ActivityResultContracts.CreateDocument(mimeType)) { registerForActivityResult(ActivityResultContracts.CreateDocument(Constants.JSON_MIME_TYPE)) {
if (it != null) exportFavourites(it) else toast(R.string.toast_fav_export_failed) if (it != null) exportFavourites(it) else toast(R.string.toast_fav_export_failed)
} }
@@ -65,7 +65,7 @@ class FavouriteFragment : BaseFragment<FragmentFavouriteBinding>() {
binding.toolbar.apply { binding.toolbar.apply {
setOnMenuItemClickListener { setOnMenuItemClickListener {
when (it.itemId) { when (it.itemId) {
R.id.action_import -> startForDocumentImport.launch(arrayOf(mimeType)) R.id.action_import -> startForDocumentImport.launch(arrayOf(Constants.JSON_MIME_TYPE))
R.id.action_export -> { R.id.action_export -> {
startForDocumentExport.launch( startForDocumentExport.launch(
"aurora_store_favourites_${Calendar.getInstance().time.time}.json" "aurora_store_favourites_${Calendar.getInstance().time.time}.json"

View File

@@ -21,6 +21,7 @@ package com.aurora.store.viewmodel.all
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import android.util.Log
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.aurora.store.data.room.favourite.Favourite import com.aurora.store.data.room.favourite.Favourite
@@ -39,26 +40,38 @@ class FavouriteViewModel @Inject constructor(
private val favouriteDao: FavouriteDao, private val favouriteDao: FavouriteDao,
private val gson: Gson private val gson: Gson
) : ViewModel() { ) : ViewModel() {
private val TAG = FavouriteViewModel::class.java.simpleName
val favouritesList = favouriteDao.favourites() val favouritesList = favouriteDao.favourites()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null)
fun importFavourites(context: Context, uri: Uri) { fun importFavourites(context: Context, uri: Uri) {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
context.contentResolver.openInputStream(uri)?.use { try {
val importExport = context.contentResolver.openInputStream(uri)?.use {
gson.fromJson(it.bufferedReader().readText(), ImportExport::class.java) val importExport = gson.fromJson(
favouriteDao.insertAll( it.bufferedReader().readText(),
importExport.favourites.map { fav -> fav.copy(mode = Favourite.Mode.IMPORT) } ImportExport::class.java
) )
favouriteDao.insertAll(
importExport.favourites.map { fav -> fav.copy(mode = Favourite.Mode.IMPORT) }
)
}
} catch (exception: Exception) {
Log.e(TAG, "Failed to import favourites", exception)
} }
} }
} }
fun exportFavourites(context: Context, uri: Uri) { fun exportFavourites(context: Context, uri: Uri) {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
context.contentResolver.openOutputStream(uri)?.use { try {
it.write(gson.toJson(ImportExport(favouritesList.value!!)).encodeToByteArray()) context.contentResolver.openOutputStream(uri)?.use {
it.write(gson.toJson(ImportExport(favouritesList.value!!)).encodeToByteArray())
}
} catch (exception: Exception) {
Log.e(TAG, "Failed to export favourites", exception)
} }
} }
} }

View File

@@ -20,13 +20,17 @@
package com.aurora.store.viewmodel.all package com.aurora.store.viewmodel.all
import android.content.Context import android.content.Context
import android.net.Uri
import android.util.Log import android.util.Log
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.aurora.gplayapi.data.models.App import com.aurora.gplayapi.data.models.App
import com.aurora.gplayapi.helpers.web.WebAppDetailsHelper import com.aurora.gplayapi.helpers.web.WebAppDetailsHelper
import com.aurora.store.data.providers.BlacklistProvider 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 com.aurora.store.util.PackageUtil
import com.google.gson.Gson
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -39,6 +43,7 @@ import javax.inject.Inject
class InstalledViewModel @Inject constructor( class InstalledViewModel @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val blacklistProvider: BlacklistProvider, private val blacklistProvider: BlacklistProvider,
private val gson: Gson
) : ViewModel() { ) : ViewModel() {
private val TAG = InstalledViewModel::class.java.simpleName private val TAG = InstalledViewModel::class.java.simpleName
@@ -69,4 +74,19 @@ class InstalledViewModel @Inject constructor(
} }
} }
} }
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(gson.toJson(ImportExport(favourites)).encodeToByteArray())
}
} catch (exception: Exception) {
Log.e(TAG, "Failed to installed apps", exception)
}
}
}
} }

View File

@@ -236,8 +236,8 @@ class AppDetailsViewModel @Inject constructor(
private fun getLatestExodusReport(packageName: String): Report? { private fun getLatestExodusReport(packageName: String): Report? {
val headers: MutableMap<String, String> = mutableMapOf() val headers: MutableMap<String, String> = mutableMapOf()
headers["Content-Type"] = "application/json" headers["Content-Type"] = Constants.JSON_MIME_TYPE
headers["Accept"] = "application/json" headers["Accept"] = Constants.JSON_MIME_TYPE
headers["Authorization"] = "Token ${BuildConfig.EXODUS_API_KEY}" headers["Authorization"] = "Token ${BuildConfig.EXODUS_API_KEY}"
val url = Constants.EXODUS_SEARCH_URL + packageName val url = Constants.EXODUS_SEARCH_URL + packageName