Add: AppSheetX

This commit is contained in:
machiav3lli 2022-02-06 03:02:55 +01:00
parent 79865b49cc
commit e93b972941
5 changed files with 587 additions and 0 deletions

View File

@ -25,6 +25,9 @@ open class Product {
@ColumnInfo(typeAffinity = ColumnInfo.BLOB)
var data: com.looker.droidify.entity.Product? = null
val trueData: com.looker.droidify.entity.Product?
get() = data?.copy(repositoryId = repository_id)
val selectedReleases: List<Release>
get() = releases.filter { it.selected }

View File

@ -0,0 +1,498 @@
package com.looker.droidify.ui.fragments
import android.content.ActivityNotFoundException
import android.content.ComponentName
import android.content.Intent
import android.content.pm.ApplicationInfo
import android.net.Uri
import android.os.Bundle
import android.provider.Settings
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.looker.droidify.R
import com.looker.droidify.content.ProductPreferences
import com.looker.droidify.database.entity.Release
import com.looker.droidify.database.entity.Repository
import com.looker.droidify.databinding.SheetAppXBinding
import com.looker.droidify.entity.Product
import com.looker.droidify.entity.ProductPreference
import com.looker.droidify.installer.AppInstaller
import com.looker.droidify.screen.MessageDialog
import com.looker.droidify.screen.ScreenshotsFragment
import com.looker.droidify.service.Connection
import com.looker.droidify.service.DownloadService
import com.looker.droidify.ui.activities.MainActivityX
import com.looker.droidify.ui.adapters.AppDetailAdapter
import com.looker.droidify.ui.viewmodels.AppViewModelX
import com.looker.droidify.utility.Utils.rootInstallerEnabled
import com.looker.droidify.utility.Utils.startUpdate
import com.looker.droidify.utility.extension.android.Android
import io.reactivex.rxjava3.disposables.Disposable
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class AppSheetX() : FullscreenBottomSheetDialogFragment(), AppDetailAdapter.Callbacks {
companion object {
private const val EXTRA_PACKAGE_NAME = "packageName"
private const val STATE_ADAPTER = "adapter"
}
constructor(packageName: String) : this() {
arguments = Bundle().apply {
putString(EXTRA_PACKAGE_NAME, packageName)
}
}
private lateinit var binding: SheetAppXBinding
val viewModel: AppViewModelX by viewModels {
AppViewModelX.Factory(mainActivityX.db, packageName)
}
val mainActivityX: MainActivityX
get() = requireActivity() as MainActivityX
val packageName: String
get() = requireArguments().getString(EXTRA_PACKAGE_NAME)!!
private var actions = Pair(emptySet<Action>(), null as Action?)
private var productRepos = emptyList<Pair<Product, Repository>>()
private var products = emptyList<Product>()
private var installed: Installed? = null
private var downloading = false
private var productDisposable: Disposable? = null
private val downloadConnection = Connection(DownloadService::class.java, onBind = { _, binder ->
binder.stateSubject
.filter { it.packageName == packageName }
.flowOn(Dispatchers.Default)
.onEach { updateDownloadState(it) }
.flowOn(Dispatchers.Main)
.launchIn(lifecycleScope)
})
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = SheetAppXBinding.inflate(layoutInflater)
binding.lifecycleOwner = this
setupAdapters()
return binding.root
}
private fun setupAdapters() {
downloadConnection.bind(requireContext())
binding.recyclerView.apply {
id = android.R.id.list
this.layoutManager = LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false)
this.adapter = AppDetailAdapter(this@AppSheetX)
}
}
override fun setupLayout() {
// TODO simplify observing and updating
viewModel.installedItem.observe(requireActivity()) {
installed = it?.let {
val isSystem = try {
((requireContext().packageManager.getApplicationInfo(packageName, 0).flags)
and ApplicationInfo.FLAG_SYSTEM) != 0
} catch (e: Exception) {
false
}
val launcherActivities =
if (packageName == requireContext().packageName) {
// Don't allow to launch self
emptyList()
} else {
val packageManager = requireContext().packageManager
packageManager
.queryIntentActivities(
Intent(Intent.ACTION_MAIN).addCategory(
Intent.CATEGORY_LAUNCHER
), 0
)
.asSequence()
.mapNotNull { resolveInfo -> resolveInfo.activityInfo }
.filter { activityInfo -> activityInfo.packageName == packageName }
.mapNotNull { activityInfo ->
val label = try {
activityInfo.loadLabel(packageManager).toString()
} catch (e: Exception) {
e.printStackTrace()
null
}
label?.let { labelName ->
Pair(
activityInfo.name,
labelName
)
}
}
.toList()
}
Installed(it, isSystem, launcherActivities)
}
updateSheet()
}
viewModel.repositories.observe(viewLifecycleOwner) {
if (it.isNotEmpty() && products.isNotEmpty()) updateSheet()
}
viewModel.products.observe(viewLifecycleOwner) {
products = it.mapNotNull { it?.trueData }
viewModel.repositories.value?.let { repos ->
if (repos.isNotEmpty() && products.isNotEmpty()) updateSheet()
}
}
}
override fun updateSheet() {
products.mapNotNull { product ->
viewModel.repositories.value
?.firstOrNull { it.id == product.repositoryId }
?.let { Pair(product, it) }
}.apply {
productRepos = this
val adapter = binding.recyclerView.adapter as AppDetailAdapter
adapter.setProducts(
binding.recyclerView.context,
packageName,
this,
installed?.data
)
lifecycleScope.launch {
updateButtons()
}
}
}
override fun onDestroyView() {
super.onDestroyView()
productDisposable?.dispose()
productDisposable = null
downloadConnection.unbind(requireContext())
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
val adapterState = (binding.recyclerView.adapter as? AppDetailAdapter)?.saveState()
adapterState?.let { outState.putParcelable(STATE_ADAPTER, it) }
}
private suspend fun updateButtons() {
updateButtons(ProductPreferences[packageName])
}
private suspend fun updateButtons(preference: ProductPreference) =
withContext(Dispatchers.Default) {
val installed = installed
val product =
Product.findSuggested(productRepos, installed?.data) { it.first }?.first
val compatible = product != null && product.selectedReleases.firstOrNull()
.let { it != null && it.incompatibilities.isEmpty() }
val canInstall = product != null && installed == null && compatible
val canUpdate =
product != null && compatible && product.canUpdate(installed?.data) &&
!preference.shouldIgnoreUpdate(product.versionCode)
val canUninstall = product != null && installed != null && !installed.isSystem
val canLaunch =
product != null && installed != null && installed.launcherActivities.isNotEmpty()
val canShare = product != null && productRepos[0].second.name == "F-Droid"
val actions = mutableSetOf<Action>()
launch {
if (canInstall) {
actions += Action.INSTALL
}
}
launch {
if (canUpdate) {
actions += Action.UPDATE
}
}
launch {
if (canLaunch) {
actions += Action.LAUNCH
}
}
launch {
if (installed != null) {
actions += Action.DETAILS
}
}
launch {
if (canUninstall) {
actions += Action.UNINSTALL
}
}
launch {
if (canShare) {
actions += Action.SHARE
}
}
val primaryAction = when {
canUpdate -> Action.UPDATE
canLaunch -> Action.LAUNCH
canInstall -> Action.INSTALL
installed != null -> Action.DETAILS
canShare -> Action.SHARE
else -> null
}
launch(Dispatchers.Main) {
val adapterAction =
if (downloading) AppDetailAdapter.Action.CANCEL else primaryAction?.adapterAction
(binding.recyclerView.adapter as? AppDetailAdapter)?.setAction(adapterAction)
for (action in sequenceOf(
Action.INSTALL,
Action.SHARE,
Action.UPDATE,
Action.UNINSTALL
)) {
//toolbar.menu.findItem(action.id).isEnabled = !downloading
}
}
launch { this@AppSheetX.actions = Pair(actions, primaryAction) }
}
private suspend fun updateDownloadState(state: DownloadService.State?) {
val status = when (state) {
is DownloadService.State.Pending -> AppDetailAdapter.Status.Pending
is DownloadService.State.Connecting -> AppDetailAdapter.Status.Connecting
is DownloadService.State.Downloading -> AppDetailAdapter.Status.Downloading(
state.read,
state.total
)
is DownloadService.State.Success, is DownloadService.State.Error, is DownloadService.State.Cancel, null -> null
}
val downloading = status != null
if (this.downloading != downloading) {
this.downloading = downloading
updateButtons()
}
(binding.recyclerView.adapter as? AppDetailAdapter)?.setStatus(status)
if (state is DownloadService.State.Success && isResumed && !rootInstallerEnabled) {
withContext(Dispatchers.Default) {
AppInstaller.getInstance(context)?.defaultInstaller?.install(state.release.cacheFileName)
}
}
}
override fun onActionClick(action: AppDetailAdapter.Action) {
when (action) {
AppDetailAdapter.Action.INSTALL,
AppDetailAdapter.Action.UPDATE,
-> {
val installedItem = installed?.data
lifecycleScope.launch {
startUpdate(
packageName,
installedItem,
productRepos,
downloadConnection
)
}
Unit
}
AppDetailAdapter.Action.LAUNCH -> {
val launcherActivities = installed?.launcherActivities.orEmpty()
if (launcherActivities.size >= 2) {
LaunchDialog(launcherActivities).show(
childFragmentManager,
LaunchDialog::class.java.name
)
} else {
launcherActivities.firstOrNull()?.let { startLauncherActivity(it.first) }
}
Unit
}
AppDetailAdapter.Action.DETAILS -> {
startActivity(
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
.setData(Uri.parse("package:$packageName"))
)
}
AppDetailAdapter.Action.UNINSTALL -> {
lifecycleScope.launch {
AppInstaller.getInstance(context)?.defaultInstaller?.uninstall(packageName)
}
Unit
}
AppDetailAdapter.Action.CANCEL -> {
val binder = downloadConnection.binder
if (downloading && binder != null) {
binder.cancel(packageName)
} else Unit
}
AppDetailAdapter.Action.SHARE -> {
shareIntent(packageName, productRepos[0].first.name)
}
}::class
}
private fun startLauncherActivity(name: String) {
try {
startActivity(
Intent(Intent.ACTION_MAIN)
.addCategory(Intent.CATEGORY_LAUNCHER)
.setComponent(ComponentName(packageName, name))
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
} catch (e: Exception) {
e.printStackTrace()
}
}
private fun shareIntent(packageName: String, appName: String) {
val shareIntent = Intent(Intent.ACTION_SEND)
val extraText = if (Android.sdk(24)) {
"https://www.f-droid.org/${resources.configuration.locales[0].language}/packages/${packageName}/"
} else "https://www.f-droid.org/${resources.configuration.locale.language}/packages/${packageName}/"
shareIntent.type = "text/plain"
shareIntent.putExtra(Intent.EXTRA_TITLE, appName)
shareIntent.putExtra(Intent.EXTRA_SUBJECT, appName)
shareIntent.putExtra(Intent.EXTRA_TEXT, extraText)
startActivity(Intent.createChooser(shareIntent, "Where to Send?"))
}
override fun onPreferenceChanged(preference: ProductPreference) {
lifecycleScope.launch { updateButtons(preference) }
}
override fun onPermissionsClick(group: String?, permissions: List<String>) {
MessageDialog(MessageDialog.Message.Permissions(group, permissions)).show(
childFragmentManager
)
}
override fun onScreenshotClick(screenshot: Product.Screenshot) {
val pair = productRepos.asSequence()
.map { it ->
Pair(
it.second,
it.first.screenshots.find { it === screenshot }?.identifier
)
}
.filter { it.second != null }.firstOrNull()
if (pair != null) {
val (repository, identifier) = pair
if (identifier != null) {
ScreenshotsFragment(packageName, repository.id, identifier).show(
childFragmentManager
)
}
}
}
override fun onReleaseClick(release: Release) {
val installedItem = installed?.data
when {
release.incompatibilities.isNotEmpty() -> {
MessageDialog(
MessageDialog.Message.ReleaseIncompatible(
release.incompatibilities,
release.platforms, release.minSdkVersion, release.maxSdkVersion
)
).show(childFragmentManager)
}
installedItem != null && installedItem.version_code > release.versionCode -> {
MessageDialog(MessageDialog.Message.ReleaseOlder).show(childFragmentManager)
}
installedItem != null && installedItem.signature != release.signature -> {
MessageDialog(MessageDialog.Message.ReleaseSignatureMismatch).show(
childFragmentManager
)
}
else -> {
val productRepository =
productRepos.asSequence()
.filter { it -> it.first.releases.any { it === release } }
.firstOrNull()
if (productRepository != null) {
downloadConnection.binder?.enqueue(
packageName, productRepository.first.name,
productRepository.second, release
)
}
}
}
}
override fun onUriClick(uri: Uri, shouldConfirm: Boolean): Boolean {
return if (shouldConfirm && (uri.scheme == "http" || uri.scheme == "https")) {
MessageDialog(MessageDialog.Message.Link(uri)).show(childFragmentManager)
true
} else {
try {
startActivity(Intent(Intent.ACTION_VIEW, uri))
true
} catch (e: ActivityNotFoundException) {
e.printStackTrace()
false
}
}
}
class LaunchDialog() : DialogFragment() {
companion object {
private const val EXTRA_NAMES = "names"
private const val EXTRA_LABELS = "labels"
}
constructor(launcherActivities: List<Pair<String, String>>) : this() {
arguments = Bundle().apply {
putStringArrayList(EXTRA_NAMES, ArrayList(launcherActivities.map { it.first }))
putStringArrayList(EXTRA_LABELS, ArrayList(launcherActivities.map { it.second }))
}
}
override fun onCreateDialog(savedInstanceState: Bundle?): AlertDialog {
val names = requireArguments().getStringArrayList(EXTRA_NAMES)!!
val labels = requireArguments().getStringArrayList(EXTRA_LABELS)!!
return MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.launch)
.setItems(labels.toTypedArray()) { _, position ->
(parentFragment as AppSheetX)
.startLauncherActivity(names[position])
}
.setNegativeButton(R.string.cancel, null)
.create()
}
}
private enum class Action(
val id: Int,
val adapterAction: AppDetailAdapter.Action,
) {
INSTALL(1, AppDetailAdapter.Action.INSTALL),
UPDATE(2, AppDetailAdapter.Action.UPDATE),
LAUNCH(3, AppDetailAdapter.Action.LAUNCH),
DETAILS(4, AppDetailAdapter.Action.DETAILS),
UNINSTALL(5, AppDetailAdapter.Action.UNINSTALL),
SHARE(6, AppDetailAdapter.Action.SHARE)
}
private class Installed(
val data: com.looker.droidify.database.entity.Installed, val isSystem: Boolean,
val launcherActivities: List<Pair<String, String>>,
)
}

View File

@ -56,6 +56,14 @@ class ExploreFragment : MainNavFragmentX() {
}
appsFastAdapter = FastAdapter.with(appsItemAdapter)
appsFastAdapter?.setHasStableIds(true)
appsFastAdapter?.onClickListener =
{ _: View?, _: IAdapter<VAppItem>?, item: VAppItem?, _: Int? ->
item?.item?.let {
AppSheetX(it.packageName)
.showNow(parentFragmentManager, "Product ${it.packageName}")
}
false
}
binding.recyclerView.apply {
layoutManager = LinearLayoutManager(requireContext())
setHasFixedSize(true)

View File

@ -0,0 +1,32 @@
package com.looker.droidify.ui.viewmodels
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.looker.droidify.database.DatabaseX
import com.looker.droidify.database.entity.Installed
import com.looker.droidify.database.entity.Product
import com.looker.droidify.database.entity.Repository
class AppViewModelX(val db: DatabaseX, val packageName: String) : ViewModel() {
val products = MediatorLiveData<List<Product?>>()
val repositories = MediatorLiveData<List<Repository>>()
val installedItem = MediatorLiveData<Installed?>()
init {
products.addSource(db.productDao.getLive(packageName), products::setValue)
repositories.addSource(db.repositoryDao.allLive, repositories::setValue)
installedItem.addSource(db.installedDao.getLive(packageName), installedItem::setValue)
}
class Factory(val db: DatabaseX, val packageName: String) : ViewModelProvider.Factory {
@Suppress("unchecked_cast")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(AppViewModelX::class.java)) {
return AppViewModelX(db, packageName) as T
}
throw IllegalArgumentException("Unknown ViewModel class")
}
}
}

View File

@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ OAndBackupX: open-source apps backup and restore app.
~ Copyright (C) 2020 Antonios Hazim
~
~ This program is free software: you can redistribute it and/or modify
~ it under the terms of the GNU Affero General Public License as
~ published by the Free Software Foundation, either version 3 of the
~ License, or (at your option) any later version.
~
~ This program 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 Affero General Public License for more details.
~
~ You should have received a copy of the GNU Affero General Public License
~ along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<data>
</data>
<com.google.android.material.circularreveal.CircularRevealFrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.core.widget.NestedScrollView
android:id="@+id/scrollLayout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="top"
android:clipToPadding="false">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipChildren="false"
android:clipToPadding="false"
tools:listitem="@layout/item_repository_x" />
</androidx.core.widget.NestedScrollView>
</com.google.android.material.circularreveal.CircularRevealFrameLayout>
</layout>