diff --git a/src/main/kotlin/com/looker/droidify/database/entity/Product.kt b/src/main/kotlin/com/looker/droidify/database/entity/Product.kt index 70085d6c..753ae2cd 100644 --- a/src/main/kotlin/com/looker/droidify/database/entity/Product.kt +++ b/src/main/kotlin/com/looker/droidify/database/entity/Product.kt @@ -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 get() = releases.filter { it.selected } diff --git a/src/main/kotlin/com/looker/droidify/ui/fragments/AppSheetX.kt b/src/main/kotlin/com/looker/droidify/ui/fragments/AppSheetX.kt new file mode 100644 index 00000000..5d74c34a --- /dev/null +++ b/src/main/kotlin/com/looker/droidify/ui/fragments/AppSheetX.kt @@ -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(), null as Action?) + private var productRepos = emptyList>() + private var products = emptyList() + 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() + 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) { + 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>) : 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>, + ) +} diff --git a/src/main/kotlin/com/looker/droidify/ui/fragments/ExploreFragment.kt b/src/main/kotlin/com/looker/droidify/ui/fragments/ExploreFragment.kt index 8539f2cc..fefb839c 100644 --- a/src/main/kotlin/com/looker/droidify/ui/fragments/ExploreFragment.kt +++ b/src/main/kotlin/com/looker/droidify/ui/fragments/ExploreFragment.kt @@ -56,6 +56,14 @@ class ExploreFragment : MainNavFragmentX() { } appsFastAdapter = FastAdapter.with(appsItemAdapter) appsFastAdapter?.setHasStableIds(true) + appsFastAdapter?.onClickListener = + { _: View?, _: IAdapter?, item: VAppItem?, _: Int? -> + item?.item?.let { + AppSheetX(it.packageName) + .showNow(parentFragmentManager, "Product ${it.packageName}") + } + false + } binding.recyclerView.apply { layoutManager = LinearLayoutManager(requireContext()) setHasFixedSize(true) diff --git a/src/main/kotlin/com/looker/droidify/ui/viewmodels/AppViewModelX.kt b/src/main/kotlin/com/looker/droidify/ui/viewmodels/AppViewModelX.kt new file mode 100644 index 00000000..568f6bdb --- /dev/null +++ b/src/main/kotlin/com/looker/droidify/ui/viewmodels/AppViewModelX.kt @@ -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>() + val repositories = MediatorLiveData>() + val installedItem = MediatorLiveData() + + 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 create(modelClass: Class): T { + if (modelClass.isAssignableFrom(AppViewModelX::class.java)) { + return AppViewModelX(db, packageName) as T + } + throw IllegalArgumentException("Unknown ViewModel class") + } + } +} diff --git a/src/main/res/layout/sheet_app_x.xml b/src/main/res/layout/sheet_app_x.xml new file mode 100644 index 00000000..1e2fa5fc --- /dev/null +++ b/src/main/res/layout/sheet_app_x.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + +