From 88d9dcdd5310addebd07f0ff7f67006b56d37e33 Mon Sep 17 00:00:00 2001 From: Aayush Gupta Date: Mon, 3 Jul 2023 18:09:40 +0530 Subject: [PATCH] DO NOT MERGE: WIP: Migrate AppDetailsActivity to fragment Signed-off-by: Aayush Gupta --- app/src/main/AndroidManifest.xml | 37 - .../java/com/aurora/extensions/Context.kt | 13 +- .../store/data/service/NotificationService.kt | 19 +- .../com/aurora/store/util/NavigationUtil.kt | 12 - .../store/view/ui/commons/BaseActivity.kt | 6 +- .../store/view/ui/commons/BaseFragment.kt | 10 +- .../view/ui/details/AppDetailsActivity.kt | 846 ----------- .../view/ui/details/AppDetailsFragment.kt | 1283 +++++++++++++++++ .../view/ui/details/BaseDetailsActivity.kt | 443 ------ .../ui/details/EmptyAppDetailsActivity.kt | 32 - ...ivity_details.xml => fragment_details.xml} | 5 +- .../main/res/navigation/mobile_navigation.xml | 16 +- 12 files changed, 1320 insertions(+), 1402 deletions(-) delete mode 100644 app/src/main/java/com/aurora/store/view/ui/details/AppDetailsActivity.kt create mode 100644 app/src/main/java/com/aurora/store/view/ui/details/AppDetailsFragment.kt delete mode 100644 app/src/main/java/com/aurora/store/view/ui/details/BaseDetailsActivity.kt delete mode 100644 app/src/main/java/com/aurora/store/view/ui/details/EmptyAppDetailsActivity.kt rename app/src/main/res/layout/{activity_details.xml => fragment_details.xml} (97%) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9e04f9535..f38363ebb 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -92,43 +92,6 @@ android:name=".MainActivity" android:launchMode="singleTask" /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/java/com/aurora/extensions/Context.kt b/app/src/main/java/com/aurora/extensions/Context.kt index 20b8c4d90..ab56cbdc0 100644 --- a/app/src/main/java/com/aurora/extensions/Context.kt +++ b/app/src/main/java/com/aurora/extensions/Context.kt @@ -37,13 +37,13 @@ import androidx.core.app.ActivityOptionsCompat import androidx.core.app.ShareCompat import androidx.core.content.ContextCompat import androidx.core.graphics.drawable.toBitmap +import androidx.navigation.NavDeepLinkBuilder import com.aurora.Constants import com.aurora.gplayapi.data.models.App import com.aurora.store.MainActivity import com.aurora.store.R import com.aurora.store.util.Log import com.aurora.store.util.Preferences -import com.aurora.store.view.ui.details.AppDetailsActivity import kotlin.system.exitProcess val Context.inflater: LayoutInflater @@ -58,12 +58,11 @@ fun Context.browse(url: String, showOpenInAuroraAction: Boolean = false) { if (showOpenInAuroraAction) { val icon = ContextCompat.getDrawable(this, R.drawable.ic_open_in_new)?.toBitmap() - val pendingIntent = PendingIntent.getActivity( - this, - 0, - Intent(this, AppDetailsActivity::class.java), - PendingIntent.FLAG_MUTABLE - ) + val pendingIntent = NavDeepLinkBuilder(this) + .setGraph(R.navigation.mobile_navigation) + .setDestination(R.id.appDetailsFragment) + .setComponentName(MainActivity::class.java) + .createPendingIntent() customTabsIntent.setActionButton( icon!!, this.getString(R.string.open_in_aurora), diff --git a/app/src/main/java/com/aurora/store/data/service/NotificationService.kt b/app/src/main/java/com/aurora/store/data/service/NotificationService.kt index 81db411ee..d05982de7 100644 --- a/app/src/main/java/com/aurora/store/data/service/NotificationService.kt +++ b/app/src/main/java/com/aurora/store/data/service/NotificationService.kt @@ -29,6 +29,7 @@ import android.util.ArrayMap import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat +import androidx.core.os.bundleOf import androidx.navigation.NavDeepLinkBuilder import com.aurora.Constants import com.aurora.extensions.getStyledAttributeColor @@ -46,7 +47,6 @@ import com.aurora.store.data.receiver.DownloadResumeReceiver import com.aurora.store.data.receiver.InstallReceiver import com.aurora.store.util.CommonUtil import com.aurora.store.util.Log -import com.aurora.store.view.ui.details.AppDetailsActivity import com.google.gson.Gson import com.google.gson.GsonBuilder import com.tonyodev.fetch2.* @@ -313,17 +313,12 @@ class NotificationService : Service() { } private fun getContentIntentForDetails(app: App?): PendingIntent { - val intent = Intent(this, AppDetailsActivity::class.java) - intent.putExtra(Constants.STRING_EXTRA, gson.toJson(app)) - val flags = if (isMAndAbove()) - PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE - else PendingIntent.FLAG_CANCEL_CURRENT - return PendingIntent.getActivity( - this, - packageName.hashCode(), - intent, - flags - ) + return NavDeepLinkBuilder(this) + .setGraph(R.navigation.mobile_navigation) + .setDestination(R.id.appDetailsFragment) + .setComponentName(MainActivity::class.java) + .setArguments(bundleOf("packageName" to app!!.packageName)) + .createPendingIntent() } private fun getContentIntentForDownloads(): PendingIntent { diff --git a/app/src/main/java/com/aurora/store/util/NavigationUtil.kt b/app/src/main/java/com/aurora/store/util/NavigationUtil.kt index 87b4f23e8..109c8aabd 100644 --- a/app/src/main/java/com/aurora/store/util/NavigationUtil.kt +++ b/app/src/main/java/com/aurora/store/util/NavigationUtil.kt @@ -26,7 +26,6 @@ import androidx.appcompat.app.AppCompatActivity import com.aurora.Constants import com.aurora.gplayapi.data.models.App import com.aurora.store.data.model.Report -import com.aurora.store.view.ui.details.AppDetailsActivity import com.aurora.store.view.ui.details.DetailsExodusActivity import com.aurora.store.view.ui.details.DevAppsActivity import com.google.gson.Gson @@ -36,17 +35,6 @@ import java.lang.reflect.Modifier object NavigationUtil { val gson: Gson = GsonBuilder().excludeFieldsWithModifiers(Modifier.TRANSIENT).create() - fun openDetailsActivity(context: Context, app: App) { - val intent = Intent( - context, - AppDetailsActivity::class.java - ).apply { - putExtra(Constants.STRING_EXTRA, gson.toJson(app)) - } - val options = ActivityOptions.makeSceneTransitionAnimation(context as AppCompatActivity) - context.startActivity(intent, options.toBundle()) - } - fun openDevAppsActivity(context: Context, app: App) { val intent = Intent( context, diff --git a/app/src/main/java/com/aurora/store/view/ui/commons/BaseActivity.kt b/app/src/main/java/com/aurora/store/view/ui/commons/BaseActivity.kt index a6b81a32f..f7fde043f 100644 --- a/app/src/main/java/com/aurora/store/view/ui/commons/BaseActivity.kt +++ b/app/src/main/java/com/aurora/store/view/ui/commons/BaseActivity.kt @@ -21,7 +21,6 @@ package com.aurora.store.view.ui.commons import android.app.ActivityOptions import android.content.Intent -import android.os.Build import android.os.Bundle import androidx.appcompat.app.AppCompatActivity import com.aurora.Constants @@ -38,10 +37,7 @@ import com.aurora.store.view.ui.sheets.NetworkDialogSheet import com.aurora.store.view.ui.sheets.TOSSheet import com.google.gson.Gson import com.google.gson.GsonBuilder -import nl.komponents.kovenant.task -import nl.komponents.kovenant.ui.successUi import java.lang.reflect.Modifier -import java.util.concurrent.TimeUnit abstract class BaseActivity : AppCompatActivity(), NetworkProvider.NetworkListener { @@ -56,7 +52,7 @@ abstract class BaseActivity : AppCompatActivity(), NetworkProvider.NetworkListen } fun openDetailsActivity(app: App) { - val intent = Intent(this, AppDetailsActivity::class.java) + val intent = Intent(this, AppDetailsFragment::class.java) intent.putExtra(Constants.STRING_EXTRA, gson.toJson(app)) val options = ActivityOptions.makeSceneTransitionAnimation(this) startActivity(intent, options.toBundle()) diff --git a/app/src/main/java/com/aurora/store/view/ui/commons/BaseFragment.kt b/app/src/main/java/com/aurora/store/view/ui/commons/BaseFragment.kt index 5d4b2a4cc..86aad51f9 100644 --- a/app/src/main/java/com/aurora/store/view/ui/commons/BaseFragment.kt +++ b/app/src/main/java/com/aurora/store/view/ui/commons/BaseFragment.kt @@ -23,11 +23,12 @@ import android.app.ActivityOptions import android.content.Intent import androidx.annotation.LayoutRes import androidx.fragment.app.Fragment +import androidx.navigation.fragment.findNavController import com.aurora.Constants import com.aurora.extensions.getEmptyActivityBundle import com.aurora.gplayapi.data.models.App import com.aurora.gplayapi.data.models.Category -import com.aurora.store.view.ui.details.AppDetailsActivity +import com.aurora.store.MobileNavigationDirections import com.aurora.store.view.ui.details.DevProfileActivity import com.google.gson.Gson import com.google.gson.GsonBuilder @@ -44,10 +45,9 @@ open class BaseFragment : Fragment { ).create() fun openDetailsActivity(app: App) { - val intent = Intent(context, AppDetailsActivity::class.java) - intent.putExtra(Constants.STRING_EXTRA, Gson().toJson(app)) - val options = ActivityOptions.makeSceneTransitionAnimation(requireActivity()) - startActivity(intent, options.toBundle()) + findNavController().navigate( + MobileNavigationDirections.actionGlobalAppDetailsFragment(app.packageName) + ) } fun openCategoryBrowseActivity(category: Category) { diff --git a/app/src/main/java/com/aurora/store/view/ui/details/AppDetailsActivity.kt b/app/src/main/java/com/aurora/store/view/ui/details/AppDetailsActivity.kt deleted file mode 100644 index 27b1e0488..000000000 --- a/app/src/main/java/com/aurora/store/view/ui/details/AppDetailsActivity.kt +++ /dev/null @@ -1,846 +0,0 @@ -/* - * Aurora Store - * Copyright (C) 2021, Rahul Kumar Patel - * Copyright (C) 2022, The Calyx Institute - * - * 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 . - * - */ - -package com.aurora.store.view.ui.details - -import android.Manifest -import android.content.ActivityNotFoundException -import android.content.ComponentName -import android.content.Intent -import android.content.ServiceConnection -import android.content.pm.PackageManager -import android.os.Build -import android.os.Bundle -import android.os.Environment -import android.os.IBinder -import android.provider.Settings -import android.view.Menu -import android.view.MenuItem -import android.view.View -import android.widget.LinearLayout -import androidx.activity.addCallback -import androidx.activity.result.contract.ActivityResultContracts -import androidx.core.content.ContextCompat -import com.aurora.Constants -import com.aurora.extensions.* -import com.aurora.gplayapi.data.models.App -import com.aurora.gplayapi.data.models.AuthData -import com.aurora.gplayapi.helpers.AppDetailsHelper -import com.aurora.store.MainActivity -import com.aurora.store.R -import com.aurora.store.State -import com.aurora.store.data.downloader.DownloadManager -import com.aurora.store.data.downloader.getGroupId -import com.aurora.store.data.event.BusEvent -import com.aurora.store.data.event.InstallerEvent -import com.aurora.store.data.installer.AppInstaller -import com.aurora.store.data.network.HttpClient -import com.aurora.store.data.providers.AuthProvider -import com.aurora.store.data.service.AppMetadataStatusListener -import com.aurora.store.data.service.UpdateService -import com.aurora.store.databinding.ActivityDetailsBinding -import com.aurora.store.util.* -import com.aurora.store.view.ui.sheets.InstallErrorDialogSheet -import com.aurora.store.view.ui.sheets.ManualDownloadSheet -import com.bumptech.glide.load.resource.bitmap.RoundedCorners -import com.google.android.material.bottomsheet.BottomSheetBehavior -import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback -import com.tonyodev.fetch2.* -import com.tonyodev.fetch2core.DownloadBlock -import nl.komponents.kovenant.task -import nl.komponents.kovenant.ui.failUi -import nl.komponents.kovenant.ui.successUi -import org.greenrobot.eventbus.EventBus -import org.greenrobot.eventbus.Subscribe -import org.greenrobot.eventbus.ThreadMode -import java.io.File - -class AppDetailsActivity : BaseDetailsActivity() { - - private lateinit var B: ActivityDetailsBinding - private lateinit var bottomSheetBehavior: BottomSheetBehavior - - private val startForStorageManagerResult = - registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - if (isRAndAbove() && Environment.isExternalStorageManager()) { - updateApp(app) - } else { - toast(R.string.permissions_denied) - } - } - private val startForPermissions = - registerForActivityResult(ActivityResultContracts.RequestPermission()) { - if (it) updateApp(app) else toast(R.string.permissions_denied) - } - - private lateinit var authData: AuthData - private lateinit var app: App - private var fetch: Fetch? = null - private var downloadManager: DownloadManager? = null - - private var attachToServiceCalled = false - private var updateService: UpdateService? = null - private var pendingAddListeners = true - private var serviceConnection = object : ServiceConnection { - override fun onServiceConnected(name: ComponentName, binder: IBinder) { - updateService = (binder as UpdateService.UpdateServiceBinder).getUpdateService() - if (::fetchGroupListener.isInitialized && ::appMetadataListener.isInitialized && pendingAddListeners) { - updateService!!.registerFetchListener(fetchGroupListener) - // appMetadataListener needs to be initialized after the fetchGroupListener - updateService!!.registerAppMetadataListener(appMetadataListener) - pendingAddListeners = false - } - if (listOfActionsWhenServiceAttaches.isNotEmpty()) { - val iterator = listOfActionsWhenServiceAttaches.iterator() - while (iterator.hasNext()) { - val next = iterator.next() - next.run() - iterator.remove() - } - } - } - - override fun onServiceDisconnected(name: ComponentName) { - updateService = null - attachToServiceCalled = false - pendingAddListeners = true - } - } - private lateinit var fetchGroupListener: FetchGroupListener - private lateinit var appMetadataListener: AppMetadataStatusListener - private lateinit var completionMarker: java.io.File - private lateinit var inProgressMarker: java.io.File - - private var isExternal = false - private var isNone = false - private var status = Status.NONE - private var isInstalled: Boolean = false - private var isUpdatable: Boolean = false - private var autoDownload: Boolean = false - private var downloadOnly: Boolean = false - - override fun onConnected() { - - } - - override fun onDisconnected() { - - } - - override fun onReconnected() { - - } - - override fun onStart() { - super.onStart() - EventBus.getDefault().register(this) - if (autoDownload) { - purchase() - } - } - - override fun onStop() { - EventBus.getDefault().unregister(this) - super.onStop() - } - - @Subscribe(threadMode = ThreadMode.MAIN) - fun onEventMainThread(event: Any) { - when (event) { - is BusEvent.InstallEvent -> { - if (app.packageName == event.packageName) { - attachActions() - } - } - is BusEvent.UninstallEvent -> { - if (app.packageName == event.packageName) { - attachActions() - } - } - is BusEvent.ManualDownload -> { - if (app.packageName == event.packageName) { - app.versionCode = event.versionCode - purchase() - } - } - is InstallerEvent.Failed -> { - if (app.packageName == event.packageName) { - InstallErrorDialogSheet.newInstance( - app, - event.packageName, - event.error, - event.extra - ).show(supportFragmentManager, "SED") - attachActions() - updateActionState(State.IDLE) - } - } - else -> { - - } - } - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - B = ActivityDetailsBinding.inflate(layoutInflater) - setContentView(B.root) - - onNewIntent(intent) - - onBackPressedDispatcher.addCallback(this) { - if (isExternal) { - open(MainActivity::class.java, true) - } else { - finish() - } - } - } - - override fun onResume() { - getUpdateServiceInstance() - checkAndSetupInstall() - super.onResume() - } - - override fun onNewIntent(intent: Intent) { - super.onNewIntent(intent) - - if (intent.scheme != null && (intent.scheme == "market" || intent.scheme == "http" || intent.scheme == "https")) { - val packageName = intent.data!!.getQueryParameter("id") - val packageVersion = intent.data!!.getQueryParameter("v") - if (packageName.isNullOrEmpty()) { - finishAfterTransition() - } else { - isExternal = true - app = App(packageName) - if (!packageVersion.isNullOrEmpty()) { - app.versionCode = packageVersion.toInt() - } - fetchCompleteApp() - } - } else { - val rawApp: String? = intent.getStringExtra(Constants.STRING_EXTRA) - if (rawApp != null) { - app = gson.fromJson(rawApp, App::class.java) - isInstalled = PackageUtil.isInstalled(this, app.packageName) - - inflatePartialApp() - fetchCompleteApp() - } else { - finishAfterTransition() - } - } - } - - private var uninstallActionEnabled = false - - override fun onCreateOptionsMenu(menu: Menu?): Boolean { - menuInflater.inflate(R.menu.menu_details, menu) - if (::app.isInitialized) { - val installed = PackageUtil.isInstalled(this, app.packageName) - menu?.findItem(R.id.action_uninstall)?.isVisible = installed - uninstallActionEnabled = installed - } - return true - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - android.R.id.home -> { - onBackPressedDispatcher.onBackPressed() - return true - } - R.id.action_share -> { - share(app) - return true - } - R.id.action_uninstall -> { - uninstallApp() - return true - } - R.id.menu_download_manual -> { - val sheet = ManualDownloadSheet.newInstance(app) - sheet.isCancelable = false - sheet.show(supportFragmentManager, ManualDownloadSheet.TAG) - return true - } - R.id.action_playstore -> { - browse("${Constants.SHARE_URL}${app.packageName}") - return true - } - } - return super.onOptionsItemSelected(item) - } - - private fun attachToolbar() { - setSupportActionBar(B.layoutDetailsToolbar.toolbar) - val actionBar = supportActionBar - if (actionBar != null) { - actionBar.setDisplayShowCustomEnabled(true) - actionBar.setDisplayHomeAsUpEnabled(true) - actionBar.elevation = 0f - actionBar.title = "" - } - } - - private fun attachActions() { - flip(0) - checkAndSetupInstall() - } - - private fun updateActionState(state: State) { - B.layoutDetailsInstall.btnDownload.updateState(state) - } - - private fun openApp() { - val intent = PackageUtil.getLaunchIntent(this, app.packageName) - if (intent != null) { - try { - startActivity(intent) - } catch (e: ActivityNotFoundException) { - toast("Unable to open app") - } - } - } - - private fun verifyAndInstall(files: List) { - if (downloadOnly) - return - - var filesExist = true - - files.forEach { download -> - filesExist = filesExist && File(download.file).exists() - } - - if (filesExist) - install(files) - else - purchase() - } - - @Synchronized - private fun install(files: List) { - updateActionState(State.IDLE) - - val apkFiles = files.filter { it.file.endsWith(".apk") } - val preferredInstaller = Preferences.getInteger(this, Preferences.PREFERENCE_INSTALLER_ID) - - if (apkFiles.size > 1 && preferredInstaller == 1) { - showDialog(R.string.title_installer, R.string.dialog_desc_native_split) - } else { - task { - AppInstaller.getInstance(this) - .getPreferredInstaller() - .install( - app.packageName, - apkFiles.map { it.file } - ) - } fail { - Log.e(it.stackTraceToString()) - } - - runOnUiThread { - B.layoutDetailsInstall.btnDownload.setText(getString(R.string.action_installing)) - } - } - } - - @Synchronized - private fun uninstallApp() { - task { - AppInstaller.getInstance(this) - .getPreferredInstaller() - .uninstall(app.packageName) - } - } - - private fun attachWhiteListStatus() { - - } - - private fun fetchCompleteApp() { - task { - authData = AuthProvider.with(this).getAuthData() - return@task AppDetailsHelper(authData) - .using(HttpClient.getPreferredClient()) - .getAppByPackageName(app.packageName) - } successUi { - if (isExternal) { - app = it - inflatePartialApp() - } - inflateExtraDetails(it) - } failUi { - toast("Failed to fetch app details") - } - } - - private fun inflatePartialApp() { - if (::app.isInitialized) { - attachWhiteListStatus() - attachHeader() - attachToolbar() - attachBottomSheet() - attachFetch() - attachActions() - - if (autoDownload) { - purchase() - } - } - } - - private fun attachHeader() { - B.layoutDetailsApp.apply { - imgIcon.load(app.iconArtwork.url) { - placeholder(R.drawable.bg_placeholder) - transform(RoundedCorners(32)) - } - - txtLine1.text = app.displayName - txtLine2.text = app.developerName - txtLine2.setOnClickListener { - NavigationUtil.openDevAppsActivity( - this@AppDetailsActivity, - app - ) - } - txtLine3.text = ("${app.versionName} (${app.versionCode})") - packageName.text = app.packageName - - val tags = mutableListOf() - if (app.isFree) - tags.add(getString(R.string.details_free)) - else - tags.add(getString(R.string.details_paid)) - - if (app.containsAds) - tags.add(getString(R.string.details_contains_ads)) - else - tags.add(getString(R.string.details_no_ads)) - - txtLine4.text = tags.joinToString(separator = " • ") - } - } - - private fun inflateExtraDetails(app: App?) { - app?.let { - B.viewFlipper.displayedChild = 1 - inflateAppDescription(B.layoutDetailDescription, app) - inflateAppRatingAndReviews(B.layoutDetailsReview, app) - inflateAppDevInfo(B.layoutDetailsDev, app) - inflateAppPrivacy(B.layoutDetailsPrivacy, app) - inflateAppPermission(B.layoutDetailsPermissions, app) - - if (!authData.isAnonymous) { - app.testingProgram?.let { - if (it.isAvailable && it.isSubscribed) { - B.layoutDetailsApp.txtLine1.text = it.displayName - } - } - - inflateBetaSubscription(B.layoutDetailsBeta, app) - } - - if (Preferences.getBoolean(this, Preferences.PREFERENCE_SIMILAR)) { - inflateAppStream(B.epoxyRecyclerStream, app) - } - } - } - - @Synchronized - private fun startDownload() { - when (status) { - Status.PAUSED -> { - fetch?.resumeGroup(app.getGroupId(this@AppDetailsActivity)) - } - Status.DOWNLOADING -> { - flip(1) - toast("Already downloading") - } - Status.COMPLETED -> { - fetch?.getFetchGroup(app.getGroupId(this@AppDetailsActivity)) { - verifyAndInstall(it.downloads) - } - } - else -> { - purchase() - } - } - } - - val listOfActionsWhenServiceAttaches = ArrayList() - - private fun purchase() { - bottomSheetBehavior.isHideable = false - bottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED - updateActionState(State.PROGRESS) - - if (PathUtil.needsStorageManagerPerm(app.fileList) || this.isExternalStorageEnable()) { - if (isRAndAbove()) { - if (!Environment.isExternalStorageManager()) { - startForStorageManagerResult.launch( - Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION) - ) - } else { - updateApp(app) - } - } else { - if (ContextCompat.checkSelfPermission( - this, - Manifest.permission.WRITE_EXTERNAL_STORAGE - ) == PackageManager.PERMISSION_GRANTED - ) { - updateApp(app) - } else { - startForPermissions.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE) - } - } - } else { - updateApp(app) - } - } - - private fun updateApp(app: App) { - if (updateService == null) { - listOfActionsWhenServiceAttaches.add { - updateService?.updateApp(app, true) - } - getUpdateServiceInstance() - } else { - updateService?.updateApp(app, true) - } - } - - private fun updateProgress( - fetchGroup: FetchGroup, - etaInMilliSeconds: Long, - downloadedBytesPerSecond: Long - ) { - runOnUiThread { - val progress = if (fetchGroup.groupDownloadProgress > 0) - fetchGroup.groupDownloadProgress - else - 0 - - if (progress == 100) { - B.layoutDetailsInstall.btnDownload.setText(getString(R.string.action_installing)) - return@runOnUiThread - } - B.layoutDetailsInstall.apply { - txtProgressPercent.text = ("${progress}%") - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - progressDownload.setProgress(progress, true) - } else { - progressDownload.progress = progress - } - - txtEta.text = CommonUtil.getETAString( - this@AppDetailsActivity, - etaInMilliSeconds - ) - txtSpeed.text = - CommonUtil.getDownloadSpeedString( - this@AppDetailsActivity, - downloadedBytesPerSecond - ) - } - } - } - - private fun expandBottomSheet(message: String?) { - bottomSheetBehavior.isHideable = false - bottomSheetBehavior.state = BottomSheetBehavior.STATE_EXPANDED - - with(B.layoutDetailsInstall) { - txtPurchaseError.text = message - btnDownload.updateState(State.IDLE) - if (app.isFree) - btnDownload.setText(R.string.action_install) - else - btnDownload.setText(app.price) - } - } - - private fun checkAndSetupInstall() { - isInstalled = PackageUtil.isInstalled(this, app.packageName) - - B.layoutDetailsInstall.btnDownload.let { btn -> - if (isInstalled) { - isUpdatable = PackageUtil.isUpdatable( - this, - app.packageName, - app.versionCode.toLong() - ) - - val installedVersion = PackageUtil.getInstalledVersion(this, app.packageName) - - if (isUpdatable) { - B.layoutDetailsApp.txtLine3.text = - ("$installedVersion ➔ ${app.versionName} (${app.versionCode})") - btn.setText(R.string.action_update) - btn.addOnClickListener { startDownload() } - } else { - B.layoutDetailsApp.txtLine3.text = installedVersion - btn.setText(R.string.action_open) - btn.addOnClickListener { openApp() } - } - if (!uninstallActionEnabled) { - invalidateOptionsMenu() - } - } else { - if (app.isFree) { - btn.setText(R.string.action_install) - } else { - btn.setText(app.price) - } - - btn.addOnClickListener { - if (authData.isAnonymous && !app.isFree) { - toast(R.string.toast_purchase_blocked) - } else { - btn.setText(R.string.download_metadata) - startDownload() - } - } - if (uninstallActionEnabled) { - invalidateOptionsMenu() - } - } - } - } - - @Synchronized - private fun flip(nextView: Int) { - runOnUiThread { - val displayChild = B.layoutDetailsInstall.viewFlipper.displayedChild - if (displayChild != nextView) { - B.layoutDetailsInstall.viewFlipper.displayedChild = nextView - if (nextView == 0) - checkAndSetupInstall() - } - } - } - - private fun attachFetch() { - if (fetch == null) { - downloadManager = DownloadManager.with(this) - fetch = downloadManager!!.fetch - } - fetch?.getFetchGroup(app.getGroupId(this@AppDetailsActivity)) { fetchGroup: FetchGroup -> - if (fetchGroup.groupDownloadProgress == 100 && fetchGroup.completedDownloads.isNotEmpty()) { - status = Status.COMPLETED - } else if (downloadManager?.isDownloading(fetchGroup) == true) { - status = Status.DOWNLOADING - flip(1) - } else if (downloadManager?.isCanceled(fetchGroup) == true) { - status = Status.CANCELLED - } else if (fetchGroup.pausedDownloads.isNotEmpty()) { - status = Status.PAUSED - } else { - status = Status.NONE - } - } - - fetchGroupListener = object : AbstractFetchGroupListener() { - - override fun onAdded(groupId: Int, download: Download, fetchGroup: FetchGroup) { - if (groupId == app.getGroupId(this@AppDetailsActivity)) { - status = download.status - } - } - - override fun onStarted( - groupId: Int, - download: Download, - downloadBlocks: List, - totalBlocks: Int, - fetchGroup: FetchGroup - ) { - if (groupId == app.getGroupId(this@AppDetailsActivity)) { - status = download.status - flip(1) - - val pkgDir = PathUtil.getPackageDirectory(applicationContext, app.packageName) - completionMarker = - java.io.File("$pkgDir/.${app.versionCode}.download-complete") - inProgressMarker = - java.io.File("$pkgDir/.${app.versionCode}.download-in-progress") - - if (completionMarker.exists()) - completionMarker.delete() - - inProgressMarker.createNewFile() - } - } - - override fun onResumed(groupId: Int, download: Download, fetchGroup: FetchGroup) { - if (groupId == app.getGroupId(this@AppDetailsActivity)) { - status = download.status - flip(1) - inProgressMarker.parentFile?.mkdirs() - inProgressMarker.createNewFile() - } - } - - override fun onPaused(groupId: Int, download: Download, fetchGroup: FetchGroup) { - if (groupId == app.getGroupId(this@AppDetailsActivity)) { - status = download.status - flip(0) - } - } - - override fun onProgress( - groupId: Int, - download: Download, - etaInMilliSeconds: Long, - downloadedBytesPerSecond: Long, - fetchGroup: FetchGroup - ) { - if (groupId == app.getGroupId(this@AppDetailsActivity)) { - updateProgress(fetchGroup, etaInMilliSeconds, downloadedBytesPerSecond) - Log.i( - "${app.displayName} : ${download.file} -> Progress : %d", - fetchGroup.groupDownloadProgress - ) - } - } - - override fun onCompleted(groupId: Int, download: Download, fetchGroup: FetchGroup) { - if (groupId == app.getGroupId(this@AppDetailsActivity) && fetchGroup.groupDownloadProgress == 100) { - status = download.status - flip(0) - updateProgress(fetchGroup, -1, -1) - try { - inProgressMarker.delete() - completionMarker.createNewFile() - } catch (ex: Exception) { - ex.printStackTrace() - } - } - } - - override fun onCancelled(groupId: Int, download: Download, fetchGroup: FetchGroup) { - if (groupId == app.getGroupId(this@AppDetailsActivity)) { - status = download.status - flip(0) - inProgressMarker.delete() - } - } - - override fun onError( - groupId: Int, - download: Download, - error: Error, - throwable: Throwable?, - fetchGroup: FetchGroup - ) { - if (groupId == app.getGroupId(this@AppDetailsActivity)) { - status = download.status - flip(0) - inProgressMarker.delete() - } - } - } - - appMetadataListener = object : AppMetadataStatusListener { - override fun onAppMetadataStatusError(reason: String, app: App) { - if (app.packageName == this@AppDetailsActivity.app.packageName) { - updateActionState(State.IDLE) - expandBottomSheet(reason) - } - } - } - - getUpdateServiceInstance() - - B.layoutDetailsInstall.imgCancel.setOnClickListener { - fetch?.cancelGroup( - app.getGroupId(this@AppDetailsActivity) - ) - } - if (updateService != null) { - pendingAddListeners = false - updateService!!.registerFetchListener(fetchGroupListener) - // appMetadataListener needs to be initialized after the fetchGroupListener - updateService!!.registerAppMetadataListener(appMetadataListener) - } else { - pendingAddListeners = true - } - } - - fun getUpdateServiceInstance() { - if (updateService == null && !attachToServiceCalled) { - attachToServiceCalled = true - val intent = Intent(this, UpdateService::class.java) - startService(intent) - bindService( - intent, - serviceConnection, - 0 - ) - } - } - - override fun onPause() { - if (updateService != null) { - updateService = null - attachToServiceCalled = false - pendingAddListeners = true - unbindService(serviceConnection) - } - super.onPause() - } - - override fun onDestroy() { - super.onDestroy() - if (updateService != null) { - updateService = null - attachToServiceCalled = false - pendingAddListeners = true - unbindService(serviceConnection) - } - } - - private fun attachBottomSheet() { - B.layoutDetailsInstall.apply { - viewFlipper.setInAnimation(this@AppDetailsActivity, R.anim.fade_in) - viewFlipper.setOutAnimation(this@AppDetailsActivity, R.anim.fade_out) - } - - bottomSheetBehavior = BottomSheetBehavior.from(B.layoutDetailsInstall.bottomSheet) - bottomSheetBehavior.isDraggable = false - - bottomSheetBehavior.addBottomSheetCallback(object : BottomSheetCallback() { - override fun onStateChanged(bottomSheet: View, newState: Int) { - if (newState == BottomSheetBehavior.STATE_EXPANDED) { - bottomSheetBehavior.setDraggable(true) - } else if (newState == BottomSheetBehavior.STATE_COLLAPSED) { - bottomSheetBehavior.isDraggable = false - } - } - - override fun onSlide(bottomSheet: View, slideOffset: Float) {} - }) - } -} 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 new file mode 100644 index 000000000..fe027e390 --- /dev/null +++ b/app/src/main/java/com/aurora/store/view/ui/details/AppDetailsFragment.kt @@ -0,0 +1,1283 @@ +/* + * Aurora Store + * Copyright (C) 2021, Rahul Kumar Patel + * Copyright (C) 2022, The Calyx Institute + * + * 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 . + * + */ + +package com.aurora.store.view.ui.details + +import android.Manifest +import android.content.ActivityNotFoundException +import android.content.ComponentName +import android.content.Intent +import android.content.ServiceConnection +import android.content.pm.PackageManager +import android.os.Build +import android.os.Bundle +import android.os.Environment +import android.os.IBinder +import android.provider.Settings +import android.view.View +import android.widget.LinearLayout +import android.widget.RelativeLayout +import android.widget.Toast +import androidx.activity.addCallback +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.ContextCompat +import androidx.core.text.HtmlCompat +import androidx.lifecycle.ViewModelProvider +import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs +import com.airbnb.epoxy.EpoxyRecyclerView +import com.aurora.Constants +import com.aurora.extensions.browse +import com.aurora.extensions.getString +import com.aurora.extensions.hide +import com.aurora.extensions.isRAndAbove +import com.aurora.extensions.load +import com.aurora.extensions.runOnUiThread +import com.aurora.extensions.share +import com.aurora.extensions.show +import com.aurora.extensions.showDialog +import com.aurora.extensions.stackTraceToString +import com.aurora.extensions.toast +import com.aurora.gplayapi.data.models.App +import com.aurora.gplayapi.data.models.AuthData +import com.aurora.gplayapi.data.models.Review +import com.aurora.gplayapi.data.models.StreamBundle +import com.aurora.gplayapi.data.models.StreamCluster +import com.aurora.gplayapi.helpers.AppDetailsHelper +import com.aurora.gplayapi.helpers.ReviewsHelper +import com.aurora.store.MainActivity +import com.aurora.store.R +import com.aurora.store.State +import com.aurora.store.data.ViewState +import com.aurora.store.data.downloader.DownloadManager +import com.aurora.store.data.downloader.getGroupId +import com.aurora.store.data.event.BusEvent +import com.aurora.store.data.event.InstallerEvent +import com.aurora.store.data.installer.AppInstaller +import com.aurora.store.data.model.ExodusReport +import com.aurora.store.data.model.Report +import com.aurora.store.data.network.HttpClient +import com.aurora.store.data.providers.AuthProvider +import com.aurora.store.data.service.AppMetadataStatusListener +import com.aurora.store.data.service.UpdateService +import com.aurora.store.databinding.FragmentDetailsBinding +import com.aurora.store.databinding.LayoutDetailsBetaBinding +import com.aurora.store.databinding.LayoutDetailsDescriptionBinding +import com.aurora.store.databinding.LayoutDetailsDevBinding +import com.aurora.store.databinding.LayoutDetailsPermissionsBinding +import com.aurora.store.databinding.LayoutDetailsPrivacyBinding +import com.aurora.store.databinding.LayoutDetailsReviewBinding +import com.aurora.store.util.CommonUtil +import com.aurora.store.util.Log +import com.aurora.store.util.NavigationUtil +import com.aurora.store.util.PackageUtil +import com.aurora.store.util.PathUtil +import com.aurora.store.util.Preferences +import com.aurora.store.util.isExternalStorageEnable +import com.aurora.store.view.custom.RatingView +import com.aurora.store.view.epoxy.controller.DetailsCarouselController +import com.aurora.store.view.epoxy.controller.GenericCarouselController +import com.aurora.store.view.epoxy.views.details.ReviewViewModel_ +import com.aurora.store.view.epoxy.views.details.ScreenshotView +import com.aurora.store.view.epoxy.views.details.ScreenshotViewModel_ +import com.aurora.store.view.ui.commons.BaseFragment +import com.aurora.store.view.ui.sheets.InstallErrorDialogSheet +import com.aurora.store.view.ui.sheets.ManualDownloadSheet +import com.aurora.store.view.ui.sheets.PermissionBottomSheet +import com.aurora.store.viewmodel.details.DetailsClusterViewModel +import com.bumptech.glide.load.resource.bitmap.RoundedCorners +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback +import com.tonyodev.fetch2.AbstractFetchGroupListener +import com.tonyodev.fetch2.Download +import com.tonyodev.fetch2.Error +import com.tonyodev.fetch2.Fetch +import com.tonyodev.fetch2.FetchGroup +import com.tonyodev.fetch2.FetchGroupListener +import com.tonyodev.fetch2.Status +import com.tonyodev.fetch2core.DownloadBlock +import nl.komponents.kovenant.task +import nl.komponents.kovenant.ui.failUi +import nl.komponents.kovenant.ui.successUi +import org.greenrobot.eventbus.EventBus +import org.greenrobot.eventbus.Subscribe +import org.greenrobot.eventbus.ThreadMode +import org.json.JSONObject +import java.io.File +import java.util.Locale + +class AppDetailsFragment : BaseFragment(R.layout.fragment_details) { + + private var _binding: FragmentDetailsBinding? = null + private val binding: FragmentDetailsBinding + get() = _binding!! + + private val args: AppDetailsFragmentArgs by navArgs() + + private lateinit var bottomSheetBehavior: BottomSheetBehavior + + private val exodusBaseUrl = "https://reports.exodus-privacy.eu.org/api/search/" + private val exodusApiKey = "Token bbe6ebae4ad45a9cbacb17d69739799b8df2c7ae" + + private val startForStorageManagerResult = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + if (isRAndAbove() && Environment.isExternalStorageManager()) { + updateApp(app) + } else { + toast(R.string.permissions_denied) + } + } + private val startForPermissions = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { + if (it) updateApp(app) else toast(R.string.permissions_denied) + } + + private lateinit var authData: AuthData + private lateinit var app: App + private var fetch: Fetch? = null + private var downloadManager: DownloadManager? = null + + private var attachToServiceCalled = false + private var updateService: UpdateService? = null + private var pendingAddListeners = true + private var serviceConnection = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName, binder: IBinder) { + updateService = (binder as UpdateService.UpdateServiceBinder).getUpdateService() + if (::fetchGroupListener.isInitialized && ::appMetadataListener.isInitialized && pendingAddListeners) { + updateService!!.registerFetchListener(fetchGroupListener) + // appMetadataListener needs to be initialized after the fetchGroupListener + updateService!!.registerAppMetadataListener(appMetadataListener) + pendingAddListeners = false + } + if (listOfActionsWhenServiceAttaches.isNotEmpty()) { + val iterator = listOfActionsWhenServiceAttaches.iterator() + while (iterator.hasNext()) { + val next = iterator.next() + next.run() + iterator.remove() + } + } + } + + override fun onServiceDisconnected(name: ComponentName) { + updateService = null + attachToServiceCalled = false + pendingAddListeners = true + } + } + private lateinit var fetchGroupListener: FetchGroupListener + private lateinit var appMetadataListener: AppMetadataStatusListener + private lateinit var completionMarker: File + private lateinit var inProgressMarker: File + + private var isExternal = false + private var isNone = false + private var status = Status.NONE + private var isInstalled: Boolean = false + private var isUpdatable: Boolean = false + private var autoDownload: Boolean = false + private var downloadOnly: Boolean = false + private var uninstallActionEnabled = false + + val listOfActionsWhenServiceAttaches = ArrayList() + + override fun onStart() { + super.onStart() + EventBus.getDefault().register(this) + if (autoDownload) { + purchase() + } + } + + override fun onStop() { + EventBus.getDefault().unregister(this) + super.onStop() + } + + @Subscribe(threadMode = ThreadMode.MAIN) + fun onEventMainThread(event: Any) { + when (event) { + is BusEvent.InstallEvent -> { + if (app.packageName == event.packageName) { + attachActions() + } + } + + is BusEvent.UninstallEvent -> { + if (app.packageName == event.packageName) { + attachActions() + } + } + + is BusEvent.ManualDownload -> { + if (app.packageName == event.packageName) { + app.versionCode = event.versionCode + purchase() + } + } + + is InstallerEvent.Failed -> { + if (app.packageName == event.packageName) { + InstallErrorDialogSheet.newInstance( + app, + event.packageName, + event.error, + event.extra + ).show(childFragmentManager, "SED") + attachActions() + updateActionState(State.IDLE) + } + } + + else -> { + + } + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + _binding = FragmentDetailsBinding.bind(view) + + // TODO: Parcel app class to better handle onNewIntent fun + isExternal = true + app = App(args.packageName) + fetchCompleteApp() + + // Toolbar + binding.layoutDetailsToolbar.toolbar.apply { + elevation = 0f + navigationIcon = ContextCompat.getDrawable(view.context, R.drawable.ic_arrow_back) + setNavigationOnClickListener { findNavController().navigateUp() } + inflateMenu(R.menu.menu_details) + + setOnMenuItemClickListener { + when (it.itemId) { + R.id.action_share -> { + view.context.share(app) + } + + R.id.action_uninstall -> { + uninstallApp() + } + + R.id.menu_download_manual -> { + val sheet = ManualDownloadSheet.newInstance(app) + sheet.isCancelable = false + sheet.show(childFragmentManager, ManualDownloadSheet.TAG) + } + + R.id.action_playstore -> { + view.context.browse("${Constants.SHARE_URL}${app.packageName}") + } + } + true + } + + if (::app.isInitialized) { + val installed = PackageUtil.isInstalled(requireContext(), app.packageName) + menu?.findItem(R.id.action_uninstall)?.isVisible = installed + uninstallActionEnabled = installed + } + } + + + activity?.onBackPressedDispatcher?.addCallback(this) { + if (isExternal) { + activity?.finish() + } else { + findNavController().navigateUp() + } + } + } + + override fun onResume() { + getUpdateServiceInstance() + checkAndSetupInstall() + super.onResume() + } + + private fun onNewIntent(intent: Intent) { + if (intent.scheme != null && (intent.scheme == "market" || intent.scheme == "http" || intent.scheme == "https")) { + val packageName = intent.data!!.getQueryParameter("id") + val packageVersion = intent.data!!.getQueryParameter("v") + if (packageName.isNullOrEmpty()) { + activity?.finishAfterTransition() + } else { + isExternal = true + app = App(packageName) + if (!packageVersion.isNullOrEmpty()) { + app.versionCode = packageVersion.toInt() + } + fetchCompleteApp() + } + } else { + val rawApp: String? = intent.getStringExtra(Constants.STRING_EXTRA) + if (rawApp != null) { + app = gson.fromJson(rawApp, App::class.java) + isInstalled = PackageUtil.isInstalled(requireContext(), app.packageName) + + inflatePartialApp() + fetchCompleteApp() + } else { + activity?.finishAfterTransition() + } + } + } + + private fun attachActions() { + flip(0) + checkAndSetupInstall() + } + + private fun updateActionState(state: State) { + binding.layoutDetailsInstall.btnDownload.updateState(state) + } + + private fun openApp() { + val intent = PackageUtil.getLaunchIntent(requireContext(), app.packageName) + if (intent != null) { + try { + startActivity(intent) + } catch (e: ActivityNotFoundException) { + toast("Unable to open app") + } + } + } + + private fun verifyAndInstall(files: List) { + if (downloadOnly) + return + + var filesExist = true + + files.forEach { download -> + filesExist = filesExist && File(download.file).exists() + } + + if (filesExist) + install(files) + else + purchase() + } + + @Synchronized + private fun install(files: List) { + updateActionState(State.IDLE) + + val apkFiles = files.filter { it.file.endsWith(".apk") } + val preferredInstaller = + Preferences.getInteger(requireContext(), Preferences.PREFERENCE_INSTALLER_ID) + + if (apkFiles.size > 1 && preferredInstaller == 1) { + showDialog(R.string.title_installer, R.string.dialog_desc_native_split) + } else { + task { + AppInstaller.getInstance(requireContext()) + .getPreferredInstaller() + .install( + app.packageName, + apkFiles.map { it.file } + ) + } fail { + Log.e(it.stackTraceToString()) + } + + runOnUiThread { + binding.layoutDetailsInstall.btnDownload.setText(getString(R.string.action_installing)) + } + } + } + + @Synchronized + private fun uninstallApp() { + task { + AppInstaller.getInstance(requireContext()) + .getPreferredInstaller() + .uninstall(app.packageName) + } + } + + private fun attachWhiteListStatus() { + + } + + private fun fetchCompleteApp() { + task { + authData = AuthProvider.with(requireContext()).getAuthData() + return@task AppDetailsHelper(authData) + .using(HttpClient.getPreferredClient()) + .getAppByPackageName(app.packageName) + } successUi { + if (isExternal) { + app = it + inflatePartialApp() + } + inflateExtraDetails(it) + } failUi { + toast("Failed to fetch app details") + } + } + + private fun inflatePartialApp() { + if (::app.isInitialized) { + attachWhiteListStatus() + attachHeader() + attachBottomSheet() + attachFetch() + attachActions() + + if (autoDownload) { + purchase() + } + } + } + + private fun attachHeader() { + binding.layoutDetailsApp.apply { + imgIcon.load(app.iconArtwork.url) { + placeholder(R.drawable.bg_placeholder) + transform(RoundedCorners(32)) + } + + txtLine1.text = app.displayName + txtLine2.text = app.developerName + txtLine2.setOnClickListener { + NavigationUtil.openDevAppsActivity( + requireContext(), + app + ) + } + txtLine3.text = ("${app.versionName} (${app.versionCode})") + packageName.text = app.packageName + + val tags = mutableListOf() + if (app.isFree) + tags.add(getString(R.string.details_free)) + else + tags.add(getString(R.string.details_paid)) + + if (app.containsAds) + tags.add(getString(R.string.details_contains_ads)) + else + tags.add(getString(R.string.details_no_ads)) + + txtLine4.text = tags.joinToString(separator = " • ") + } + } + + private fun inflateExtraDetails(app: App?) { + app?.let { + binding.viewFlipper.displayedChild = 1 + inflateAppDescription(binding.layoutDetailDescription, app) + inflateAppRatingAndReviews(binding.layoutDetailsReview, app) + inflateAppDevInfo(binding.layoutDetailsDev, app) + inflateAppPrivacy(binding.layoutDetailsPrivacy, app) + inflateAppPermission(binding.layoutDetailsPermissions, app) + + if (!authData.isAnonymous) { + app.testingProgram?.let { + if (it.isAvailable && it.isSubscribed) { + binding.layoutDetailsApp.txtLine1.text = it.displayName + } + } + + inflateBetaSubscription(binding.layoutDetailsBeta, app) + } + + if (Preferences.getBoolean(requireContext(), Preferences.PREFERENCE_SIMILAR)) { + inflateAppStream(binding.epoxyRecyclerStream, app) + } + } + } + + @Synchronized + private fun startDownload() { + when (status) { + Status.PAUSED -> { + fetch?.resumeGroup(app.getGroupId(requireContext())) + } + + Status.DOWNLOADING -> { + flip(1) + toast("Already downloading") + } + + Status.COMPLETED -> { + fetch?.getFetchGroup(app.getGroupId(requireContext())) { + verifyAndInstall(it.downloads) + } + } + + else -> { + purchase() + } + } + } + + private fun purchase() { + bottomSheetBehavior.isHideable = false + bottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED + updateActionState(State.PROGRESS) + + if (PathUtil.needsStorageManagerPerm(app.fileList) || requireContext().isExternalStorageEnable()) { + if (isRAndAbove()) { + if (!Environment.isExternalStorageManager()) { + startForStorageManagerResult.launch( + Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION) + ) + } else { + updateApp(app) + } + } else { + if (ContextCompat.checkSelfPermission( + requireContext(), + Manifest.permission.WRITE_EXTERNAL_STORAGE + ) == PackageManager.PERMISSION_GRANTED + ) { + updateApp(app) + } else { + startForPermissions.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE) + } + } + } else { + updateApp(app) + } + } + + private fun updateApp(app: App) { + if (updateService == null) { + listOfActionsWhenServiceAttaches.add { + updateService?.updateApp(app, true) + } + getUpdateServiceInstance() + } else { + updateService?.updateApp(app, true) + } + } + + private fun updateProgress( + fetchGroup: FetchGroup, + etaInMilliSeconds: Long, + downloadedBytesPerSecond: Long + ) { + runOnUiThread { + val progress = if (fetchGroup.groupDownloadProgress > 0) + fetchGroup.groupDownloadProgress + else + 0 + + if (progress == 100) { + binding.layoutDetailsInstall.btnDownload.setText(getString(R.string.action_installing)) + return@runOnUiThread + } + binding.layoutDetailsInstall.apply { + txtProgressPercent.text = ("${progress}%") + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + progressDownload.setProgress(progress, true) + } else { + progressDownload.progress = progress + } + + txtEta.text = CommonUtil.getETAString( + requireContext(), + etaInMilliSeconds + ) + txtSpeed.text = + CommonUtil.getDownloadSpeedString( + requireContext(), + downloadedBytesPerSecond + ) + } + } + } + + private fun expandBottomSheet(message: String?) { + bottomSheetBehavior.isHideable = false + bottomSheetBehavior.state = BottomSheetBehavior.STATE_EXPANDED + + with(binding.layoutDetailsInstall) { + txtPurchaseError.text = message + btnDownload.updateState(State.IDLE) + if (app.isFree) + btnDownload.setText(R.string.action_install) + else + btnDownload.setText(app.price) + } + } + + private fun checkAndSetupInstall() { + isInstalled = PackageUtil.isInstalled(requireContext(), app.packageName) + + binding.layoutDetailsInstall.btnDownload.let { btn -> + if (isInstalled) { + isUpdatable = PackageUtil.isUpdatable( + requireContext(), + app.packageName, + app.versionCode.toLong() + ) + + val installedVersion = + PackageUtil.getInstalledVersion(requireContext(), app.packageName) + + if (isUpdatable) { + binding.layoutDetailsApp.txtLine3.text = + ("$installedVersion ➔ ${app.versionName} (${app.versionCode})") + btn.setText(R.string.action_update) + btn.addOnClickListener { startDownload() } + } else { + binding.layoutDetailsApp.txtLine3.text = installedVersion + btn.setText(R.string.action_open) + btn.addOnClickListener { openApp() } + } + if (!uninstallActionEnabled) { + binding.layoutDetailsToolbar.toolbar.invalidateMenu() + } + } else { + if (app.isFree) { + btn.setText(R.string.action_install) + } else { + btn.setText(app.price) + } + + btn.addOnClickListener { + if (authData.isAnonymous && !app.isFree) { + toast(R.string.toast_purchase_blocked) + } else { + btn.setText(R.string.download_metadata) + startDownload() + } + } + if (uninstallActionEnabled) { + binding.layoutDetailsToolbar.toolbar.invalidateMenu() + } + } + } + } + + @Synchronized + private fun flip(nextView: Int) { + runOnUiThread { + val displayChild = binding.layoutDetailsInstall.viewFlipper.displayedChild + if (displayChild != nextView) { + binding.layoutDetailsInstall.viewFlipper.displayedChild = nextView + if (nextView == 0) checkAndSetupInstall() + } + } + } + + private fun attachFetch() { + if (fetch == null) { + downloadManager = DownloadManager.with(requireContext()) + fetch = downloadManager!!.fetch + } + fetch?.getFetchGroup(app.getGroupId(requireContext())) { fetchGroup: FetchGroup -> + if (fetchGroup.groupDownloadProgress == 100 && fetchGroup.completedDownloads.isNotEmpty()) { + status = Status.COMPLETED + } else if (downloadManager?.isDownloading(fetchGroup) == true) { + status = Status.DOWNLOADING + flip(1) + } else if (downloadManager?.isCanceled(fetchGroup) == true) { + status = Status.CANCELLED + } else if (fetchGroup.pausedDownloads.isNotEmpty()) { + status = Status.PAUSED + } else { + status = Status.NONE + } + } + + fetchGroupListener = object : AbstractFetchGroupListener() { + + override fun onAdded(groupId: Int, download: Download, fetchGroup: FetchGroup) { + if (groupId == app.getGroupId(requireContext())) { + status = download.status + } + } + + override fun onStarted( + groupId: Int, + download: Download, + downloadBlocks: List, + totalBlocks: Int, + fetchGroup: FetchGroup + ) { + if (groupId == app.getGroupId(requireContext())) { + status = download.status + flip(1) + + val pkgDir = PathUtil.getPackageDirectory(requireContext(), app.packageName) + completionMarker = + File("$pkgDir/.${app.versionCode}.download-complete") + inProgressMarker = + File("$pkgDir/.${app.versionCode}.download-in-progress") + + if (completionMarker.exists()) + completionMarker.delete() + + inProgressMarker.createNewFile() + } + } + + override fun onResumed(groupId: Int, download: Download, fetchGroup: FetchGroup) { + if (groupId == app.getGroupId(requireContext())) { + status = download.status + flip(1) + inProgressMarker.parentFile?.mkdirs() + inProgressMarker.createNewFile() + } + } + + override fun onPaused(groupId: Int, download: Download, fetchGroup: FetchGroup) { + if (groupId == app.getGroupId(requireContext())) { + status = download.status + flip(0) + } + } + + override fun onProgress( + groupId: Int, + download: Download, + etaInMilliSeconds: Long, + downloadedBytesPerSecond: Long, + fetchGroup: FetchGroup + ) { + if (groupId == app.getGroupId(requireContext())) { + updateProgress(fetchGroup, etaInMilliSeconds, downloadedBytesPerSecond) + Log.i( + "${app.displayName} : ${download.file} -> Progress : %d", + fetchGroup.groupDownloadProgress + ) + } + } + + override fun onCompleted(groupId: Int, download: Download, fetchGroup: FetchGroup) { + if (groupId == app.getGroupId(requireContext()) && fetchGroup.groupDownloadProgress == 100) { + status = download.status + flip(0) + updateProgress(fetchGroup, -1, -1) + try { + inProgressMarker.delete() + completionMarker.createNewFile() + } catch (ex: Exception) { + ex.printStackTrace() + } + } + } + + override fun onCancelled(groupId: Int, download: Download, fetchGroup: FetchGroup) { + if (groupId == app.getGroupId(requireContext())) { + status = download.status + flip(0) + inProgressMarker.delete() + } + } + + override fun onError( + groupId: Int, + download: Download, + error: Error, + throwable: Throwable?, + fetchGroup: FetchGroup + ) { + if (groupId == app.getGroupId(requireContext())) { + status = download.status + flip(0) + inProgressMarker.delete() + } + } + } + + appMetadataListener = object : AppMetadataStatusListener { + override fun onAppMetadataStatusError(reason: String, app: App) { + if (app.packageName == this@AppDetailsFragment.app.packageName) { + updateActionState(State.IDLE) + expandBottomSheet(reason) + } + } + } + + getUpdateServiceInstance() + + binding.layoutDetailsInstall.imgCancel.setOnClickListener { + fetch?.cancelGroup( + app.getGroupId(requireContext()) + ) + } + if (updateService != null) { + pendingAddListeners = false + updateService!!.registerFetchListener(fetchGroupListener) + // appMetadataListener needs to be initialized after the fetchGroupListener + updateService!!.registerAppMetadataListener(appMetadataListener) + } else { + pendingAddListeners = true + } + } + + private fun getUpdateServiceInstance() { + if (updateService == null && !attachToServiceCalled) { + attachToServiceCalled = true + val intent = Intent(requireContext(), UpdateService::class.java) + activity?.startService(intent) + activity?.bindService( + intent, + serviceConnection, + 0 + ) + } + } + + override fun onPause() { + if (updateService != null) { + updateService = null + attachToServiceCalled = false + pendingAddListeners = true + activity?.unbindService(serviceConnection) + } + super.onPause() + } + + override fun onDestroy() { + super.onDestroy() + if (updateService != null) { + updateService = null + attachToServiceCalled = false + pendingAddListeners = true + activity?.unbindService(serviceConnection) + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + private fun attachBottomSheet() { + binding.layoutDetailsInstall.apply { + viewFlipper.setInAnimation(requireContext(), R.anim.fade_in) + viewFlipper.setOutAnimation(requireContext(), R.anim.fade_out) + } + + bottomSheetBehavior = BottomSheetBehavior.from(binding.layoutDetailsInstall.bottomSheet) + bottomSheetBehavior.isDraggable = false + + bottomSheetBehavior.addBottomSheetCallback(object : BottomSheetCallback() { + override fun onStateChanged(bottomSheet: View, newState: Int) { + if (newState == BottomSheetBehavior.STATE_EXPANDED) { + bottomSheetBehavior.setDraggable(true) + } else if (newState == BottomSheetBehavior.STATE_COLLAPSED) { + bottomSheetBehavior.isDraggable = false + } + } + + override fun onSlide(bottomSheet: View, slideOffset: Float) {} + }) + } + + //Sub Section Inflation + private fun inflateAppDescription(B: LayoutDetailsDescriptionBinding, app: App) { + val installs = CommonUtil.addDiPrefix(app.installs) + + if (installs != "NA") { + B.txtInstalls.text = CommonUtil.addDiPrefix(app.installs) + } else { + B.txtInstalls.hide() + } + + B.txtSize.text = CommonUtil.addSiPrefix(app.size) + B.txtRating.text = app.labeledRating + B.txtSdk.text = ("Target SDK ${app.targetSdk}") + B.txtUpdated.text = app.updatedOn + B.txtDescription.text = HtmlCompat.fromHtml( + app.shortDescription, + HtmlCompat.FROM_HTML_OPTION_USE_CSS_COLORS + ) + + app.changes.apply { + if (isEmpty()) { + B.txtChangelog.text = getString(R.string.details_changelog_unavailable) + } else { + B.txtChangelog.text = HtmlCompat.fromHtml( + this, + HtmlCompat.FROM_HTML_MODE_COMPACT + ) + } + } + + B.headerDescription.addClickListener { + (activity as MainActivity).openDetailsMoreActivity(app) + } + + B.epoxyRecycler.withModels { + setFilterDuplicates(true) + var position = 0 + app.screenshots + //.sortedWith { o1, o2 -> o2.height.compareTo(o1.height) } + .forEach { artwork -> + add( + ScreenshotViewModel_() + .id(artwork.url) + .artwork(artwork) + .position(position++) + .callback(object : ScreenshotView.ScreenshotCallback { + override fun onClick(position: Int) { + (activity as MainActivity).openScreenshotActivity(app, position) + } + }) + ) + } + } + } + + private fun inflateAppRatingAndReviews(B: LayoutDetailsReviewBinding, app: App) { + B.averageRating.text = app.rating.average.toString() + B.txtReviewCount.text = app.rating.abbreviatedLabel + + var totalStars = 0L + totalStars += app.rating.oneStar + totalStars += app.rating.twoStar + totalStars += app.rating.threeStar + totalStars += app.rating.fourStar + totalStars += app.rating.fiveStar + + B.avgRatingLayout.apply { + removeAllViews() + addView(addAvgReviews(5, totalStars, app.rating.fiveStar)) + addView(addAvgReviews(4, totalStars, app.rating.fourStar)) + addView(addAvgReviews(3, totalStars, app.rating.threeStar)) + addView(addAvgReviews(2, totalStars, app.rating.twoStar)) + addView(addAvgReviews(1, totalStars, app.rating.oneStar)) + } + + B.averageRating.text = String.format(Locale.getDefault(), "%.1f", app.rating.average) + B.txtReviewCount.text = app.rating.abbreviatedLabel + + val authData = AuthProvider.with(requireContext()).getAuthData() + + B.layoutUserReview.visibility = if (authData.isAnonymous) View.GONE else View.VISIBLE + + B.btnPostReview.setOnClickListener { + if (authData.isAnonymous) { + toast(R.string.toast_anonymous_restriction) + } else { + addOrUpdateReview(B, app, Review().apply { + title = authData.userProfile!!.name + rating = B.userStars.rating.toInt() + comment = B.inputReview.text.toString() + }) + } + } + + B.headerRatingReviews.addClickListener { + (activity as MainActivity).openDetailsReviewActivity(app) + } + + task { + fetchReviewSummary(app) + } successUi { + B.epoxyRecycler.withModels { + it.take(4) + .forEach { + add( + ReviewViewModel_() + .id(it.timeStamp) + .review(it) + ) + } + } + } failUi { + + } + + } + + private fun inflateAppPrivacy(B: LayoutDetailsPrivacyBinding, app: App) { + + task { + fetchReport(app.packageName) + } successUi { report -> + if (report.trackers.isNotEmpty()) { + B.txtStatus.apply { + setTextColor( + ContextCompat.getColor( + requireContext(), + if (report.trackers.size > 4) + R.color.colorRed + else + R.color.colorOrange + ) + ) + text = + ("${report.trackers.size} ${getString(R.string.exodus_substring)} ${report.version}") + } + + B.headerPrivacy.addClickListener { + NavigationUtil.openExodusActivity(requireContext(), app, report) + } + } else { + B.txtStatus.apply { + setTextColor( + ContextCompat.getColor( + requireContext(), + R.color.colorGreen + ) + ) + text = getString(R.string.exodus_no_tracker) + } + } + } failUi { + B.txtStatus.text = it.message + } + } + + private fun inflateAppDevInfo(B: LayoutDetailsDevBinding, app: App) { + if (app.developerAddress.isNotEmpty()) { + B.devAddress.apply { + setTxtSubtitle( + HtmlCompat.fromHtml( + app.developerAddress, + HtmlCompat.FROM_HTML_MODE_LEGACY + ).toString() + ) + visibility = View.VISIBLE + } + } + + if (app.developerWebsite.isNotEmpty()) { + B.devWeb.apply { + setTxtSubtitle(app.developerWebsite) + visibility = View.VISIBLE + } + } + + if (app.developerEmail.isNotEmpty()) { + B.devMail.apply { + setTxtSubtitle(app.developerEmail) + visibility = View.VISIBLE + } + } + } + + private fun inflateBetaSubscription(B: LayoutDetailsBetaBinding, app: App) { + app.testingProgram?.let { betaProgram -> + if (betaProgram.isAvailable) { + B.root.show() + + updateBetaActions(B, betaProgram.isSubscribed) + + if (betaProgram.isSubscribedAndInstalled) { + + } + + B.imgBeta.load(betaProgram.artwork.url) { + + } + + B.btnBetaAction.setOnClickListener { + val authData = AuthProvider.with(requireContext()).getAuthData() + task { + B.btnBetaAction.text = getString(R.string.action_pending) + B.btnBetaAction.isEnabled = false + AppDetailsHelper(authData).testingProgram( + app.packageName, + !betaProgram.isSubscribed + ) + } successUi { + B.btnBetaAction.isEnabled = true + if (it.subscribed) { + updateBetaActions(B, true) + } + if (it.unsubscribed) { + updateBetaActions(B, false) + } + } failUi { + updateBetaActions(B, betaProgram.isSubscribed) + toast(getString(R.string.details_beta_delay)) + } + } + } else { + B.root.hide() + } + } + } + + private fun inflateAppStream(epoxyRecyclerView: EpoxyRecyclerView, app: App) { + app.detailsStreamUrl?.let { + val VM = ViewModelProvider(this)[DetailsClusterViewModel::class.java] + + val carouselController = + DetailsCarouselController(object : GenericCarouselController.Callbacks { + override fun onHeaderClicked(streamCluster: StreamCluster) { + if (streamCluster.clusterBrowseUrl.isNotEmpty()) + openStreamBrowseActivity( + streamCluster.clusterBrowseUrl, + streamCluster.clusterTitle + ) + else + toast(getString(R.string.toast_page_unavailable)) + } + + override fun onClusterScrolled(streamCluster: StreamCluster) { + VM.observeCluster(streamCluster) + } + + override fun onAppClick(app: App) { + openDetailsActivity(app) + } + + override fun onAppLongClick(app: App) { + + } + }) + + VM.liveData.observe(viewLifecycleOwner) { + when (it) { + is ViewState.Empty -> { + } + + is ViewState.Loading -> { + + } + + is ViewState.Error -> { + + } + + is ViewState.Status -> { + + } + + is ViewState.Success<*> -> { + carouselController.setData(it.data as StreamBundle) + } + } + } + + epoxyRecyclerView.setController(carouselController) + + VM.getStreamBundle(it) + } + } + + private fun inflateAppPermission(B: LayoutDetailsPermissionsBinding, app: App) { + B.headerPermission.addClickListener { + if (app.permissions.size > 0) { + PermissionBottomSheet.newInstance(app) + .show(childFragmentManager, PermissionBottomSheet.TAG) + } + } + B.txtPermissionCount.text = ("${app.permissions.size} permissions") + } + + private fun updateBetaActions(B: LayoutDetailsBetaBinding, isSubscribed: Boolean) { + if (isSubscribed) { + B.btnBetaAction.text = getString(R.string.action_leave) + B.txtBetaTitle.text = getString(R.string.details_beta_subscribed) + } else { + B.btnBetaAction.text = getString(R.string.action_join) + B.txtBetaTitle.text = getString(R.string.details_beta_available) + } + } + + /* App Review Helpers */ + + private fun addAvgReviews(number: Int, max: Long, rating: Long): RelativeLayout { + return RatingView(requireContext(), number, max.toInt(), rating.toInt()) + } + + private fun addOrUpdateReview( + B: LayoutDetailsReviewBinding, + app: App, + review: Review, + isBeta: Boolean = false + ) { + task { + val authData = AuthProvider.with(requireContext()).getAuthData() + ReviewsHelper(authData) + .using(HttpClient.getPreferredClient()) + .addOrEditReview( + app.packageName, + review.title, + review.comment, + review.rating, + isBeta + ) + }.successUi { + it?.let { + B.userStars.rating = it.rating.toFloat() + Toast.makeText( + requireContext(), + getString(R.string.toast_rated_success), + Toast.LENGTH_SHORT + ).show() + } + }.failUi { + Toast.makeText( + requireContext(), + getString(R.string.toast_rated_failed), + Toast.LENGTH_SHORT + ).show() + } + } + + private fun fetchReviewSummary(app: App): List { + val authData = AuthProvider + .with(requireContext()) + .getAuthData() + val reviewsHelper = ReviewsHelper(authData) + .using(HttpClient.getPreferredClient()) + return reviewsHelper.getReviewSummary(app.packageName) + } + + /* App Privacy Helpers */ + + private fun parseResponse(response: String, packageName: String): List { + try { + val jsonObject = JSONObject(response) + val exodusObject = jsonObject.getJSONObject(packageName) + val exodusReport: ExodusReport = gson.fromJson( + exodusObject.toString(), + ExodusReport::class.java + ) + return exodusReport.reports + } catch (e: Exception) { + throw Exception("No reports found") + } + } + + private fun fetchReport(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 + .getPreferredClient() + .get(url, headers) + + if (playResponse.isSuccessful) { + return parseResponse(String(playResponse.responseBytes), packageName)[0] + } else { + throw Exception("Failed to fetch report") + } + } +} diff --git a/app/src/main/java/com/aurora/store/view/ui/details/BaseDetailsActivity.kt b/app/src/main/java/com/aurora/store/view/ui/details/BaseDetailsActivity.kt deleted file mode 100644 index 76a9920b3..000000000 --- a/app/src/main/java/com/aurora/store/view/ui/details/BaseDetailsActivity.kt +++ /dev/null @@ -1,443 +0,0 @@ -/* - * Aurora Store - * Copyright (C) 2021, Rahul Kumar Patel - * - * 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 . - * - */ - -package com.aurora.store.view.ui.details - -import android.view.View -import android.widget.RelativeLayout -import android.widget.Toast -import androidx.core.content.ContextCompat -import androidx.core.text.HtmlCompat -import androidx.lifecycle.ViewModelProvider -import com.airbnb.epoxy.EpoxyRecyclerView -import com.aurora.extensions.hide -import com.aurora.extensions.load -import com.aurora.extensions.show -import com.aurora.extensions.toast -import com.aurora.gplayapi.data.models.* -import com.aurora.gplayapi.helpers.AppDetailsHelper -import com.aurora.gplayapi.helpers.ReviewsHelper -import com.aurora.store.R -import com.aurora.store.data.ViewState -import com.aurora.store.data.model.ExodusReport -import com.aurora.store.data.model.Report -import com.aurora.store.data.network.HttpClient -import com.aurora.store.data.providers.AuthProvider -import com.aurora.store.databinding.* -import com.aurora.store.util.CommonUtil -import com.aurora.store.util.NavigationUtil -import com.aurora.store.view.custom.RatingView -import com.aurora.store.view.epoxy.controller.DetailsCarouselController -import com.aurora.store.view.epoxy.controller.GenericCarouselController -import com.aurora.store.view.epoxy.views.details.ReviewViewModel_ -import com.aurora.store.view.epoxy.views.details.ScreenshotView -import com.aurora.store.view.epoxy.views.details.ScreenshotViewModel_ -import com.aurora.store.view.ui.commons.BaseActivity -import com.aurora.store.view.ui.sheets.PermissionBottomSheet -import com.aurora.store.viewmodel.details.DetailsClusterViewModel -import nl.komponents.kovenant.task -import nl.komponents.kovenant.ui.failUi -import nl.komponents.kovenant.ui.successUi -import org.json.JSONObject -import java.util.* - - -abstract class BaseDetailsActivity : BaseActivity() { - - private val exodusBaseUrl = "https://reports.exodus-privacy.eu.org/api/search/" - private val exodusApiKey = "Token bbe6ebae4ad45a9cbacb17d69739799b8df2c7ae" - - //Sub Section Inflation - fun inflateAppDescription(B: LayoutDetailsDescriptionBinding, app: App) { - val installs = CommonUtil.addDiPrefix(app.installs) - - if (installs != "NA") { - B.txtInstalls.text = CommonUtil.addDiPrefix(app.installs) - } else { - B.txtInstalls.hide() - } - - B.txtSize.text = CommonUtil.addSiPrefix(app.size) - B.txtRating.text = app.labeledRating - B.txtSdk.text = ("Target SDK ${app.targetSdk}") - B.txtUpdated.text = app.updatedOn - B.txtDescription.text = HtmlCompat.fromHtml( - app.shortDescription, - HtmlCompat.FROM_HTML_OPTION_USE_CSS_COLORS - ) - - app.changes.apply { - if (isEmpty()) { - B.txtChangelog.text = getString(R.string.details_changelog_unavailable) - } else { - B.txtChangelog.text = HtmlCompat.fromHtml( - this, - HtmlCompat.FROM_HTML_MODE_COMPACT - ) - } - } - - B.headerDescription.addClickListener { - openDetailsMoreActivity(app) - } - - B.epoxyRecycler.withModels { - setFilterDuplicates(true) - var position = 0 - app.screenshots - //.sortedWith { o1, o2 -> o2.height.compareTo(o1.height) } - .forEach { artwork -> - add( - ScreenshotViewModel_() - .id(artwork.url) - .artwork(artwork) - .position(position++) - .callback(object : ScreenshotView.ScreenshotCallback { - override fun onClick(position: Int) { - openScreenshotActivity(app, position) - } - }) - ) - } - } - } - - fun inflateAppRatingAndReviews(B: LayoutDetailsReviewBinding, app: App) { - B.averageRating.text = app.rating.average.toString() - B.txtReviewCount.text = app.rating.abbreviatedLabel - - var totalStars = 0L - totalStars += app.rating.oneStar - totalStars += app.rating.twoStar - totalStars += app.rating.threeStar - totalStars += app.rating.fourStar - totalStars += app.rating.fiveStar - - B.avgRatingLayout.apply { - removeAllViews() - addView(addAvgReviews(5, totalStars, app.rating.fiveStar)) - addView(addAvgReviews(4, totalStars, app.rating.fourStar)) - addView(addAvgReviews(3, totalStars, app.rating.threeStar)) - addView(addAvgReviews(2, totalStars, app.rating.twoStar)) - addView(addAvgReviews(1, totalStars, app.rating.oneStar)) - } - - B.averageRating.text = String.format(Locale.getDefault(), "%.1f", app.rating.average) - B.txtReviewCount.text = app.rating.abbreviatedLabel - - val authData = AuthProvider.with(this).getAuthData() - - B.layoutUserReview.visibility = if (authData.isAnonymous) View.GONE else View.VISIBLE - - B.btnPostReview.setOnClickListener { - if (authData.isAnonymous) { - toast(R.string.toast_anonymous_restriction) - } else { - addOrUpdateReview(B, app, Review().apply { - title = authData.userProfile!!.name - rating = B.userStars.rating.toInt() - comment = B.inputReview.text.toString() - }) - } - } - - B.headerRatingReviews.addClickListener { - openDetailsReviewActivity(app) - } - - task { - fetchReviewSummary(app) - } successUi { - B.epoxyRecycler.withModels { - it.take(4) - .forEach { - add( - ReviewViewModel_() - .id(it.timeStamp) - .review(it) - ) - } - } - } failUi { - - } - - } - - fun inflateAppPrivacy(B: LayoutDetailsPrivacyBinding, app: App) { - - task { - fetchReport(app.packageName) - } successUi { report -> - if (report.trackers.isNotEmpty()) { - B.txtStatus.apply { - setTextColor( - ContextCompat.getColor( - this@BaseDetailsActivity, - if (report.trackers.size > 4) - R.color.colorRed - else - R.color.colorOrange - ) - ) - text = - ("${report.trackers.size} ${getString(R.string.exodus_substring)} ${report.version}") - } - - B.headerPrivacy.addClickListener { - NavigationUtil.openExodusActivity(this, app, report) - } - } else { - B.txtStatus.apply { - setTextColor( - ContextCompat.getColor( - this@BaseDetailsActivity, - R.color.colorGreen - ) - ) - text = getString(R.string.exodus_no_tracker) - } - } - } failUi { - B.txtStatus.text = it.message - } - } - - fun inflateAppDevInfo(B: LayoutDetailsDevBinding, app: App) { - if (app.developerAddress.isNotEmpty()) { - B.devAddress.apply { - setTxtSubtitle( - HtmlCompat.fromHtml( - app.developerAddress, - HtmlCompat.FROM_HTML_MODE_LEGACY - ).toString() - ) - visibility = View.VISIBLE - } - } - - if (app.developerWebsite.isNotEmpty()) { - B.devWeb.apply { - setTxtSubtitle(app.developerWebsite) - visibility = View.VISIBLE - } - } - - if (app.developerEmail.isNotEmpty()) { - B.devMail.apply { - setTxtSubtitle(app.developerEmail) - visibility = View.VISIBLE - } - } - } - - fun inflateBetaSubscription(B: LayoutDetailsBetaBinding, app: App) { - app.testingProgram?.let { betaProgram -> - if (betaProgram.isAvailable) { - B.root.show() - - updateBetaActions(B, betaProgram.isSubscribed) - - if (betaProgram.isSubscribedAndInstalled) { - - } - - B.imgBeta.load(betaProgram.artwork.url) { - - } - - B.btnBetaAction.setOnClickListener { - val authData = AuthProvider.with(this).getAuthData() - task { - B.btnBetaAction.text = getString(R.string.action_pending) - B.btnBetaAction.isEnabled = false - AppDetailsHelper(authData).testingProgram( - app.packageName, - !betaProgram.isSubscribed - ) - } successUi { - B.btnBetaAction.isEnabled = true - if (it.subscribed) { - updateBetaActions(B, true) - } - if (it.unsubscribed) { - updateBetaActions(B, false) - } - } failUi { - updateBetaActions(B, betaProgram.isSubscribed) - toast(getString(R.string.details_beta_delay)) - } - } - } else { - B.root.hide() - } - } - } - - fun inflateAppStream(epoxyRecyclerView: EpoxyRecyclerView, app: App) { - app.detailsStreamUrl?.let { - val VM = ViewModelProvider(this)[DetailsClusterViewModel::class.java] - - val carouselController = - DetailsCarouselController(object : GenericCarouselController.Callbacks { - override fun onHeaderClicked(streamCluster: StreamCluster) { - if (streamCluster.clusterBrowseUrl.isNotEmpty()) - openStreamBrowseActivity( - streamCluster.clusterBrowseUrl, - streamCluster.clusterTitle - ) - else - toast(getString(R.string.toast_page_unavailable)) - } - - override fun onClusterScrolled(streamCluster: StreamCluster) { - VM.observeCluster(streamCluster) - } - - override fun onAppClick(app: App) { - openDetailsActivity(app) - } - - override fun onAppLongClick(app: App) { - - } - }) - - VM.liveData.observe(this) { - when (it) { - is ViewState.Empty -> { - } - is ViewState.Loading -> { - - } - is ViewState.Error -> { - - } - is ViewState.Status -> { - - } - is ViewState.Success<*> -> { - carouselController.setData(it.data as StreamBundle) - } - } - } - - epoxyRecyclerView.setController(carouselController) - - VM.getStreamBundle(it) - } - } - - fun inflateAppPermission(B: LayoutDetailsPermissionsBinding, app: App) { - B.headerPermission.addClickListener { - if (app.permissions.size > 0) { - PermissionBottomSheet.newInstance(app) - .show(supportFragmentManager, PermissionBottomSheet.TAG) - } - } - B.txtPermissionCount.text = ("${app.permissions.size} permissions") - } - - private fun updateBetaActions(B: LayoutDetailsBetaBinding, isSubscribed: Boolean) { - if (isSubscribed) { - B.btnBetaAction.text = getString(R.string.action_leave) - B.txtBetaTitle.text = getString(R.string.details_beta_subscribed) - } else { - B.btnBetaAction.text = getString(R.string.action_join) - B.txtBetaTitle.text = getString(R.string.details_beta_available) - } - } - - /* App Review Helpers */ - - private fun addAvgReviews(number: Int, max: Long, rating: Long): RelativeLayout { - return RatingView(this, number, max.toInt(), rating.toInt()) - } - - private fun addOrUpdateReview( - B: LayoutDetailsReviewBinding, - app: App, - review: Review, - isBeta: Boolean = false - ) { - task { - val authData = AuthProvider.with(this).getAuthData() - ReviewsHelper(authData) - .using(HttpClient.getPreferredClient()) - .addOrEditReview( - app.packageName, - review.title, - review.comment, - review.rating, - isBeta - ) - }.successUi { - it?.let { - B.userStars.rating = it.rating.toFloat() - Toast.makeText(this, getString(R.string.toast_rated_success), Toast.LENGTH_SHORT).show() - } - }.failUi { - Toast.makeText(this, getString(R.string.toast_rated_failed), Toast.LENGTH_SHORT).show() - } - } - - private fun fetchReviewSummary(app: App): List { - val authData = AuthProvider - .with(this) - .getAuthData() - val reviewsHelper = ReviewsHelper(authData) - .using(HttpClient.getPreferredClient()) - return reviewsHelper.getReviewSummary(app.packageName) - } - - /* App Privacy Helpers */ - - private fun parseResponse(response: String, packageName: String): List { - try { - val jsonObject = JSONObject(response) - val exodusObject = jsonObject.getJSONObject(packageName) - val exodusReport: ExodusReport = gson.fromJson( - exodusObject.toString(), - ExodusReport::class.java - ) - return exodusReport.reports - } catch (e: Exception) { - throw Exception("No reports found") - } - } - - private fun fetchReport(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 - .getPreferredClient() - .get(url, headers) - - if (playResponse.isSuccessful) { - return parseResponse(String(playResponse.responseBytes), packageName)[0] - } else { - throw Exception("Failed to fetch report") - } - } -} diff --git a/app/src/main/java/com/aurora/store/view/ui/details/EmptyAppDetailsActivity.kt b/app/src/main/java/com/aurora/store/view/ui/details/EmptyAppDetailsActivity.kt deleted file mode 100644 index c38d96af6..000000000 --- a/app/src/main/java/com/aurora/store/view/ui/details/EmptyAppDetailsActivity.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.aurora.store.view.ui.details - -import android.content.Intent -import android.os.Bundle -import androidx.appcompat.app.AppCompatActivity -import com.aurora.store.R - -class EmptyAppDetailsActivity: AppCompatActivity(R.layout.activity_details) { - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - onNewIntent(intent) - } - - override fun onNewIntent(intent: Intent?) { - super.onNewIntent(intent) - val validSchemes = listOf("market", "http", "https") - - if (intent != null && validSchemes.any { it == intent.scheme }) { - if (intent.data!!.getQueryParameter("id").isNullOrEmpty()) { - finishAfterTransition() - } else { - // Construct a new intent manually to avoid accepting extras from external apps - Intent(this, AppDetailsActivity::class.java).also { extIntent -> - extIntent.data = intent.data - startActivity(extIntent) - } - } - - } - } -} diff --git a/app/src/main/res/layout/activity_details.xml b/app/src/main/res/layout/fragment_details.xml similarity index 97% rename from app/src/main/res/layout/activity_details.xml rename to app/src/main/res/layout/fragment_details.xml index c74b41030..ad292428c 100644 --- a/app/src/main/res/layout/activity_details.xml +++ b/app/src/main/res/layout/fragment_details.xml @@ -23,7 +23,8 @@ android:id="@+id/container" android:layout_width="match_parent" android:layout_height="match_parent" - android:orientation="vertical"> + android:orientation="vertical" + tools:context=".view.ui.details.AppDetailsFragment"> - \ No newline at end of file + diff --git a/app/src/main/res/navigation/mobile_navigation.xml b/app/src/main/res/navigation/mobile_navigation.xml index b70490728..0d8dd5ced 100644 --- a/app/src/main/res/navigation/mobile_navigation.xml +++ b/app/src/main/res/navigation/mobile_navigation.xml @@ -98,4 +98,18 @@ android:name="com.aurora.store.view.ui.downloads.DownloadFragment" android:label="@string/title_download_manager" tools:layout="@layout/fragment_download" /> - \ No newline at end of file + + + + + +