Remove: The old-already-migrated hierarchy (+ BIG CLEAN UP)

This commit is contained in:
machiav3lli 2022-02-02 02:13:09 +01:00
parent 7694f3cade
commit 3f454c5199
13 changed files with 10 additions and 2081 deletions

View File

@ -1,135 +0,0 @@
package com.looker.droidify.database
import android.database.Cursor
import android.os.Bundle
import androidx.fragment.app.Fragment
import androidx.loader.app.LoaderManager
import androidx.loader.content.Loader
import com.looker.droidify.entity.Order
import com.looker.droidify.entity.Section
class CursorOwner : Fragment(), LoaderManager.LoaderCallbacks<Cursor> {
sealed class Request {
internal abstract val id: Int
data class ProductsAvailable(
val searchQuery: String, val section: Section,
val order: Order,
) : Request() {
override val id: Int
get() = 1
}
data class ProductsInstalled(
val searchQuery: String, val section: Section,
val order: Order,
) : Request() {
override val id: Int
get() = 2
}
data class ProductsUpdates(
val searchQuery: String, val section: Section,
val order: Order,
) : Request() {
override val id: Int
get() = 3
}
object Repositories : Request() {
override val id: Int
get() = 4
}
}
interface Callback {
fun onCursorData(request: Request, cursor: Cursor?)
}
data class ActiveRequest(
val request: Request,
val callback: Callback?,
val cursor: Cursor?,
)
init {
retainInstance = true
}
private val activeRequests = mutableMapOf<Int, ActiveRequest>()
fun attach(callback: Callback, request: Request) {
val oldActiveRequest = activeRequests[request.id]
if (oldActiveRequest?.callback != null &&
oldActiveRequest.callback != callback && oldActiveRequest.cursor != null
) {
oldActiveRequest.callback.onCursorData(oldActiveRequest.request, null)
}
val cursor = if (oldActiveRequest?.request == request && oldActiveRequest.cursor != null) {
callback.onCursorData(request, oldActiveRequest.cursor)
oldActiveRequest.cursor
} else {
null
}
activeRequests[request.id] = ActiveRequest(request, callback, cursor)
if (cursor == null) {
LoaderManager.getInstance(this).restartLoader(request.id, null, this)
}
}
fun detach(callback: Callback) {
for (id in activeRequests.keys) {
val activeRequest = activeRequests[id]!!
if (activeRequest.callback == callback) {
activeRequests[id] = activeRequest.copy(callback = null)
}
}
}
override fun onCreateLoader(id: Int, args: Bundle?): Loader<Cursor> {
val request = activeRequests[id]!!.request
val db = DatabaseX.getInstance(requireContext())
return QueryLoader(requireContext()) {
when (request) {
is Request.ProductsAvailable -> db.productDao
.query(
installed = false,
updates = false,
searchQuery = request.searchQuery,
section = request.section,
order = request.order,
signal = it
)
is Request.ProductsInstalled -> db.productDao
.query(
installed = true,
updates = false,
searchQuery = request.searchQuery,
section = request.section,
order = request.order,
signal = it
)
is Request.ProductsUpdates -> db.productDao
.query(
installed = true,
updates = true,
searchQuery = request.searchQuery,
section = request.section,
order = request.order,
signal = it
)
is Request.Repositories -> db.repositoryDao.allCursor
}
}
}
override fun onLoadFinished(loader: Loader<Cursor>, data: Cursor?) {
val activeRequest = activeRequests[loader.id]
if (activeRequest != null) {
activeRequests[loader.id] = activeRequest.copy(cursor = data)
activeRequest.callback?.onCursorData(activeRequest.request, data)
}
}
override fun onLoaderReset(loader: Loader<Cursor>) = onLoadFinished(loader, null)
}

View File

@ -1,7 +1,5 @@
package com.looker.droidify.database
import android.database.Cursor
import android.os.CancellationSignal
import androidx.lifecycle.LiveData
import androidx.paging.DataSource
import androidx.room.*
@ -13,7 +11,6 @@ import com.looker.droidify.entity.Order
import com.looker.droidify.entity.Section
import io.reactivex.rxjava3.core.Flowable
interface BaseDao<T> {
@Insert
fun insert(vararg product: T)
@ -49,9 +46,6 @@ interface RepositoryDao : BaseDao<Repository> {
@Query("SELECT * FROM repository WHERE _id = :id")
fun getLive(id: Long): LiveData<Repository?>
@get:Query("SELECT * FROM repository ORDER BY _id ASC")
val allCursor: Cursor
@get:Query("SELECT * FROM repository ORDER BY _id ASC")
val all: List<Repository>
@ -86,95 +80,6 @@ interface ProductDao : BaseDao<Product> {
@Query("DELETE FROM product WHERE repository_id = :id")
fun deleteById(id: Long): Int
@RawQuery
fun query(
query: SupportSQLiteQuery
): Cursor
// TODO Remove
@Transaction
fun query(
installed: Boolean, updates: Boolean, searchQuery: String,
section: Section, order: Order, signal: CancellationSignal?
): Cursor {
val builder = QueryBuilder()
val signatureMatches = """installed.${ROW_SIGNATURE} IS NOT NULL AND
product.${ROW_SIGNATURES} LIKE ('%.' || installed.${ROW_SIGNATURE} || '.%') AND
product.${ROW_SIGNATURES} != ''"""
builder += """SELECT product.rowid AS _id, product.${ROW_REPOSITORY_ID},
product.${ROW_PACKAGE_NAME}, product.${ROW_NAME},
product.${ROW_SUMMARY}, installed.${ROW_VERSION},
(COALESCE(lock.${ROW_VERSION_CODE}, -1) NOT IN (0, product.${ROW_VERSION_CODE}) AND
product.${ROW_COMPATIBLE} != 0 AND product.${ROW_VERSION_CODE} >
COALESCE(installed.${ROW_VERSION_CODE}, 0xffffffff) AND $signatureMatches)
AS ${ROW_CAN_UPDATE}, product.${ROW_COMPATIBLE},"""
if (searchQuery.isNotEmpty()) {
builder += """(((product.${ROW_NAME} LIKE ? OR
product.${ROW_SUMMARY} LIKE ?) * 7) |
((product.${ROW_PACKAGE_NAME} LIKE ?) * 3) |
(product.${ROW_DESCRIPTION} LIKE ?)) AS ${ROW_MATCH_RANK},"""
builder %= List(4) { "%$searchQuery%" }
} else {
builder += "0 AS ${ROW_MATCH_RANK},"
}
builder += """MAX((product.${ROW_COMPATIBLE} AND
(installed.${ROW_SIGNATURE} IS NULL OR $signatureMatches)) ||
PRINTF('%016X', product.${ROW_VERSION_CODE})) FROM $ROW_PRODUCT_NAME AS product"""
builder += """JOIN $ROW_REPOSITORY_NAME AS repository
ON product.${ROW_REPOSITORY_ID} = repository.${ROW_ID}"""
builder += """LEFT JOIN $ROW_LOCK_NAME AS lock
ON product.${ROW_PACKAGE_NAME} = lock.${ROW_PACKAGE_NAME}"""
if (!installed && !updates) {
builder += "LEFT"
}
builder += """JOIN $ROW_INSTALLED_NAME AS installed
ON product.${ROW_PACKAGE_NAME} = installed.${ROW_PACKAGE_NAME}"""
if (section is Section.Category) {
builder += """JOIN $ROW_CATEGORY_NAME AS category
ON product.${ROW_PACKAGE_NAME} = category.${ROW_PACKAGE_NAME}"""
}
builder += """WHERE repository.${ROW_ENABLED} != 0"""
if (section is Section.Category) {
builder += "AND category.${ROW_NAME} = ?"
builder %= section.name
} else if (section is Section.Repository) {
builder += "AND product.${ROW_REPOSITORY_ID} = ?"
builder %= section.id.toString()
}
if (searchQuery.isNotEmpty()) {
builder += """AND $ROW_MATCH_RANK > 0"""
}
builder += "GROUP BY product.${ROW_PACKAGE_NAME} HAVING 1"
if (updates) {
builder += "AND $ROW_CAN_UPDATE"
}
builder += "ORDER BY"
if (searchQuery.isNotEmpty()) {
builder += """$ROW_MATCH_RANK DESC,"""
}
when (order) {
Order.NAME -> Unit
Order.DATE_ADDED -> builder += "product.${ROW_ADDED} DESC,"
Order.LAST_UPDATE -> builder += "product.${ROW_UPDATED} DESC,"
}::class
builder += "product.${ROW_NAME} COLLATE LOCALIZED ASC"
return query(SimpleSQLiteQuery(builder.build()))
}
@RawQuery
fun queryObject(query: SupportSQLiteQuery): List<Product>

View File

@ -1,9 +1,5 @@
package com.looker.droidify.entity
import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.core.JsonParser
import com.looker.droidify.utility.extension.json.forEachKey
data class ProductItem(
var repositoryId: Long,
var packageName: String,
@ -16,55 +12,4 @@ data class ProductItem(
var compatible: Boolean,
var canUpdate: Boolean,
var matchRank: Int,
) {
fun serialize(generator: JsonGenerator) {
generator.writeNumberField("serialVersion", 1)
generator.writeNumberField("repositoryId", repositoryId)
generator.writeStringField("packageName", packageName)
generator.writeStringField("name", name)
generator.writeStringField("summary", summary)
generator.writeStringField("icon", icon)
generator.writeStringField("metadataIcon", metadataIcon)
generator.writeStringField("version", version)
generator.writeStringField("installedVersion", installedVersion)
generator.writeBooleanField("compatible", compatible)
generator.writeBooleanField("canUpdate", canUpdate)
generator.writeNumberField("matchRank", matchRank)
}
companion object {
fun deserialize(parser: JsonParser): ProductItem {
var repositoryId = 0L
var packageName = ""
var name = ""
var summary = ""
var icon = ""
var metadataIcon = ""
var version = ""
var installedVersion = ""
var compatible = false
var canUpdate = false
var matchRank = 0
parser.forEachKey {
when {
it.number("repositoryId") -> repositoryId = valueAsLong
it.string("packageName") -> packageName = valueAsString
it.string("name") -> name = valueAsString
it.string("summary") -> summary = valueAsString
it.string("icon") -> icon = valueAsString
it.string("metadataIcon") -> metadataIcon = valueAsString
it.string("version") -> version = valueAsString
it.string("installedVersion") -> installedVersion = valueAsString
it.boolean("compatible") -> compatible = valueAsBoolean
it.boolean("canUpdate") -> canUpdate = valueAsBoolean
it.number("matchRank") -> matchRank = valueAsInt
else -> skipChildren()
}
}
return ProductItem(
repositoryId, packageName, name, summary, icon, metadataIcon,
version, installedVersion, compatible, canUpdate, matchRank
)
}
}
}
)

View File

@ -1,10 +0,0 @@
package com.looker.droidify.screen
import androidx.fragment.app.Fragment
open class BaseFragment : Fragment() {
val screenActivity: ScreenActivity
get() = requireActivity() as ScreenActivity
open fun onBackPressed(): Boolean = false
}

View File

@ -1,264 +0,0 @@
package com.looker.droidify.screen
import android.content.Intent
import android.content.pm.PackageInstaller
import android.os.Bundle
import android.os.Parcel
import android.view.ViewGroup
import android.view.inputmethod.InputMethodManager
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import com.google.android.material.circularreveal.CircularRevealFrameLayout
import com.looker.droidify.MainApplication
import com.looker.droidify.R
import com.looker.droidify.content.Preferences
import com.looker.droidify.database.CursorOwner
import com.looker.droidify.installer.InstallerService
import com.looker.droidify.ui.fragments.AppDetailFragment
import com.looker.droidify.utility.KParcelable
import com.looker.droidify.utility.extension.resources.getDrawableFromAttr
import com.looker.droidify.utility.extension.text.nullIfEmpty
import kotlinx.coroutines.launch
abstract class ScreenActivity : AppCompatActivity() {
companion object {
private const val STATE_FRAGMENT_STACK = "fragmentStack"
}
val db
get() = (application as MainApplication).db
sealed class SpecialIntent {
object Updates : SpecialIntent()
class Install(val packageName: String?, val status: Int?, val promptIntent: Intent?) :
SpecialIntent()
}
private class FragmentStackItem(
val className: String, val arguments: Bundle?,
val savedState: Fragment.SavedState?,
) : KParcelable {
override fun writeToParcel(dest: Parcel, flags: Int) {
dest.writeString(className)
dest.writeByte(if (arguments != null) 1 else 0)
arguments?.writeToParcel(dest, flags)
dest.writeByte(if (savedState != null) 1 else 0)
savedState?.writeToParcel(dest, flags)
}
companion object {
@Suppress("unused")
@JvmField
val CREATOR = KParcelable.creator {
val className = it.readString()!!
val arguments =
if (it.readByte().toInt() == 0) null else Bundle.CREATOR.createFromParcel(it)
arguments?.classLoader = ScreenActivity::class.java.classLoader
val savedState = if (it.readByte()
.toInt() == 0
) null else Fragment.SavedState.CREATOR.createFromParcel(it)
FragmentStackItem(className, arguments, savedState)
}
}
}
lateinit var cursorOwner: CursorOwner
private set
private val fragmentStack = mutableListOf<FragmentStackItem>()
private val currentFragment: Fragment?
get() {
supportFragmentManager.executePendingTransactions()
return supportFragmentManager.findFragmentById(R.id.main_content)
}
override fun onCreate(savedInstanceState: Bundle?) {
setTheme(Preferences[Preferences.Key.Theme].getResId(resources.configuration))
super.onCreate(savedInstanceState)
addContentView(
CircularRevealFrameLayout(this).apply { id = R.id.main_content },
ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
)
if (savedInstanceState == null) {
cursorOwner = CursorOwner()
supportFragmentManager.beginTransaction()
.add(cursorOwner, CursorOwner::class.java.name)
.commit()
} else {
cursorOwner = supportFragmentManager
.findFragmentByTag(CursorOwner::class.java.name) as CursorOwner
}
savedInstanceState?.getParcelableArrayList<FragmentStackItem>(STATE_FRAGMENT_STACK)
?.let { fragmentStack += it }
if (savedInstanceState == null) {
replaceFragment(TabsFragment(), null)
if ((intent.flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY) == 0) {
handleIntent(intent)
}
}
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putParcelableArrayList(STATE_FRAGMENT_STACK, ArrayList(fragmentStack))
}
override fun onBackPressed() {
val currentFragment = currentFragment
if (!(currentFragment is ScreenFragment && currentFragment.onBackPressed())) {
hideKeyboard()
if (!popFragment()) {
super.onBackPressed()
}
}
}
private fun replaceFragment(fragment: Fragment, open: Boolean?) {
if (open != null) {
currentFragment?.view?.translationZ =
(if (open) Int.MIN_VALUE else Int.MAX_VALUE).toFloat()
}
supportFragmentManager
.beginTransaction()
.apply {
if (open != null) {
setCustomAnimations(
if (open) R.animator.slide_in else 0,
if (open) R.animator.slide_in_keep else R.animator.slide_out
)
}
}
.replace(R.id.main_content, fragment)
.commit()
}
private fun pushFragment(fragment: Fragment) {
currentFragment?.let {
fragmentStack.add(
FragmentStackItem(
it::class.java.name, it.arguments,
supportFragmentManager.saveFragmentInstanceState(it)
)
)
}
replaceFragment(fragment, true)
}
private fun popFragment(): Boolean {
return fragmentStack.isNotEmpty() && run {
val stackItem = fragmentStack.removeAt(fragmentStack.size - 1)
val fragment = Class.forName(stackItem.className).newInstance() as Fragment
stackItem.arguments?.let(fragment::setArguments)
stackItem.savedState?.let(fragment::setInitialSavedState)
replaceFragment(fragment, false)
true
}
}
private fun hideKeyboard() {
(getSystemService(INPUT_METHOD_SERVICE) as? InputMethodManager)
?.hideSoftInputFromWindow((currentFocus ?: window.decorView).windowToken, 0)
}
override fun onAttachFragment(fragment: Fragment) {
super.onAttachFragment(fragment)
hideKeyboard()
}
internal fun onToolbarCreated(toolbar: Toolbar) {
if (fragmentStack.isNotEmpty()) {
toolbar.navigationIcon =
toolbar.context.getDrawableFromAttr(android.R.attr.homeAsUpIndicator)
toolbar.setNavigationOnClickListener { onBackPressed() }
}
}
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
handleIntent(intent)
}
protected val Intent.packageName: String?
get() {
val uri = data
return when {
uri?.scheme == "package" || uri?.scheme == "fdroid.app" -> {
uri.schemeSpecificPart?.nullIfEmpty()
}
uri?.scheme == "market" && uri.host == "details" -> {
uri.getQueryParameter("id")?.nullIfEmpty()
}
uri != null && uri.scheme in setOf("http", "https") -> {
val host = uri.host.orEmpty()
if (host == "f-droid.org" || host.endsWith(".f-droid.org")) {
uri.lastPathSegment?.nullIfEmpty()
} else {
null
}
}
else -> {
null
}
}
}
protected fun handleSpecialIntent(specialIntent: SpecialIntent) {
when (specialIntent) {
is SpecialIntent.Updates -> {
if (currentFragment !is TabsFragment) {
fragmentStack.clear()
replaceFragment(TabsFragment(), true)
}
val tabsFragment = currentFragment as TabsFragment
tabsFragment.selectUpdates()
}
is SpecialIntent.Install -> {
val packageName = specialIntent.packageName
val status = specialIntent.status
val promptIntent = specialIntent.promptIntent
if (!packageName.isNullOrEmpty() && status != null && promptIntent != null) {
lifecycleScope.launch {
startService(
Intent(baseContext, InstallerService::class.java)
.putExtra(PackageInstaller.EXTRA_STATUS, status)
.putExtra(
PackageInstaller.EXTRA_PACKAGE_NAME,
packageName
)
.putExtra(Intent.EXTRA_INTENT, promptIntent)
)
}
} else {
throw IllegalArgumentException("Missing parameters needed to relaunch InstallerService and trigger prompt.")
}
Unit
}
}::class
}
open fun handleIntent(intent: Intent?) {
when (intent?.action) {
Intent.ACTION_VIEW -> {
val packageName = intent.packageName
if (!packageName.isNullOrEmpty()) {
val fragment = currentFragment
if (fragment !is AppDetailFragment || fragment.packageName != packageName) {
navigateProduct(packageName)
}
}
}
}
}
internal fun navigateProduct(packageName: String) = pushFragment(AppDetailFragment(packageName))
internal fun navigatePreferences() = pushFragment(SettingsFragment())
}

View File

@ -4,11 +4,12 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import com.google.android.material.appbar.CollapsingToolbarLayout
import com.google.android.material.appbar.MaterialToolbar
import com.looker.droidify.databinding.FragmentBinding
open class ScreenFragment : BaseFragment() {
open class ScreenFragment : Fragment() {
private var _fragmentBinding: FragmentBinding? = null
val fragmentBinding get() = _fragmentBinding!!

View File

@ -1,433 +0,0 @@
package com.looker.droidify.screen
import android.app.Dialog
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.text.InputFilter
import android.text.InputType
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.widget.LinearLayoutCompat
import androidx.core.net.toUri
import androidx.core.widget.NestedScrollView
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.flowWithLifecycle
import androidx.lifecycle.lifecycleScope
import com.google.android.material.circularreveal.CircularRevealFrameLayout
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.switchmaterial.SwitchMaterial
import com.google.android.material.textfield.TextInputEditText
import com.google.android.material.textview.MaterialTextView
import com.looker.droidify.BuildConfig
import com.looker.droidify.R
import com.looker.droidify.content.Preferences
import com.looker.droidify.databinding.PreferenceItemBinding
import com.looker.droidify.utility.Utils.getLocaleOfCode
import com.looker.droidify.utility.Utils.languagesList
import com.looker.droidify.utility.Utils.translateLocale
import com.looker.droidify.utility.extension.resources.*
import com.topjohnwu.superuser.Shell
import kotlinx.coroutines.launch
class SettingsFragment : ScreenFragment() {
private var preferenceBinding: PreferenceItemBinding? = null
private val preferences = mutableMapOf<Preferences.Key<*>, Preference<*>>()
override fun onResume() {
super.onResume()
preferences.forEach { (_, preference) -> preference.update() }
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
preferenceBinding = PreferenceItemBinding.inflate(layoutInflater)
screenActivity.onToolbarCreated(toolbar)
collapsingToolbar.title = getString(R.string.settings)
val content = fragmentBinding.fragmentContent
val scroll = NestedScrollView(content.context)
scroll.id = R.id.preferences_list
scroll.isFillViewport = true
content.addView(
scroll,
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
val scrollLayout = CircularRevealFrameLayout(content.context)
scroll.addView(
scrollLayout,
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
val preferences = LinearLayoutCompat(scrollLayout.context)
preferences.orientation = LinearLayoutCompat.VERTICAL
scrollLayout.addView(
preferences,
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
preferences.addCategory(requireContext().getString(R.string.prefs_personalization)) {
addList(
Preferences.Key.Language,
context.getString(R.string.prefs_language_title),
languagesList
) { translateLocale(context.getLocaleOfCode(it)) }
addEnumeration(Preferences.Key.Theme, getString(R.string.theme)) {
when (it) {
is Preferences.Theme.System -> getString(R.string.system)
is Preferences.Theme.AmoledSystem -> getString(R.string.system) + " " + getString(
R.string.amoled
)
is Preferences.Theme.Light -> getString(R.string.light)
is Preferences.Theme.Dark -> getString(R.string.dark)
is Preferences.Theme.Amoled -> getString(R.string.amoled)
}
}
addSwitch(
Preferences.Key.ListAnimation, getString(R.string.list_animation),
getString(R.string.list_animation_description)
)
}
preferences.addCategory(getString(R.string.updates)) {
addEnumeration(
Preferences.Key.AutoSync,
getString(R.string.sync_repositories_automatically)
) {
when (it) {
Preferences.AutoSync.Never -> getString(R.string.never)
Preferences.AutoSync.Wifi -> getString(R.string.only_on_wifi)
Preferences.AutoSync.WifiBattery -> getString(R.string.only_on_wifi_and_battery)
Preferences.AutoSync.Always -> getString(R.string.always)
}
}
addSwitch(
Preferences.Key.InstallAfterSync, getString(R.string.install_after_sync),
getString(R.string.install_after_sync_summary)
)
addSwitch(
Preferences.Key.UpdateNotify, getString(R.string.notify_about_updates),
getString(R.string.notify_about_updates_summary)
)
addSwitch(
Preferences.Key.UpdateUnstable, getString(R.string.unstable_updates),
getString(R.string.unstable_updates_summary)
)
addSwitch(
Preferences.Key.IncompatibleVersions, getString(R.string.incompatible_versions),
getString(R.string.incompatible_versions_summary)
)
}
preferences.addCategory(getString(R.string.proxy)) {
addEnumeration(Preferences.Key.ProxyType, getString(R.string.proxy_type)) {
when (it) {
is Preferences.ProxyType.Direct -> getString(R.string.no_proxy)
is Preferences.ProxyType.Http -> getString(R.string.http_proxy)
is Preferences.ProxyType.Socks -> getString(R.string.socks_proxy)
}
}
addEditString(Preferences.Key.ProxyHost, getString(R.string.proxy_host))
addEditInt(Preferences.Key.ProxyPort, getString(R.string.proxy_port), 1..65535)
}
preferences.addCategory(getString(R.string.install_types)) {
addSwitch(
Preferences.Key.RootPermission, getString(R.string.root_permission),
getString(R.string.root_permission_description)
)
}
preferences.addCategory(getString(R.string.credits)) {
addText(
title = "Based on Foxy-Droid",
summary = "FoxyDroid",
url = "https://github.com/kitsunyan/foxy-droid/"
)
addText(
title = getString(R.string.application_name),
summary = "v ${BuildConfig.VERSION_NAME}",
url = "https://github.com/iamlooker/Droid-ify/"
)
}
lifecycleScope.launch {
Preferences.subject
.flowWithLifecycle(lifecycle, Lifecycle.State.STARTED)
.collect { updatePreference(it) }
}
updatePreference(null)
}
private fun LinearLayoutCompat.addText(title: String, summary: String, url: String) {
val text = MaterialTextView(context)
val subText = MaterialTextView(context)
text.text = title
subText.text = summary
text.setTextSizeScaled(16)
subText.setTextSizeScaled(14)
resources.sizeScaled(16).let {
text.setPadding(it, it, 5, 5)
subText.setPadding(it, 5, 5, 25)
}
addView(
text,
LinearLayoutCompat.LayoutParams.MATCH_PARENT,
LinearLayoutCompat.LayoutParams.WRAP_CONTENT
)
addView(
subText,
LinearLayoutCompat.LayoutParams.MATCH_PARENT,
LinearLayoutCompat.LayoutParams.WRAP_CONTENT
)
text.setOnClickListener { openURI(url.toUri()) }
subText.setOnClickListener { openURI(url.toUri()) }
}
private fun openURI(url: Uri) {
startActivity(Intent(Intent.ACTION_VIEW, url))
}
override fun onDestroyView() {
super.onDestroyView()
preferences.clear()
preferenceBinding = null
}
private fun updatePreference(key: Preferences.Key<*>?) {
if (key != null) {
preferences[key]?.update()
}
if (key == null || key == Preferences.Key.ProxyType) {
val enabled = when (Preferences[Preferences.Key.ProxyType]) {
is Preferences.ProxyType.Direct -> false
is Preferences.ProxyType.Http, is Preferences.ProxyType.Socks -> true
}
preferences[Preferences.Key.ProxyHost]?.setEnabled(enabled)
preferences[Preferences.Key.ProxyPort]?.setEnabled(enabled)
}
if (key == Preferences.Key.RootPermission) {
preferences[Preferences.Key.RootPermission]?.setEnabled(
Shell.getCachedShell()?.isRoot
?: Shell.getShell().isRoot
)
}
if (key == Preferences.Key.Theme) {
requireActivity().recreate()
}
}
private inline fun LinearLayoutCompat.addCategory(
title: String,
callback: LinearLayoutCompat.() -> Unit,
) {
val text = MaterialTextView(context)
text.typeface = TypefaceExtra.medium
text.setTextSizeScaled(14)
text.setTextColor(text.context.getColorFromAttr(R.attr.colorPrimary))
text.text = title
resources.sizeScaled(16).let { text.setPadding(it, it, it, 0) }
addView(
text,
LinearLayoutCompat.LayoutParams.MATCH_PARENT,
LinearLayoutCompat.LayoutParams.WRAP_CONTENT
)
callback()
}
private fun <T> LinearLayoutCompat.addPreference(
key: Preferences.Key<T>, title: String,
summaryProvider: () -> String, dialogProvider: ((Context) -> AlertDialog)?,
): Preference<T> {
val preference =
Preference(key, this@SettingsFragment, this, title, summaryProvider, dialogProvider)
preferences[key] = preference
return preference
}
private fun LinearLayoutCompat.addSwitch(
key: Preferences.Key<Boolean>,
title: String,
summary: String,
) {
val preference = addPreference(key, title, { summary }, null)
preference.check.visibility = View.VISIBLE
preference.view.setOnClickListener { Preferences[key] = !Preferences[key] }
preference.setCallback { preference.check.isChecked = Preferences[key] }
}
private fun <T> LinearLayoutCompat.addEdit(
key: Preferences.Key<T>, title: String, valueToString: (T) -> String,
stringToValue: (String) -> T?, configureEdit: (TextInputEditText) -> Unit,
) {
addPreference(key, title, { valueToString(Preferences[key]) }) { it ->
val scroll = NestedScrollView(it)
scroll.resources.sizeScaled(20).let { scroll.setPadding(it, 0, it, 0) }
val edit = TextInputEditText(it)
configureEdit(edit)
edit.id = android.R.id.edit
edit.resources.sizeScaled(16)
.let { edit.setPadding(edit.paddingLeft, it, edit.paddingRight, it) }
edit.setText(valueToString(Preferences[key]))
edit.hint = edit.text.toString()
edit.text?.let { editable -> edit.setSelection(editable.length) }
edit.requestFocus()
scroll.addView(
edit,
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
MaterialAlertDialogBuilder(it)
.setTitle(title)
.setView(scroll)
.setPositiveButton(R.string.ok) { _, _ ->
val value = stringToValue(edit.text.toString()) ?: key.default.value
post { Preferences[key] = value }
}
.setNegativeButton(R.string.cancel, null)
.create()
.apply {
window!!.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE)
}
}
}
private fun LinearLayoutCompat.addEditString(key: Preferences.Key<String>, title: String) {
addEdit(key, title, { it }, { it }, { })
}
private fun LinearLayoutCompat.addEditInt(
key: Preferences.Key<Int>,
title: String,
range: IntRange?,
) {
addEdit(key, title, { it.toString() }, { it.toIntOrNull() }) {
it.inputType = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_DECIMAL
if (range != null) it.filters =
arrayOf(InputFilter { source, start, end, dest, dstart, dend ->
val value = (dest.substring(0, dstart) + source.substring(start, end) +
dest.substring(dend, dest.length)).toIntOrNull()
if (value != null && value in range) null else ""
})
}
}
private fun <T : Preferences.Enumeration<T>> LinearLayoutCompat.addEnumeration(
key: Preferences.Key<T>,
title: String,
valueToString: (T) -> String,
) {
addPreference(key, title, { valueToString(Preferences[key]) }) {
val values = key.default.value.values
MaterialAlertDialogBuilder(it)
.setTitle(title)
.setSingleChoiceItems(
values.map(valueToString).toTypedArray(),
values.indexOf(Preferences[key])
) { dialog, which ->
dialog.dismiss()
post { Preferences[key] = values[which] }
}
.setNegativeButton(R.string.cancel, null)
.create()
}
}
private fun <T> LinearLayoutCompat.addList(
key: Preferences.Key<T>,
title: String,
values: List<T>,
valueToString: (T) -> String,
) {
addPreference(key, title, { valueToString(Preferences[key]) }) {
MaterialAlertDialogBuilder(it)
.setTitle(title)
.setSingleChoiceItems(
values.map(valueToString).toTypedArray(),
values.indexOf(Preferences[key])
) { dialog, which ->
dialog.dismiss()
post { Preferences[key] = values[which] }
}
.setNegativeButton(R.string.cancel, null)
.create()
}
}
private class Preference<T>(
private val key: Preferences.Key<T>,
fragment: Fragment,
parent: ViewGroup,
titleText: String,
private val summaryProvider: () -> String,
private val dialogProvider: ((Context) -> AlertDialog)?,
) {
val view = parent.inflate(R.layout.preference_item)
val title = view.findViewById<MaterialTextView>(R.id.title)!!
val summary = view.findViewById<MaterialTextView>(R.id.summary)!!
val check = view.findViewById<SwitchMaterial>(R.id.check)!!
private var callback: (() -> Unit)? = null
init {
title.text = titleText
parent.addView(view)
if (dialogProvider != null) {
view.setOnClickListener {
PreferenceDialog(key.name)
.show(
fragment.childFragmentManager,
"${PreferenceDialog::class.java.name}.${key.name}"
)
}
}
update()
}
fun setCallback(callback: () -> Unit) {
this.callback = callback
update()
}
fun setEnabled(enabled: Boolean) {
view.isEnabled = enabled
title.isEnabled = enabled
summary.isEnabled = enabled
check.isEnabled = enabled
}
fun update() {
summary.text = summaryProvider()
summary.visibility = if (summary.text.isNotEmpty()) View.VISIBLE else View.GONE
callback?.invoke()
}
fun createDialog(context: Context): AlertDialog {
return dialogProvider!!(context)
}
}
class PreferenceDialog() : DialogFragment() {
companion object {
private const val EXTRA_KEY = "key"
}
constructor(key: String) : this() {
arguments = Bundle().apply {
putString(EXTRA_KEY, key)
}
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val preferences = (parentFragment as SettingsFragment).preferences
val key = requireArguments().getString(EXTRA_KEY)!!
.let { name -> preferences.keys.find { it.name == name }!! }
val preference = preferences[key]!!
return preference.createDialog(requireContext())
}
}
}

View File

@ -1,598 +0,0 @@
package com.looker.droidify.screen
import android.animation.ValueAnimator
import android.content.Context
import android.os.Bundle
import android.view.Gravity
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.view.animation.AccelerateInterpolator
import android.view.animation.DecelerateInterpolator
import androidx.appcompat.widget.SearchView
import androidx.fragment.app.Fragment
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.flowWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.adapter.FragmentStateAdapter
import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.tabs.TabLayoutMediator
import com.google.android.material.textview.MaterialTextView
import com.looker.droidify.R
import com.looker.droidify.content.Preferences
import com.looker.droidify.databinding.TabsToolbarBinding
import com.looker.droidify.entity.Section
import com.looker.droidify.service.Connection
import com.looker.droidify.service.SyncService
import com.looker.droidify.ui.fragments.AppListFragment
import com.looker.droidify.utility.RxUtils
import com.looker.droidify.utility.Utils
import com.looker.droidify.utility.extension.android.Android
import com.looker.droidify.utility.extension.resources.getDrawableCompat
import com.looker.droidify.utility.extension.resources.getDrawableFromAttr
import com.looker.droidify.utility.extension.resources.sizeScaled
import com.looker.droidify.widget.DividerItemDecoration
import com.looker.droidify.widget.FocusSearchView
import com.looker.droidify.widget.StableRecyclerAdapter
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.schedulers.Schedulers
import kotlinx.coroutines.launch
import kotlin.math.abs
import kotlin.math.roundToInt
class TabsFragment : ScreenFragment() {
private var _tabsBinding: TabsToolbarBinding? = null
private val tabsBinding get() = _tabsBinding!!
companion object {
private const val STATE_SEARCH_FOCUSED = "searchFocused"
private const val STATE_SEARCH_QUERY = "searchQuery"
private const val STATE_SHOW_SECTIONS = "showSections"
private const val STATE_SECTIONS = "sections"
private const val STATE_SECTION = "section"
}
private class Layout(view: TabsToolbarBinding) {
val tabs = view.tabs
val sectionLayout = view.sectionLayout
val sectionChange = view.sectionChange
val sectionName = view.sectionName
val sectionIcon = view.sectionIcon
}
private var searchMenuItem: MenuItem? = null
private var sortOrderMenu: Pair<MenuItem, List<MenuItem>>? = null
private var syncRepositoriesMenuItem: MenuItem? = null
private var layout: Layout? = null
private var sectionsList: RecyclerView? = null
private var viewPager: ViewPager2? = null
private var showSections = false
set(value) {
if (field != value) {
field = value
val layout = layout
layout?.tabs?.let {
(0 until it.childCount)
.forEach { index -> it.getChildAt(index)!!.isEnabled = !value }
}
layout?.sectionIcon?.scaleY = if (value) -1f else 1f
if ((sectionsList?.parent as? View)?.height ?: 0 > 0) {
animateSectionsList()
}
}
}
private var searchQuery = ""
private var sections = listOf<Section>(Section.All)
private var section: Section = Section.All
private val syncConnection = Connection(SyncService::class.java, onBind = { _, _ ->
viewPager?.let {
val source = AppListFragment.Source.values()[it.currentItem]
updateUpdateNotificationBlocker(source)
}
})
private var categoriesDisposable: Disposable? = null
private var repositoriesDisposable: Disposable? = null
private var sectionsAnimator: ValueAnimator? = null
private var needSelectUpdates = false
private val productFragments: Sequence<AppListFragment>
get() = if (host == null) emptySequence() else
childFragmentManager.fragments.asSequence().mapNotNull { it as? AppListFragment }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
_tabsBinding = TabsToolbarBinding.inflate(layoutInflater)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
syncConnection.bind(requireContext())
screenActivity.onToolbarCreated(toolbar)
collapsingToolbar.title = getString(R.string.application_name)
// Move focus from SearchView to Toolbar
toolbar.isFocusableInTouchMode = true
val searchView = FocusSearchView(toolbar.context).apply {
maxWidth = Int.MAX_VALUE
queryHint = getString(R.string.search)
setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String?): Boolean {
clearFocus()
return true
}
override fun onQueryTextChange(newText: String?): Boolean {
if (isResumed) {
searchQuery = newText.orEmpty()
productFragments.forEach { it.setSearchQuery(newText.orEmpty()) }
}
return true
}
})
setOnSearchClickListener { fragmentBinding.appbarLayout.setExpanded(false, true) }
}
toolbar.menu.apply {
if (Android.sdk(28) && !Android.Device.isHuaweiEmui) {
setGroupDividerEnabled(true)
}
searchMenuItem = add(0, R.id.toolbar_search, 0, R.string.search)
.setIcon(Utils.getToolbarIcon(toolbar.context, R.drawable.ic_search))
.setActionView(searchView)
.setShowAsActionFlags(MenuItem.SHOW_AS_ACTION_ALWAYS or MenuItem.SHOW_AS_ACTION_COLLAPSE_ACTION_VIEW)
sortOrderMenu = addSubMenu(0, 0, 0, R.string.sorting_order)
.setIcon(Utils.getToolbarIcon(toolbar.context, R.drawable.ic_sort))
.let { menu ->
menu.item.setShowAsActionFlags(MenuItem.SHOW_AS_ACTION_ALWAYS)
val items = Preferences.Key.SortOrder.default.value.values
.map { sortOrder ->
menu
.add(sortOrder.order.titleResId)
.setOnMenuItemClickListener {
Preferences[Preferences.Key.SortOrder] = sortOrder
true
}
}
menu.setGroupCheckable(0, true, true)
Pair(menu.item, items)
}
syncRepositoriesMenuItem = add(0, 0, 0, R.string.sync_repositories)
.setIcon(Utils.getToolbarIcon(toolbar.context, R.drawable.ic_sync))
.setOnMenuItemClickListener {
syncConnection.binder?.sync(SyncService.SyncRequest.MANUAL)
true
}
add(1, 0, 0, R.string.settings)
.setOnMenuItemClickListener {
view.post { screenActivity.navigatePreferences() }
true
}
}
searchQuery = savedInstanceState?.getString(STATE_SEARCH_QUERY).orEmpty()
productFragments.forEach { it.setSearchQuery(searchQuery) }
val toolbarExtra = fragmentBinding.toolbarExtra
toolbarExtra.addView(tabsBinding.root)
val layout = Layout(tabsBinding)
this.layout = layout
showSections = savedInstanceState?.getByte(STATE_SHOW_SECTIONS)?.toInt() ?: 0 != 0
sections = savedInstanceState?.getParcelableArrayList<Section>(STATE_SECTIONS)
.orEmpty()
section = savedInstanceState?.getParcelable(STATE_SECTION) ?: Section.All
layout.sectionChange.setOnClickListener {
showSections = sections
.any { it !is Section.All } && !showSections
}
updateOrder()
lifecycleScope.launch {
Preferences.subject
.flowWithLifecycle(lifecycle, Lifecycle.State.STARTED)
.collect {
if (it == Preferences.Key.SortOrder) updateOrder()
}
}
val content = fragmentBinding.fragmentContent
viewPager = ViewPager2(content.context).apply {
id = R.id.fragment_pager
adapter = object : FragmentStateAdapter(this@TabsFragment) {
override fun getItemCount(): Int = AppListFragment.Source.values().size
override fun createFragment(position: Int): Fragment = AppListFragment(
AppListFragment
.Source.values()[position]
)
}
content.addView(this)
registerOnPageChangeCallback(pageChangeCallback)
offscreenPageLimit = 1
}
viewPager?.let {
TabLayoutMediator(layout.tabs, it) { tab, position ->
tab.text = getString(AppListFragment.Source.values()[position].titleResId)
}.attach()
}
categoriesDisposable = Observable.just(Unit)
//.concatWith(Database.observable(Database.Subject.Products)) // TODO have to be replaced like whole rxJava
.observeOn(Schedulers.io())
.flatMapSingle { RxUtils.querySingle { screenActivity.db.categoryDao.allNames } }
.observeOn(AndroidSchedulers.mainThread())
.subscribe {
setSectionsAndUpdate(
it.asSequence().sorted()
.map(Section::Category).toList(), null
)
}
repositoriesDisposable = Observable.just(Unit)
//.concatWith(Database.observable(Database.Subject.Repositories)) // TODO have to be replaced like whole rxJava
.observeOn(Schedulers.io())
.flatMapSingle { RxUtils.querySingle { screenActivity.db.repositoryDao.all } }
.observeOn(AndroidSchedulers.mainThread())
.subscribe { it ->
setSectionsAndUpdate(null, it.asSequence().filter { it.enabled }
.map { Section.Repository(it.id, it.name) }.toList())
}
updateSection()
val sectionsList = RecyclerView(toolbar.context).apply {
id = R.id.sections_list
layoutManager = LinearLayoutManager(context)
isMotionEventSplittingEnabled = false
isVerticalScrollBarEnabled = false
setHasFixedSize(true)
val adapter = SectionsAdapter({ sections }) {
if (showSections) {
showSections = false
section = it
updateSection()
}
}
this.adapter = adapter
addItemDecoration(DividerItemDecoration(context, adapter::configureDivider))
background = context.getDrawableCompat(R.drawable.background_border)
elevation = resources.sizeScaled(4).toFloat()
content.addView(this)
val margins = resources.sizeScaled(8)
(layoutParams as ViewGroup.MarginLayoutParams).setMargins(margins, margins, margins, 0)
visibility = View.GONE
}
this.sectionsList = sectionsList
var lastContentHeight = -1
content.viewTreeObserver.addOnGlobalLayoutListener {
if (this.view != null) {
val initial = lastContentHeight <= 0
val contentHeight = content.height
if (lastContentHeight != contentHeight) {
lastContentHeight = contentHeight
if (initial) {
sectionsList.layoutParams.height = if (showSections) contentHeight else 0
sectionsList.visibility = if (showSections) View.VISIBLE else View.GONE
sectionsList.requestLayout()
} else {
animateSectionsList()
}
}
}
}
}
override fun onDestroyView() {
super.onDestroyView()
searchMenuItem = null
sortOrderMenu = null
syncRepositoriesMenuItem = null
layout = null
sectionsList = null
viewPager = null
syncConnection.unbind(requireContext())
categoriesDisposable?.dispose()
categoriesDisposable = null
repositoriesDisposable?.dispose()
repositoriesDisposable = null
sectionsAnimator?.cancel()
sectionsAnimator = null
_tabsBinding = null
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putBoolean(STATE_SEARCH_FOCUSED, searchMenuItem?.actionView?.hasFocus() == true)
outState.putString(STATE_SEARCH_QUERY, searchQuery)
outState.putByte(STATE_SHOW_SECTIONS, if (showSections) 1 else 0)
outState.putParcelableArrayList(STATE_SECTIONS, ArrayList(sections))
outState.putParcelable(STATE_SECTION, section)
}
override fun onViewStateRestored(savedInstanceState: Bundle?) {
super.onViewStateRestored(savedInstanceState)
(searchMenuItem?.actionView as FocusSearchView).allowFocus = true
if (needSelectUpdates) {
needSelectUpdates = false
selectUpdatesInternal(false)
}
}
override fun onBackPressed(): Boolean {
return when {
searchMenuItem?.isActionViewExpanded == true -> {
searchMenuItem?.collapseActionView()
true
}
showSections -> {
showSections = false
true
}
else -> {
super.onBackPressed()
}
}
}
internal fun selectUpdates() = selectUpdatesInternal(true)
private fun selectUpdatesInternal(allowSmooth: Boolean) {
if (view != null) {
val viewPager = viewPager
viewPager?.setCurrentItem(
AppListFragment.Source.UPDATES.ordinal,
allowSmooth && viewPager.isLaidOut
)
} else {
needSelectUpdates = true
}
}
private fun updateUpdateNotificationBlocker(activeSource: AppListFragment.Source) {
val blockerFragment = if (activeSource == AppListFragment.Source.UPDATES) {
productFragments.find { it.source == activeSource }
} else {
null
}
syncConnection.binder?.setUpdateNotificationBlocker(blockerFragment)
}
private fun updateOrder() {
val order = Preferences[Preferences.Key.SortOrder].order
sortOrderMenu!!.second[order.ordinal].isChecked = true
productFragments.forEach { it.setOrder(order) }
}
private inline fun <reified T : Section> collectOldSections(list: List<T>?): List<T>? {
val oldList = sections.mapNotNull { it as? T }
return if (list == null || oldList == list) oldList else null
}
private fun setSectionsAndUpdate(
categories: List<Section.Category>?,
repositories: List<Section.Repository>?,
) {
val oldCategories = collectOldSections(categories)
val oldRepositories = collectOldSections(repositories)
if (oldCategories == null || oldRepositories == null) {
sections = listOf(Section.All) +
(categories ?: oldCategories).orEmpty() +
(repositories ?: oldRepositories).orEmpty()
updateSection()
}
}
private fun updateSection() {
if (section !in sections) {
section = Section.All
}
layout?.sectionName?.text = when (val section = section) {
is Section.All -> getString(R.string.all_applications)
is Section.Category -> section.name
is Section.Repository -> section.name
}
layout?.sectionIcon?.visibility =
if (sections.any { it !is Section.All }) View.VISIBLE else View.GONE
productFragments.forEach { it.setSection(section) }
sectionsList?.adapter?.notifyDataSetChanged()
}
private fun animateSectionsList() {
val sectionsList = sectionsList!!
val value = if (sectionsList.visibility != View.VISIBLE) 0f else
sectionsList.height.toFloat() / (sectionsList.parent as View).height
val target = if (showSections) 0.98f else 0f
sectionsAnimator?.cancel()
sectionsAnimator = null
if (value != target) {
sectionsAnimator = ValueAnimator.ofFloat(value, target).apply {
duration = (250 * abs(target - value)).toLong()
interpolator =
if (target >= 1f) AccelerateInterpolator(2f) else DecelerateInterpolator(2f)
addUpdateListener {
val newValue = animatedValue as Float
sectionsList.apply {
val height = ((parent as View).height * newValue).toInt()
val visible = height > 0
if ((visibility == View.VISIBLE) != visible) {
visibility = if (visible) View.VISIBLE else View.GONE
}
if (layoutParams.height != height) {
layoutParams.height = height
requestLayout()
}
}
if (target <= 0f && newValue <= 0f || target >= 1f && newValue >= 1f) {
sectionsAnimator = null
}
}
start()
}
}
}
private val pageChangeCallback = object : ViewPager2.OnPageChangeCallback() {
override fun onPageScrolled(
position: Int,
positionOffset: Float,
positionOffsetPixels: Int,
) {
val layout = layout!!
val fromSections = AppListFragment.Source.values()[position].sections
val toSections = if (positionOffset <= 0f) fromSections else
AppListFragment.Source.values()[position + 1].sections
val offset = if (fromSections != toSections) {
if (fromSections) 1f - positionOffset else positionOffset
} else {
if (fromSections) 1f else 0f
}
assert(layout.sectionLayout.childCount == 1)
val child = layout.sectionLayout.getChildAt(0)
val height = child.layoutParams.height
assert(height > 0)
val currentHeight = (offset * height).roundToInt()
if (layout.sectionLayout.layoutParams.height != currentHeight) {
layout.sectionLayout.layoutParams.height = currentHeight
layout.sectionLayout.requestLayout()
}
}
override fun onPageSelected(position: Int) {
val source = AppListFragment.Source.values()[position]
updateUpdateNotificationBlocker(source)
sortOrderMenu!!.first.apply {
isVisible = source.order
setShowAsActionFlags(
if (!source.order ||
resources.configuration.screenWidthDp >= 400
) MenuItem.SHOW_AS_ACTION_ALWAYS else 0
)
}
syncRepositoriesMenuItem!!.setShowAsActionFlags(MenuItem.SHOW_AS_ACTION_ALWAYS)
if (showSections && !source.sections) {
showSections = false
}
}
override fun onPageScrollStateChanged(state: Int) {
val source = AppListFragment.Source.values()[viewPager!!.currentItem]
layout!!.sectionChange.isEnabled =
state != ViewPager2.SCROLL_STATE_DRAGGING && source.sections
if (state == ViewPager2.SCROLL_STATE_IDLE) {
// onPageSelected can be called earlier than fragments created
updateUpdateNotificationBlocker(source)
}
}
}
private class SectionsAdapter(
private val sections: () -> List<Section>,
private val onClick: (Section) -> Unit,
) : StableRecyclerAdapter<SectionsAdapter.ViewType,
RecyclerView.ViewHolder>() {
enum class ViewType { SECTION }
private class SectionViewHolder(context: Context) :
RecyclerView.ViewHolder(MaterialTextView(context)) {
val title: MaterialTextView
get() = itemView as MaterialTextView
init {
itemView as MaterialTextView
itemView.gravity = Gravity.CENTER_VERTICAL
itemView.resources.sizeScaled(16).let { itemView.setPadding(it, 0, it, 0) }
itemView.background =
context.getDrawableFromAttr(android.R.attr.selectableItemBackground)
itemView.layoutParams = RecyclerView.LayoutParams(
RecyclerView.LayoutParams.MATCH_PARENT,
itemView.resources.sizeScaled(48)
)
}
}
fun configureDivider(
context: Context,
position: Int,
configuration: DividerItemDecoration.Configuration,
) {
val currentSection = sections()[position]
val nextSection = sections().getOrNull(position + 1)
when {
nextSection != null && currentSection.javaClass != nextSection.javaClass -> {
val padding = context.resources.sizeScaled(16)
configuration.set(
needDivider = true,
toTop = false,
paddingStart = padding,
paddingEnd = padding
)
}
else -> {
configuration.set(
needDivider = false,
toTop = false,
paddingStart = 0,
paddingEnd = 0
)
}
}
}
override val viewTypeClass: Class<ViewType>
get() = ViewType::class.java
override fun getItemCount(): Int = sections().size
override fun getItemDescriptor(position: Int): String = sections()[position].toString()
override fun getItemEnumViewType(position: Int): ViewType = ViewType.SECTION
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: ViewType,
): RecyclerView.ViewHolder {
return SectionViewHolder(parent.context).apply {
itemView.setOnClickListener { onClick(sections()[adapterPosition]) }
}
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
holder as SectionViewHolder
val section = sections()[position]
val previousSection = sections().getOrNull(position - 1)
val nextSection = sections().getOrNull(position + 1)
val margin = holder.itemView.resources.sizeScaled(8)
val layoutParams = holder.itemView.layoutParams as RecyclerView.LayoutParams
layoutParams.topMargin = if (previousSection == null ||
section.javaClass != previousSection.javaClass
) margin else 0
layoutParams.bottomMargin = if (nextSection == null ||
section.javaClass != nextSection.javaClass
) margin else 0
holder.title.text = when (section) {
is Section.All -> holder.itemView.resources.getString(R.string.all_applications)
is Section.Category -> section.name
is Section.Repository -> section.name
}
}
}
}

View File

@ -1,212 +0,0 @@
package com.looker.droidify.ui.adapters
import android.content.Context
import android.graphics.drawable.Drawable
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.view.animation.AnimationUtils
import androidx.core.content.res.ResourcesCompat
import androidx.recyclerview.widget.RecyclerView
import coil.load
import coil.transform.RoundedCornersTransformation
import com.google.android.material.circularreveal.CircularRevealFrameLayout
import com.google.android.material.imageview.ShapeableImageView
import com.google.android.material.progressindicator.CircularProgressIndicator
import com.google.android.material.textview.MaterialTextView
import com.looker.droidify.R
import com.looker.droidify.content.Preferences
import com.looker.droidify.database.entity.Repository
import com.looker.droidify.entity.ProductItem
import com.looker.droidify.network.CoilDownloader
import com.looker.droidify.utility.Utils
import com.looker.droidify.utility.extension.resources.*
import com.looker.droidify.utility.extension.text.nullIfEmpty
import com.looker.droidify.utility.getProduct
import com.looker.droidify.widget.CursorRecyclerAdapter
class AppListAdapter(private val onClick: (ProductItem) -> Unit) :
CursorRecyclerAdapter<AppListAdapter.ViewType, RecyclerView.ViewHolder>() {
private var lastPosition = 0
enum class ViewType { PRODUCT, LOADING, EMPTY }
private class ProductViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
val name = itemView.findViewById<MaterialTextView>(R.id.name)!!
val status = itemView.findViewById<MaterialTextView>(R.id.status)!!
val summary = itemView.findViewById<MaterialTextView>(R.id.summary)!!
val icon = itemView.findViewById<ShapeableImageView>(R.id.icon)!!
val progressIcon: Drawable
val defaultIcon: Drawable
init {
val (progressIcon, defaultIcon) = Utils.getDefaultApplicationIcons(icon.context)
this.progressIcon = progressIcon
this.defaultIcon = defaultIcon
}
}
private class LoadingViewHolder(context: Context) :
RecyclerView.ViewHolder(CircularRevealFrameLayout(context)) {
init {
itemView as CircularRevealFrameLayout
val progressBar = CircularProgressIndicator(itemView.context)
itemView.addView(progressBar)
itemView.layoutParams = RecyclerView.LayoutParams(
RecyclerView.LayoutParams.MATCH_PARENT,
RecyclerView.LayoutParams.MATCH_PARENT
)
}
}
private class EmptyViewHolder(context: Context) :
RecyclerView.ViewHolder(MaterialTextView(context)) {
val text: MaterialTextView
get() = itemView as MaterialTextView
init {
itemView as MaterialTextView
itemView.gravity = Gravity.CENTER
itemView.resources.sizeScaled(20).let { itemView.setPadding(it, it, it, it) }
itemView.typeface = TypefaceExtra.light
itemView.setTextColor(context.getColorFromAttr(android.R.attr.colorPrimary))
itemView.setTextSizeScaled(20)
itemView.layoutParams = RecyclerView.LayoutParams(
RecyclerView.LayoutParams.MATCH_PARENT,
RecyclerView.LayoutParams.MATCH_PARENT
)
}
}
var repositories: Map<Long, Repository> = emptyMap()
set(value) {
field = value
notifyDataSetChanged()
}
var emptyText: String = ""
set(value) {
if (field != value) {
field = value
if (isEmpty) {
notifyDataSetChanged()
}
}
}
override val viewTypeClass: Class<ViewType>
get() = ViewType::class.java
private val isEmpty: Boolean
get() = super.getItemCount() == 0
override fun getItemCount(): Int = if (isEmpty) 1 else super.getItemCount()
override fun getItemId(position: Int): Long = if (isEmpty) -1 else super.getItemId(position)
override fun getItemEnumViewType(position: Int): ViewType {
return when {
!isEmpty -> ViewType.PRODUCT
cursor == null -> ViewType.LOADING
else -> ViewType.EMPTY
}
}
private fun getProductItem(position: Int): ProductItem {
return moveTo(position).getProduct().item()
}
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: ViewType,
): RecyclerView.ViewHolder {
return when (viewType) {
ViewType.PRODUCT -> ProductViewHolder(parent.inflate(R.layout.product_item)).apply {
itemView.setOnClickListener { onClick(getProductItem(adapterPosition)) }
}
ViewType.LOADING -> LoadingViewHolder(parent.context)
ViewType.EMPTY -> EmptyViewHolder(parent.context)
}
}
override fun onViewDetachedFromWindow(holder: RecyclerView.ViewHolder) {
super.onViewDetachedFromWindow(holder)
if (Preferences[Preferences.Key.ListAnimation]) {
holder.itemView.clearAnimation()
}
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (getItemEnumViewType(position)) {
ViewType.PRODUCT -> {
holder as ProductViewHolder
val productItem = getProductItem(position)
holder.name.text = productItem.name
holder.summary.text =
if (productItem.name == productItem.summary) "" else productItem.summary
holder.summary.visibility =
if (holder.summary.text.isNotEmpty()) View.VISIBLE else View.GONE
val repository: Repository? = repositories[productItem.repositoryId]
holder.icon.load(
repository?.let {
CoilDownloader.createIconUri(
holder.icon, productItem.packageName,
productItem.icon, productItem.metadataIcon, it
)
}
) {
transformations(RoundedCornersTransformation(4.toPx))
placeholder(holder.progressIcon)
error(holder.defaultIcon)
}
holder.status.apply {
if (productItem.canUpdate) {
text = productItem.version
if (background == null) {
background =
ResourcesCompat.getDrawable(
holder.itemView.resources,
R.drawable.background_border,
context.theme
)
resources.sizeScaled(6).let { setPadding(it, it, it, it) }
backgroundTintList =
context.getColorFromAttr(R.attr.colorSecondaryContainer)
setTextColor(context.getColorFromAttr(R.attr.colorSecondary))
}
} else {
text = productItem.installedVersion.nullIfEmpty() ?: productItem.version
if (background != null) {
setPadding(0, 0, 0, 0)
setTextColor(holder.status.context.getColorFromAttr(android.R.attr.colorControlNormal))
background = null
}
}
}
val enabled = productItem.compatible || productItem.installedVersion.isNotEmpty()
sequenceOf(holder.name, holder.status, holder.summary).forEach {
it.isEnabled = enabled
}
}
ViewType.LOADING -> {
// Do nothing
}
ViewType.EMPTY -> {
holder as EmptyViewHolder
holder.text.text = emptyText
}
}::class
if (Preferences[Preferences.Key.ListAnimation]) {
setAnimation(holder.itemView, holder.adapterPosition)
}
}
private fun setAnimation(itemView: View, position: Int) {
val animation = AnimationUtils.loadAnimation(
itemView.context,
if (position > lastPosition) R.anim.slide_up else R.anim.slide_down
)
itemView.startAnimation(animation)
lastPosition = position
}
}

View File

@ -27,6 +27,7 @@ import com.looker.droidify.screen.ScreenFragment
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.utility.RxUtils
import com.looker.droidify.utility.Utils
@ -47,6 +48,9 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class AppDetailFragment() : ScreenFragment(), AppDetailAdapter.Callbacks {
private val screenActivity: MainActivityX
get() = requireActivity() as MainActivityX
companion object {
private const val EXTRA_PACKAGE_NAME = "packageName"
private const val STATE_LAYOUT_MANAGER = "layoutManager"
@ -103,7 +107,6 @@ class AppDetailFragment() : ScreenFragment(), AppDetailAdapter.Callbacks {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
screenActivity.onToolbarCreated(toolbar)
toolbar.menu.apply {
for (action in Action.values()) {
add(0, action.id, 0, action.adapterAction.titleResId)

View File

@ -1,146 +0,0 @@
package com.looker.droidify.ui.fragments
import android.database.Cursor
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.looker.droidify.R
import com.looker.droidify.database.CursorOwner
import com.looker.droidify.entity.Order
import com.looker.droidify.entity.Section
import com.looker.droidify.screen.BaseFragment
import com.looker.droidify.ui.adapters.AppListAdapter
import com.looker.droidify.ui.viewmodels.AppListViewModel
import com.looker.droidify.utility.RxUtils
import com.looker.droidify.utility.extension.resources.getDrawableCompat
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.schedulers.Schedulers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import me.zhanghai.android.fastscroll.FastScrollerBuilder
class AppListFragment() : BaseFragment(), CursorOwner.Callback {
private val viewModel: AppListViewModel by viewModels()
companion object {
private const val EXTRA_SOURCE = "source"
}
enum class Source(val titleResId: Int, val sections: Boolean, val order: Boolean) {
AVAILABLE(R.string.available, true, true),
INSTALLED(R.string.installed, false, true),
UPDATES(R.string.updates, false, false)
}
constructor(source: Source) : this() {
arguments = Bundle().apply {
putString(EXTRA_SOURCE, source.name)
}
}
val source: Source
get() = requireArguments().getString(EXTRA_SOURCE)!!.let(Source::valueOf)
private var recyclerView: RecyclerView? = null
private var repositoriesDisposable: Disposable? = null
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View {
return RecyclerView(requireContext()).apply {
id = android.R.id.list
layoutManager = LinearLayoutManager(context)
isMotionEventSplittingEnabled = false
isVerticalScrollBarEnabled = false
setHasFixedSize(true)
recycledViewPool.setMaxRecycledViews(AppListAdapter.ViewType.PRODUCT.ordinal, 30)
val adapter = AppListAdapter { screenActivity.navigateProduct(it.packageName) }
this.adapter = adapter
FastScrollerBuilder(this)
.useMd2Style()
.setThumbDrawable(this.context.getDrawableCompat(R.drawable.scrollbar_thumb))
.build()
recyclerView = this
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
screenActivity.cursorOwner.attach(this, viewModel.request(source))
repositoriesDisposable = Observable.just(Unit)
//.concatWith(Database.observable(Database.Subject.Repositories)) // TODO have to be replaced like whole rxJava
.observeOn(Schedulers.io())
.flatMapSingle { RxUtils.querySingle { screenActivity.db.repositoryDao.all } }
.map { it.asSequence().map { Pair(it.id, it) }.toMap() }
.observeOn(AndroidSchedulers.mainThread())
.subscribe { (recyclerView?.adapter as? AppListAdapter)?.repositories = it }
}
override fun onDestroyView() {
super.onDestroyView()
recyclerView = null
screenActivity.cursorOwner.detach(this)
repositoriesDisposable?.dispose()
repositoriesDisposable = null
}
override fun onCursorData(request: CursorOwner.Request, cursor: Cursor?) {
(recyclerView?.adapter as? AppListAdapter)?.apply {
this.cursor = cursor
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.RESUMED) {
emptyText = when {
cursor == null -> ""
viewModel.searchQuery.first()
.isNotEmpty() -> getString(R.string.no_matching_applications_found)
else -> when (source) {
Source.AVAILABLE -> getString(R.string.no_applications_available)
Source.INSTALLED -> getString(R.string.no_applications_installed)
Source.UPDATES -> getString(R.string.all_applications_up_to_date)
}
}
}
}
}
}
internal fun setSearchQuery(searchQuery: String) {
viewModel.setSearchQuery(searchQuery) {
if (view != null) {
screenActivity.cursorOwner.attach(this, viewModel.request(source))
}
}
}
internal fun setSection(section: Section) {
viewModel.setSection(section) {
if (view != null) {
screenActivity.cursorOwner.attach(this, viewModel.request(source))
}
}
}
internal fun setOrder(order: Order) {
viewModel.setOrder(order) {
if (view != null) {
screenActivity.cursorOwner.attach(this, viewModel.request(source))
}
}
}
}

View File

@ -1,94 +0,0 @@
package com.looker.droidify.ui.viewmodels
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.looker.droidify.content.Preferences
import com.looker.droidify.database.CursorOwner
import com.looker.droidify.entity.Order
import com.looker.droidify.entity.Section
import com.looker.droidify.ui.fragments.AppListFragment
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
class AppListViewModel : ViewModel() {
private val _order = MutableStateFlow(Preferences[Preferences.Key.SortOrder].order)
private val _sections = MutableStateFlow<Section>(Section.All)
private val _searchQuery = MutableStateFlow("")
val order: StateFlow<Order> = _order.stateIn(
initialValue = Order.LAST_UPDATE,
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000)
)
val sections: StateFlow<Section> = _sections.stateIn(
initialValue = Section.All,
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000)
)
val searchQuery: StateFlow<String> = _searchQuery.stateIn(
initialValue = "",
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000)
)
fun request(source: AppListFragment.Source): CursorOwner.Request {
var mSearchQuery = ""
var mSections: Section = Section.All
var mOrder: Order = Order.NAME
viewModelScope.launch {
launch { searchQuery.collect { if (source.sections) mSearchQuery = it } }
launch { sections.collect { if (source.sections) mSections = it } }
launch { order.collect { if (source.order) mOrder = it } }
}
return when (source) {
AppListFragment.Source.AVAILABLE -> CursorOwner.Request.ProductsAvailable(
mSearchQuery,
mSections,
mOrder
)
AppListFragment.Source.INSTALLED -> CursorOwner.Request.ProductsInstalled(
mSearchQuery,
mSections,
mOrder
)
AppListFragment.Source.UPDATES -> CursorOwner.Request.ProductsUpdates(
mSearchQuery,
mSections,
mOrder
)
}
}
fun setSection(newSection: Section, perform: () -> Unit) {
viewModelScope.launch {
if (newSection != sections.value) {
_sections.emit(newSection)
launch(Dispatchers.Main) { perform() }
}
}
}
fun setOrder(newOrder: Order, perform: () -> Unit) {
viewModelScope.launch {
if (newOrder != order.value) {
_order.emit(newOrder)
launch(Dispatchers.Main) { perform() }
}
}
}
fun setSearchQuery(newSearchQuery: String, perform: () -> Unit) {
viewModelScope.launch {
if (newSearchQuery != searchQuery.value) {
_searchQuery.emit(newSearchQuery)
launch(Dispatchers.Main) { perform() }
}
}
}
}

View File

@ -7,18 +7,18 @@ import android.content.Context
import android.content.pm.PackageInfo
import android.content.pm.Signature
import android.content.res.Configuration
import android.database.Cursor
import android.graphics.drawable.Drawable
import androidx.recyclerview.widget.AsyncDifferConfig
import androidx.recyclerview.widget.DiffUtil
import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.core.JsonParser
import com.looker.droidify.*
import com.looker.droidify.BuildConfig
import com.looker.droidify.PREFS_LANGUAGE_DEFAULT
import com.looker.droidify.R
import com.looker.droidify.content.Preferences
import com.looker.droidify.database.entity.Installed
import com.looker.droidify.database.entity.Repository
import com.looker.droidify.entity.Product
import com.looker.droidify.entity.ProductItem
import com.looker.droidify.service.Connection
import com.looker.droidify.service.DownloadService
import com.looker.droidify.utility.extension.android.Android
@ -184,39 +184,6 @@ object Utils {
}
// TODO Remove
fun Cursor.getProduct(): Product = getBlob(getColumnIndex(ROW_DATA))
.jsonParse {
Product.deserialize(it).apply {
this.repositoryId = getLong(getColumnIndex(ROW_REPOSITORY_ID))
this.description = getString(getColumnIndex(ROW_DESCRIPTION))
}
}
// TODO Remove
fun Cursor.getProductItem(): ProductItem = getBlob(getColumnIndex(ROW_DATA_ITEM))
.jsonParse {
ProductItem.deserialize(it).apply {
this.repositoryId = getLong(getColumnIndex(ROW_REPOSITORY_ID))
this.packageName = getString(getColumnIndex(ROW_PACKAGE_NAME))
this.name = getString(getColumnIndex(ROW_NAME))
this.summary = getString(getColumnIndex(ROW_SUMMARY))
this.installedVersion = getString(getColumnIndex(ROW_VERSION))
.orEmpty()
this.compatible = getInt(getColumnIndex(ROW_COMPATIBLE)) != 0
this.canUpdate = getInt(getColumnIndex(ROW_CAN_UPDATE)) != 0
this.matchRank = getInt(getColumnIndex(ROW_MATCH_RANK))
}
}
// TODO Remove
fun Cursor.getRepository(): Repository = getBlob(getColumnIndex(ROW_DATA))
.jsonParse {
Repository.deserialize(it).apply {
this.id = getLong(getColumnIndex(ROW_ID))
}
}
fun <T> ByteArray.jsonParse(callback: (JsonParser) -> T): T {
return Json.factory.createParser(this).use { it.parseDictionary(callback) }
}