mirror of
https://github.com/Aviortheking/Neo-Store.git
synced 2025-06-17 04:49:20 +00:00
Initial Commit
This commit is contained in:
@ -0,0 +1,484 @@
|
||||
package com.looker.droidify.screen
|
||||
|
||||
import android.app.AlertDialog
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.graphics.PorterDuff
|
||||
import android.graphics.PorterDuffColorFilter
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.text.Editable
|
||||
import android.text.Selection
|
||||
import android.text.TextWatcher
|
||||
import android.util.Base64
|
||||
import android.view.LayoutInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.EditText
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.TextView
|
||||
import android.widget.Toolbar
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.disposables.Disposable
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import com.looker.droidify.R
|
||||
import com.looker.droidify.database.Database
|
||||
import com.looker.droidify.entity.Repository
|
||||
import com.looker.droidify.network.Downloader
|
||||
import com.looker.droidify.service.Connection
|
||||
import com.looker.droidify.service.SyncService
|
||||
import com.looker.droidify.utility.RxUtils
|
||||
import com.looker.droidify.utility.Utils
|
||||
import com.looker.droidify.utility.extension.resources.*
|
||||
import com.looker.droidify.utility.extension.text.*
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.Request
|
||||
import java.net.URI
|
||||
import java.net.URL
|
||||
import java.nio.charset.Charset
|
||||
import java.util.Locale
|
||||
import kotlin.math.*
|
||||
|
||||
class EditRepositoryFragment(): ScreenFragment() {
|
||||
companion object {
|
||||
private const val EXTRA_REPOSITORY_ID = "repositoryId"
|
||||
|
||||
private val checkPaths = listOf("", "fdroid/repo", "repo")
|
||||
}
|
||||
|
||||
constructor(repositoryId: Long?): this() {
|
||||
arguments = Bundle().apply {
|
||||
repositoryId?.let { putLong(EXTRA_REPOSITORY_ID, it) }
|
||||
}
|
||||
}
|
||||
|
||||
private class Layout(view: View) {
|
||||
val address = view.findViewById<EditText>(R.id.address)!!
|
||||
val addressMirror = view.findViewById<View>(R.id.address_mirror)!!
|
||||
val addressError = view.findViewById<TextView>(R.id.address_error)!!
|
||||
val fingerprint = view.findViewById<EditText>(R.id.fingerprint)!!
|
||||
val fingerprintError = view.findViewById<TextView>(R.id.fingerprint_error)!!
|
||||
val username = view.findViewById<EditText>(R.id.username)!!
|
||||
val usernameError = view.findViewById<TextView>(R.id.username_error)!!
|
||||
val password = view.findViewById<EditText>(R.id.password)!!
|
||||
val passwordError = view.findViewById<TextView>(R.id.password_error)!!
|
||||
val overlay = view.findViewById<View>(R.id.overlay)!!
|
||||
val skip = view.findViewById<View>(R.id.skip)!!
|
||||
}
|
||||
|
||||
private val repositoryId: Long?
|
||||
get() = requireArguments().let { if (it.containsKey(EXTRA_REPOSITORY_ID))
|
||||
it.getLong(EXTRA_REPOSITORY_ID) else null }
|
||||
|
||||
private lateinit var errorColorFilter: PorterDuffColorFilter
|
||||
|
||||
private var saveMenuItem: MenuItem? = null
|
||||
private var layout: Layout? = null
|
||||
|
||||
private val syncConnection = Connection(SyncService::class.java)
|
||||
private var repositoriesDisposable: Disposable? = null
|
||||
private var checkDisposable: Disposable? = null
|
||||
|
||||
private var takenAddresses = emptySet<String>()
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
return inflater.inflate(R.layout.fragment, container, false)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
syncConnection.bind(requireContext())
|
||||
|
||||
val toolbar = view.findViewById<Toolbar>(R.id.toolbar)!!
|
||||
screenActivity.onToolbarCreated(toolbar)
|
||||
if (repositoryId != null) {
|
||||
toolbar.setTitle(R.string.edit_repository)
|
||||
} else {
|
||||
toolbar.setTitle(R.string.add_repository)
|
||||
}
|
||||
|
||||
toolbar.menu.apply {
|
||||
saveMenuItem = add(R.string.save)
|
||||
.setIcon(Utils.getToolbarIcon(toolbar.context, R.drawable.ic_save))
|
||||
.setEnabled(false)
|
||||
.setShowAsActionFlags(MenuItem.SHOW_AS_ACTION_ALWAYS)
|
||||
.setOnMenuItemClickListener {
|
||||
onSaveRepositoryClick(true)
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
val content = view.findViewById<FrameLayout>(R.id.fragment_content)!!
|
||||
errorColorFilter = PorterDuffColorFilter(content.context
|
||||
.getColorFromAttr(R.attr.colorError).defaultColor, PorterDuff.Mode.SRC_IN)
|
||||
|
||||
content.addView(content.inflate(R.layout.edit_repository))
|
||||
val layout = Layout(content)
|
||||
this.layout = layout
|
||||
|
||||
layout.fingerprint.hint = generateSequence { "FF" }.take(32).joinToString(separator = " ")
|
||||
layout.fingerprint.addTextChangedListener(object: TextWatcher {
|
||||
override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) = Unit
|
||||
override fun onTextChanged(s: CharSequence, start: Int, count: Int, after: Int) = Unit
|
||||
|
||||
private val validChar: (Char) -> Boolean = { it in '0' .. '9' || it in 'a' .. 'f' || it in 'A' .. 'F' }
|
||||
|
||||
private fun logicalPosition(s: String, position: Int): Int {
|
||||
return if (position > 0) s.asSequence().take(position).count(validChar) else position
|
||||
}
|
||||
|
||||
private fun realPosition(s: String, position: Int): Int {
|
||||
return if (position > 0) {
|
||||
var left = position
|
||||
val index = s.indexOfFirst {
|
||||
validChar(it) && run {
|
||||
left -= 1
|
||||
left <= 0
|
||||
}
|
||||
}
|
||||
if (index >= 0) min(index + 1, s.length) else s.length
|
||||
} else {
|
||||
position
|
||||
}
|
||||
}
|
||||
|
||||
override fun afterTextChanged(s: Editable) {
|
||||
val inputString = s.toString()
|
||||
val outputString = inputString.toUpperCase(Locale.US)
|
||||
.filter(validChar).windowed(2, 2, true).take(32).joinToString(separator = " ")
|
||||
if (inputString != outputString) {
|
||||
val inputStart = logicalPosition(inputString, Selection.getSelectionStart(s))
|
||||
val inputEnd = logicalPosition(inputString, Selection.getSelectionEnd(s))
|
||||
s.replace(0, s.length, outputString)
|
||||
Selection.setSelection(s, realPosition(outputString, inputStart), realPosition(outputString, inputEnd))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (savedInstanceState == null) {
|
||||
val repository = repositoryId?.let(Database.RepositoryAdapter::get)
|
||||
if (repository == null) {
|
||||
val clipboardManager = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
val text = clipboardManager.primaryClip
|
||||
?.let { if (it.itemCount > 0) it else null }
|
||||
?.getItemAt(0)?.text?.toString().orEmpty()
|
||||
val (addressText, fingerprintText) = try {
|
||||
val uri = Uri.parse(URL(text).toString())
|
||||
val fingerprintText = uri.getQueryParameter("fingerprint")?.nullIfEmpty()
|
||||
?: uri.getQueryParameter("FINGERPRINT")?.nullIfEmpty()
|
||||
Pair(uri.buildUpon().path(uri.path?.pathCropped)
|
||||
.query(null).fragment(null).build().toString(), fingerprintText)
|
||||
} catch (e: Exception) {
|
||||
Pair(null, null)
|
||||
}
|
||||
layout.address.setText(addressText?.nullIfEmpty() ?: layout.address.hint)
|
||||
layout.fingerprint.setText(fingerprintText)
|
||||
} else {
|
||||
layout.address.setText(repository.address)
|
||||
val mirrors = repository.mirrors.map { it.withoutKnownPath }
|
||||
if (mirrors.isNotEmpty()) {
|
||||
layout.addressMirror.visibility = View.VISIBLE
|
||||
layout.address.apply { setPaddingRelative(paddingStart, paddingTop,
|
||||
paddingEnd + layout.addressMirror.layoutParams.width, paddingBottom) }
|
||||
layout.addressMirror.setOnClickListener { SelectMirrorDialog(mirrors)
|
||||
.show(childFragmentManager, SelectMirrorDialog::class.java.name) }
|
||||
}
|
||||
layout.fingerprint.setText(repository.fingerprint)
|
||||
val (usernameText, passwordText) = repository.authentication.nullIfEmpty()
|
||||
?.let { if (it.startsWith("Basic ")) it.substring(6) else null }
|
||||
?.let {
|
||||
try {
|
||||
Base64.decode(it, Base64.NO_WRAP).toString(Charset.defaultCharset())
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
}
|
||||
?.let {
|
||||
val index = it.indexOf(':')
|
||||
if (index >= 0) Pair(it.substring(0, index), it.substring(index + 1)) else null
|
||||
}
|
||||
?: Pair(null, null)
|
||||
layout.username.setText(usernameText)
|
||||
layout.password.setText(passwordText)
|
||||
}
|
||||
}
|
||||
|
||||
layout.address.addTextChangedListener(SimpleTextWatcher { invalidateAddress() })
|
||||
layout.fingerprint.addTextChangedListener(SimpleTextWatcher { invalidateFingerprint() })
|
||||
layout.username.addTextChangedListener(SimpleTextWatcher { invalidateUsernamePassword() })
|
||||
layout.password.addTextChangedListener(SimpleTextWatcher { invalidateUsernamePassword() })
|
||||
|
||||
(layout.overlay.parent as ViewGroup).layoutTransition?.setDuration(200L)
|
||||
layout.overlay.background!!.apply {
|
||||
mutate()
|
||||
alpha = 0xcc
|
||||
}
|
||||
layout.skip.setOnClickListener {
|
||||
if (checkDisposable != null) {
|
||||
checkDisposable?.dispose()
|
||||
checkDisposable = null
|
||||
onSaveRepositoryClick(false)
|
||||
}
|
||||
}
|
||||
|
||||
repositoriesDisposable = Observable.just(Unit)
|
||||
.concatWith(Database.observable(Database.Subject.Repositories))
|
||||
.observeOn(Schedulers.io())
|
||||
.flatMapSingle { RxUtils.querySingle { Database.RepositoryAdapter.getAll(it) } }
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe {
|
||||
takenAddresses = it.asSequence().filter { it.id != repositoryId }
|
||||
.flatMap { (it.mirrors + it.address).asSequence() }
|
||||
.map { it.withoutKnownPath }.toSet()
|
||||
invalidateAddress()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
|
||||
saveMenuItem = null
|
||||
layout = null
|
||||
|
||||
syncConnection.unbind(requireContext())
|
||||
repositoriesDisposable?.dispose()
|
||||
repositoriesDisposable = null
|
||||
checkDisposable?.dispose()
|
||||
checkDisposable = null
|
||||
}
|
||||
|
||||
override fun onActivityCreated(savedInstanceState: Bundle?) {
|
||||
super.onActivityCreated(savedInstanceState)
|
||||
|
||||
invalidateAddress()
|
||||
invalidateFingerprint()
|
||||
invalidateUsernamePassword()
|
||||
}
|
||||
|
||||
private var addressError = false
|
||||
private var fingerprintError = false
|
||||
private var usernamePasswordError = false
|
||||
|
||||
private fun invalidateAddress() {
|
||||
invalidateAddress(layout!!.address.text.toString())
|
||||
}
|
||||
|
||||
private fun invalidateAddress(addressText: String) {
|
||||
val layout = layout!!
|
||||
val normalizedAddress = normalizeAddress(addressText)
|
||||
val addressErrorResId = if (normalizedAddress != null) {
|
||||
if (normalizedAddress.withoutKnownPath in takenAddresses) {
|
||||
R.string.already_exists
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} else {
|
||||
R.string.invalid_address
|
||||
}
|
||||
layout.address.setError(addressErrorResId != null)
|
||||
layout.addressError.visibility = if (addressErrorResId != null) View.VISIBLE else View.GONE
|
||||
if (addressErrorResId != null) {
|
||||
layout.addressError.setText(addressErrorResId)
|
||||
}
|
||||
addressError = addressErrorResId != null
|
||||
invalidateState()
|
||||
}
|
||||
|
||||
private fun invalidateFingerprint() {
|
||||
val layout = layout!!
|
||||
val fingerprint = layout.fingerprint.text.toString().replace(" ", "")
|
||||
val fingerprintInvalid = fingerprint.isNotEmpty() && fingerprint.length != 64
|
||||
layout.fingerprintError.visibility = if (fingerprintInvalid) View.VISIBLE else View.GONE
|
||||
if (fingerprintInvalid) {
|
||||
layout.fingerprintError.setText(R.string.invalid_fingerprint_format)
|
||||
}
|
||||
layout.fingerprint.setError(fingerprintInvalid)
|
||||
fingerprintError = fingerprintInvalid
|
||||
invalidateState()
|
||||
}
|
||||
|
||||
private fun invalidateUsernamePassword() {
|
||||
val layout = layout!!
|
||||
val username = layout.username.text.toString()
|
||||
val password = layout.password.text.toString()
|
||||
val usernameInvalid = username.contains(':')
|
||||
val usernameEmpty = username.isEmpty() && password.isNotEmpty()
|
||||
val passwordEmpty = username.isNotEmpty() && password.isEmpty()
|
||||
layout.usernameError.visibility = if (usernameInvalid || usernameEmpty) View.VISIBLE else View.GONE
|
||||
layout.passwordError.visibility = if (passwordEmpty) View.VISIBLE else View.GONE
|
||||
if (usernameInvalid) {
|
||||
layout.usernameError.setText(R.string.invalid_username_format)
|
||||
} else if (usernameEmpty) {
|
||||
layout.usernameError.setText(R.string.username_missing)
|
||||
}
|
||||
layout.username.setError(usernameEmpty)
|
||||
if (passwordEmpty) {
|
||||
layout.passwordError.setText(R.string.password_missing)
|
||||
}
|
||||
layout.password.setError(passwordEmpty)
|
||||
usernamePasswordError = usernameInvalid || usernameEmpty || passwordEmpty
|
||||
invalidateState()
|
||||
}
|
||||
|
||||
private fun invalidateState() {
|
||||
val layout = layout!!
|
||||
saveMenuItem!!.isEnabled = !addressError && !fingerprintError &&
|
||||
!usernamePasswordError && checkDisposable == null
|
||||
layout.apply { sequenceOf(address, addressMirror, fingerprint, username, password)
|
||||
.forEach { it.isEnabled = checkDisposable == null } }
|
||||
layout.overlay.visibility = if (checkDisposable != null) View.VISIBLE else View.GONE
|
||||
}
|
||||
|
||||
private val String.pathCropped: String
|
||||
get() {
|
||||
val index = indexOfLast { it != '/' }
|
||||
return if (index >= 0 && index < length - 1) substring(0, index + 1) else this
|
||||
}
|
||||
|
||||
private val String.withoutKnownPath: String
|
||||
get() {
|
||||
val cropped = pathCropped
|
||||
val endsWith = checkPaths.asSequence().filter { it.isNotEmpty() }
|
||||
.sortedByDescending { it.length }.find { cropped.endsWith("/$it") }
|
||||
return if (endsWith != null) cropped.substring(0, cropped.length - endsWith.length - 1) else cropped
|
||||
}
|
||||
|
||||
private fun normalizeAddress(address: String): String? {
|
||||
val uri = try {
|
||||
val uri = URI(address)
|
||||
if (uri.isAbsolute) uri.normalize() else null
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
val path = uri?.path?.pathCropped
|
||||
return if (uri != null && path != null) {
|
||||
try {
|
||||
URI(uri.scheme, uri.userInfo, uri.host, uri.port, path, uri.query, uri.fragment).toString()
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun setMirror(address: String) {
|
||||
layout?.address?.setText(address)
|
||||
}
|
||||
|
||||
private fun EditText.setError(error: Boolean) {
|
||||
val drawable = background.mutate()
|
||||
drawable.colorFilter = if (error) errorColorFilter else null
|
||||
}
|
||||
|
||||
private fun onSaveRepositoryClick(check: Boolean) {
|
||||
if (checkDisposable == null) {
|
||||
val layout = layout!!
|
||||
val address = normalizeAddress(layout.address.text.toString())!!
|
||||
val fingerprint = layout.fingerprint.text.toString().replace(" ", "")
|
||||
val username = layout.username.text.toString().nullIfEmpty()
|
||||
val password = layout.password.text.toString().nullIfEmpty()
|
||||
val paths = sequenceOf("", "fdroid/repo", "repo")
|
||||
val authentication = username?.let { u -> password
|
||||
?.let { p -> Base64.encodeToString("$u:$p".toByteArray(Charset.defaultCharset()), Base64.NO_WRAP) } }
|
||||
?.let { "Basic $it" }.orEmpty()
|
||||
|
||||
if (check) {
|
||||
checkDisposable = paths
|
||||
.fold(Single.just("")) { oldAddressSingle, checkPath -> oldAddressSingle
|
||||
.flatMap { oldAddress ->
|
||||
if (oldAddress.isEmpty()) {
|
||||
val builder = Uri.parse(address).buildUpon()
|
||||
.let { if (checkPath.isEmpty()) it else it.appendEncodedPath(checkPath) }
|
||||
val newAddress = builder.build()
|
||||
val indexAddress = builder.appendPath("index.jar").build()
|
||||
RxUtils
|
||||
.callSingle { Downloader
|
||||
.createCall(Request.Builder().method("HEAD", null)
|
||||
.url(indexAddress.toString().toHttpUrl()), authentication, null) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.map { if (it.code == 200) newAddress.toString() else "" }
|
||||
} else {
|
||||
Single.just(oldAddress)
|
||||
}
|
||||
}
|
||||
}
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe { result, throwable ->
|
||||
checkDisposable = null
|
||||
throwable?.printStackTrace()
|
||||
val resultAddress = result?.let { if (it.isEmpty()) null else it } ?: address
|
||||
val allow = resultAddress == address || run {
|
||||
layout.address.setText(resultAddress)
|
||||
invalidateAddress(resultAddress)
|
||||
!addressError
|
||||
}
|
||||
if (allow) {
|
||||
onSaveRepositoryProceedInvalidate(resultAddress, fingerprint, authentication)
|
||||
} else {
|
||||
invalidateState()
|
||||
}
|
||||
}
|
||||
invalidateState()
|
||||
} else {
|
||||
onSaveRepositoryProceedInvalidate(address, fingerprint, authentication)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onSaveRepositoryProceedInvalidate(address: String, fingerprint: String, authentication: String) {
|
||||
val binder = syncConnection.binder
|
||||
if (binder != null) {
|
||||
val repositoryId = repositoryId
|
||||
if (repositoryId != null && binder.isCurrentlySyncing(repositoryId)) {
|
||||
MessageDialog(MessageDialog.Message.CantEditSyncing).show(childFragmentManager)
|
||||
invalidateState()
|
||||
} else {
|
||||
val repository = repositoryId?.let(Database.RepositoryAdapter::get)
|
||||
?.edit(address, fingerprint, authentication)
|
||||
?: Repository.newRepository(address, fingerprint, authentication)
|
||||
val changedRepository = Database.RepositoryAdapter.put(repository)
|
||||
if (repositoryId == null && changedRepository.enabled) {
|
||||
binder.sync(changedRepository)
|
||||
}
|
||||
requireActivity().onBackPressed()
|
||||
}
|
||||
} else {
|
||||
invalidateState()
|
||||
}
|
||||
}
|
||||
|
||||
private class SimpleTextWatcher(private val callback: (Editable) -> Unit): TextWatcher {
|
||||
override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) = Unit
|
||||
override fun onTextChanged(s: CharSequence, start: Int, count: Int, after: Int) = Unit
|
||||
override fun afterTextChanged(s: Editable) = callback(s)
|
||||
}
|
||||
|
||||
class SelectMirrorDialog(): DialogFragment() {
|
||||
companion object {
|
||||
private const val EXTRA_MIRRORS = "mirrors"
|
||||
}
|
||||
|
||||
constructor(mirrors: List<String>): this() {
|
||||
arguments = Bundle().apply {
|
||||
putStringArrayList(EXTRA_MIRRORS, ArrayList(mirrors))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): AlertDialog {
|
||||
val mirrors = requireArguments().getStringArrayList(EXTRA_MIRRORS)!!
|
||||
return AlertDialog.Builder(requireContext())
|
||||
.setTitle(R.string.select_mirror)
|
||||
.setItems(mirrors.toTypedArray()) { _, position -> (parentFragment as EditRepositoryFragment)
|
||||
.setMirror(mirrors[position]) }
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.create()
|
||||
}
|
||||
}
|
||||
}
|
231
src/main/kotlin/com/looker/droidify/screen/MessageDialog.kt
Normal file
231
src/main/kotlin/com/looker/droidify/screen/MessageDialog.kt
Normal file
@ -0,0 +1,231 @@
|
||||
package com.looker.droidify.screen
|
||||
|
||||
import android.app.AlertDialog
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.os.Parcel
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import com.looker.droidify.R
|
||||
import com.looker.droidify.entity.Release
|
||||
import com.looker.droidify.utility.KParcelable
|
||||
import com.looker.droidify.utility.PackageItemResolver
|
||||
import com.looker.droidify.utility.extension.android.*
|
||||
import com.looker.droidify.utility.extension.text.*
|
||||
|
||||
class MessageDialog(): DialogFragment() {
|
||||
companion object {
|
||||
private const val EXTRA_MESSAGE = "message"
|
||||
}
|
||||
|
||||
sealed class Message: KParcelable {
|
||||
object DeleteRepositoryConfirm: Message() {
|
||||
@Suppress("unused") @JvmField val CREATOR = KParcelable.creator { DeleteRepositoryConfirm }
|
||||
}
|
||||
|
||||
object CantEditSyncing: Message() {
|
||||
@Suppress("unused") @JvmField val CREATOR = KParcelable.creator { CantEditSyncing }
|
||||
}
|
||||
|
||||
class Link(val uri: Uri): Message() {
|
||||
override fun writeToParcel(dest: Parcel, flags: Int) {
|
||||
dest.writeString(uri.toString())
|
||||
}
|
||||
|
||||
companion object {
|
||||
@Suppress("unused") @JvmField val CREATOR = KParcelable.creator {
|
||||
val uri = Uri.parse(it.readString()!!)
|
||||
Link(uri)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Permissions(val group: String?, val permissions: List<String>): Message() {
|
||||
override fun writeToParcel(dest: Parcel, flags: Int) {
|
||||
dest.writeString(group)
|
||||
dest.writeStringList(permissions)
|
||||
}
|
||||
|
||||
companion object {
|
||||
@Suppress("unused") @JvmField val CREATOR = KParcelable.creator {
|
||||
val group = it.readString()
|
||||
val permissions = it.createStringArrayList()!!
|
||||
Permissions(group, permissions)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ReleaseIncompatible(val incompatibilities: List<Release.Incompatibility>,
|
||||
val platforms: List<String>, val minSdkVersion: Int, val maxSdkVersion: Int): Message() {
|
||||
override fun writeToParcel(dest: Parcel, flags: Int) {
|
||||
dest.writeInt(incompatibilities.size)
|
||||
for (incompatibility in incompatibilities) {
|
||||
when (incompatibility) {
|
||||
is Release.Incompatibility.MinSdk -> {
|
||||
dest.writeInt(0)
|
||||
}
|
||||
is Release.Incompatibility.MaxSdk -> {
|
||||
dest.writeInt(1)
|
||||
}
|
||||
is Release.Incompatibility.Platform -> {
|
||||
dest.writeInt(2)
|
||||
}
|
||||
is Release.Incompatibility.Feature -> {
|
||||
dest.writeInt(3)
|
||||
dest.writeString(incompatibility.feature)
|
||||
}
|
||||
}::class
|
||||
}
|
||||
dest.writeStringList(platforms)
|
||||
dest.writeInt(minSdkVersion)
|
||||
dest.writeInt(maxSdkVersion)
|
||||
}
|
||||
|
||||
companion object {
|
||||
@Suppress("unused") @JvmField val CREATOR = KParcelable.creator {
|
||||
val count = it.readInt()
|
||||
val incompatibilities = generateSequence {
|
||||
when (it.readInt()) {
|
||||
0 -> Release.Incompatibility.MinSdk
|
||||
1 -> Release.Incompatibility.MaxSdk
|
||||
2 -> Release.Incompatibility.Platform
|
||||
3 -> Release.Incompatibility.Feature(it.readString()!!)
|
||||
else -> throw RuntimeException()
|
||||
}
|
||||
}.take(count).toList()
|
||||
val platforms = it.createStringArrayList()!!
|
||||
val minSdkVersion = it.readInt()
|
||||
val maxSdkVersion = it.readInt()
|
||||
ReleaseIncompatible(incompatibilities, platforms, minSdkVersion, maxSdkVersion)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object ReleaseOlder: Message() {
|
||||
@Suppress("unused") @JvmField val CREATOR = KParcelable.creator { ReleaseOlder }
|
||||
}
|
||||
|
||||
object ReleaseSignatureMismatch: Message() {
|
||||
@Suppress("unused") @JvmField val CREATOR = KParcelable.creator { ReleaseSignatureMismatch }
|
||||
}
|
||||
}
|
||||
|
||||
constructor(message: Message): this() {
|
||||
arguments = Bundle().apply {
|
||||
putParcelable(EXTRA_MESSAGE, message)
|
||||
}
|
||||
}
|
||||
|
||||
fun show(fragmentManager: FragmentManager) {
|
||||
show(fragmentManager, this::class.java.name)
|
||||
}
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): AlertDialog {
|
||||
val dialog = AlertDialog.Builder(requireContext())
|
||||
when (val message = requireArguments().getParcelable<Message>(EXTRA_MESSAGE)!!) {
|
||||
is Message.DeleteRepositoryConfirm -> {
|
||||
dialog.setTitle(R.string.confirmation)
|
||||
dialog.setMessage(R.string.delete_repository_DESC)
|
||||
dialog.setPositiveButton(R.string.delete) { _, _ -> (parentFragment as RepositoryFragment).onDeleteConfirm() }
|
||||
dialog.setNegativeButton(R.string.cancel, null)
|
||||
}
|
||||
is Message.CantEditSyncing -> {
|
||||
dialog.setTitle(R.string.action_failed)
|
||||
dialog.setMessage(R.string.cant_edit_sync_DESC)
|
||||
dialog.setPositiveButton(R.string.ok, null)
|
||||
}
|
||||
is Message.Link -> {
|
||||
dialog.setTitle(R.string.confirmation)
|
||||
dialog.setMessage(getString(R.string.open_DESC_FORMAT, message.uri.toString()))
|
||||
dialog.setPositiveButton(R.string.ok) { _, _ ->
|
||||
try {
|
||||
startActivity(Intent(Intent.ACTION_VIEW, message.uri))
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
dialog.setNegativeButton(R.string.cancel, null)
|
||||
}
|
||||
is Message.Permissions -> {
|
||||
val packageManager = requireContext().packageManager
|
||||
val builder = StringBuilder()
|
||||
val localCache = PackageItemResolver.LocalCache()
|
||||
val title = if (message.group != null) {
|
||||
val name = try {
|
||||
val permissionGroupInfo = packageManager.getPermissionGroupInfo(message.group, 0)
|
||||
PackageItemResolver.loadLabel(requireContext(), localCache, permissionGroupInfo)
|
||||
?.nullIfEmpty()?.let { if (it == message.group) null else it }
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
name ?: getString(R.string.unknown)
|
||||
} else {
|
||||
getString(R.string.other)
|
||||
}
|
||||
for (permission in message.permissions) {
|
||||
val description = try {
|
||||
val permissionInfo = packageManager.getPermissionInfo(permission, 0)
|
||||
PackageItemResolver.loadDescription(requireContext(), localCache, permissionInfo)
|
||||
?.nullIfEmpty()?.let { if (it == permission) null else it }
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
description?.let { builder.append(it).append("\n\n") }
|
||||
}
|
||||
if (builder.isNotEmpty()) {
|
||||
builder.delete(builder.length - 2, builder.length)
|
||||
} else {
|
||||
builder.append(getString(R.string.no_description_available_DESC))
|
||||
}
|
||||
dialog.setTitle(title)
|
||||
dialog.setMessage(builder)
|
||||
dialog.setPositiveButton(R.string.ok, null)
|
||||
}
|
||||
is Message.ReleaseIncompatible -> {
|
||||
val builder = StringBuilder()
|
||||
val minSdkVersion = if (Release.Incompatibility.MinSdk in message.incompatibilities)
|
||||
message.minSdkVersion else null
|
||||
val maxSdkVersion = if (Release.Incompatibility.MaxSdk in message.incompatibilities)
|
||||
message.maxSdkVersion else null
|
||||
if (minSdkVersion != null || maxSdkVersion != null) {
|
||||
val versionMessage = minSdkVersion?.let { getString(R.string.incompatible_api_min_DESC_FORMAT, it) }
|
||||
?: maxSdkVersion?.let { getString(R.string.incompatible_api_max_DESC_FORMAT, it) }
|
||||
builder.append(getString(R.string.incompatible_api_DESC_FORMAT,
|
||||
Android.name, Android.sdk, versionMessage.orEmpty())).append("\n\n")
|
||||
}
|
||||
if (Release.Incompatibility.Platform in message.incompatibilities) {
|
||||
builder.append(getString(R.string.incompatible_platforms_DESC_FORMAT,
|
||||
Android.primaryPlatform ?: getString(R.string.unknown),
|
||||
message.platforms.joinToString(separator = ", "))).append("\n\n")
|
||||
}
|
||||
val features = message.incompatibilities.mapNotNull { it as? Release.Incompatibility.Feature }
|
||||
if (features.isNotEmpty()) {
|
||||
builder.append(getString(R.string.incompatible_features_DESC))
|
||||
for (feature in features) {
|
||||
builder.append("\n\u2022 ").append(feature.feature)
|
||||
}
|
||||
builder.append("\n\n")
|
||||
}
|
||||
if (builder.isNotEmpty()) {
|
||||
builder.delete(builder.length - 2, builder.length)
|
||||
}
|
||||
dialog.setTitle(R.string.incompatible_version)
|
||||
dialog.setMessage(builder)
|
||||
dialog.setPositiveButton(R.string.ok, null)
|
||||
}
|
||||
is Message.ReleaseOlder -> {
|
||||
dialog.setTitle(R.string.incompatible_version)
|
||||
dialog.setMessage(R.string.incompatible_older_DESC)
|
||||
dialog.setPositiveButton(R.string.ok, null)
|
||||
}
|
||||
is Message.ReleaseSignatureMismatch -> {
|
||||
dialog.setTitle(R.string.incompatible_version)
|
||||
dialog.setMessage(R.string.incompatible_signature_DESC)
|
||||
dialog.setPositiveButton(R.string.ok, null)
|
||||
}
|
||||
}::class
|
||||
return dialog.create()
|
||||
}
|
||||
}
|
@ -0,0 +1,296 @@
|
||||
package com.looker.droidify.screen
|
||||
|
||||
import android.app.AlertDialog
|
||||
import android.app.Dialog
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Typeface
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.text.InputFilter
|
||||
import android.text.InputType
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.WindowManager
|
||||
import android.widget.*
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.fragment.app.Fragment
|
||||
import com.looker.droidify.R
|
||||
import com.looker.droidify.content.Preferences
|
||||
import com.looker.droidify.utility.extension.resources.*
|
||||
import io.reactivex.rxjava3.disposables.Disposable
|
||||
|
||||
|
||||
class PreferencesFragment: ScreenFragment() {
|
||||
private val preferences = mutableMapOf<Preferences.Key<*>, Preference<*>>()
|
||||
private var disposable: Disposable? = null
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
return inflater.inflate(R.layout.fragment, container, false)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
val toolbar = view.findViewById<Toolbar>(R.id.toolbar)!!
|
||||
screenActivity.onToolbarCreated(toolbar)
|
||||
toolbar.setTitle(R.string.preferences)
|
||||
|
||||
val content = view.findViewById<FrameLayout>(R.id.fragment_content)!!
|
||||
val scroll = ScrollView(content.context)
|
||||
scroll.id = R.id.preferences_list
|
||||
scroll.isFillViewport = true
|
||||
content.addView(scroll, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
|
||||
val scrollLayout = FrameLayout(content.context)
|
||||
scroll.addView(scrollLayout, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
|
||||
val preferences = LinearLayout(scrollLayout.context)
|
||||
preferences.orientation = LinearLayout.VERTICAL
|
||||
scrollLayout.addView(preferences, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
|
||||
|
||||
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.Always -> getString(R.string.always)
|
||||
}
|
||||
}
|
||||
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))
|
||||
}
|
||||
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.other)) {
|
||||
addEnumeration(Preferences.Key.Theme, getString(R.string.theme)) {
|
||||
when (it) {
|
||||
is Preferences.Theme.System -> getString(R.string.system)
|
||||
is Preferences.Theme.Light -> getString(R.string.light)
|
||||
is Preferences.Theme.Dark -> getString(R.string.dark)
|
||||
}
|
||||
}
|
||||
addSwitch(Preferences.Key.IncompatibleVersions, getString(R.string.incompatible_versions),
|
||||
getString(R.string.incompatible_versions_summary))
|
||||
}
|
||||
// Adding Credits to Foxy
|
||||
//TODO "Fix Linking"
|
||||
var number = 0
|
||||
|
||||
preferences.addCategory("Credits") {
|
||||
addText(title = "Based on an App by kitsunyan", summary = "FoxyDroid").also { setOnClickListener { number = 1 ; openURI(urlToSite = "https://github.com/kitsunyan/foxy-droid/") } }
|
||||
}
|
||||
|
||||
// End Credits
|
||||
|
||||
|
||||
disposable = Preferences.observable.subscribe(this::updatePreference)
|
||||
updatePreference(null)
|
||||
}
|
||||
|
||||
// Add Text for Credits
|
||||
|
||||
private fun LinearLayout.addText(title: String, summary: String){
|
||||
val text = TextView(context)
|
||||
val subText = TextView(context)
|
||||
text.text = title
|
||||
subText.text = summary
|
||||
text.setTypeface(null, Typeface.BOLD)
|
||||
resources.sizeScaled(16).let { text.setPadding(it, it, 5, 5) }
|
||||
resources.sizeScaled(16).let { subText.setPadding(it, 5, 5, 5) }
|
||||
addView(text, LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT)
|
||||
addView(subText, LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT)
|
||||
}
|
||||
|
||||
private fun openURI(urlToSite: String){
|
||||
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(urlToSite))
|
||||
startActivity(browserIntent)
|
||||
}
|
||||
// End Add Text for Credits
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
|
||||
preferences.clear()
|
||||
disposable?.dispose()
|
||||
disposable = 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.Theme) {
|
||||
requireActivity().recreate()
|
||||
}
|
||||
}
|
||||
|
||||
private fun LinearLayout.addCategory(title: String, callback: LinearLayout.() -> Unit) {
|
||||
val text = TextView(context)
|
||||
text.typeface = TypefaceExtra.medium
|
||||
text.setTextSizeScaled(14)
|
||||
text.setTextColor(text.context.getColorFromAttr(android.R.attr.colorAccent))
|
||||
text.text = title
|
||||
resources.sizeScaled(16).let { text.setPadding(it, it, it, 0) }
|
||||
addView(text, LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT)
|
||||
callback()
|
||||
}
|
||||
|
||||
private fun <T> LinearLayout.addPreference(key: Preferences.Key<T>, title: String,
|
||||
summaryProvider: () -> String, dialogProvider: ((Context) -> AlertDialog)?): Preference<T> {
|
||||
val preference = Preference(key, this@PreferencesFragment, this, title, summaryProvider, dialogProvider)
|
||||
preferences[key] = preference
|
||||
return preference
|
||||
}
|
||||
|
||||
private fun LinearLayout.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> LinearLayout.addEdit(key: Preferences.Key<T>, title: String, valueToString: (T) -> String,
|
||||
stringToValue: (String) -> T?, configureEdit: (EditText) -> Unit) {
|
||||
addPreference(key, title, { valueToString(Preferences[key]) }) {
|
||||
val scroll = ScrollView(it)
|
||||
scroll.resources.sizeScaled(20).let { scroll.setPadding(it, 0, it, 0) }
|
||||
val edit = EditText(it)
|
||||
configureEdit(edit)
|
||||
edit.id = android.R.id.edit
|
||||
edit.setTextSizeScaled(16)
|
||||
edit.resources.sizeScaled(16).let { edit.setPadding(edit.paddingLeft, it, edit.paddingRight, it) }
|
||||
edit.setText(valueToString(Preferences[key]))
|
||||
edit.hint = edit.text.toString()
|
||||
edit.setSelection(edit.text.length)
|
||||
edit.requestFocus()
|
||||
scroll.addView(edit, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
|
||||
AlertDialog.Builder(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 LinearLayout.addEditString(key: Preferences.Key<String>, title: String) {
|
||||
addEdit(key, title, { it }, { it }, { })
|
||||
}
|
||||
|
||||
private fun LinearLayout.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>> LinearLayout
|
||||
.addEnumeration(key: Preferences.Key<T>, title: String, valueToString: (T) -> String) {
|
||||
addPreference(key, title, { valueToString(Preferences[key]) }) {
|
||||
val values = key.default.value.values
|
||||
AlertDialog.Builder(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<TextView>(R.id.title)!!
|
||||
val summary = view.findViewById<TextView>(R.id.summary)!!
|
||||
val check = view.findViewById<Switch>(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 PreferencesFragment).preferences
|
||||
val key = requireArguments().getString(EXTRA_KEY)!!
|
||||
.let { name -> preferences.keys.find { it.name == name }!! }
|
||||
val preference = preferences[key]!!
|
||||
return preference.createDialog(requireContext())
|
||||
}
|
||||
}
|
||||
}
|
1305
src/main/kotlin/com/looker/droidify/screen/ProductAdapter.kt
Normal file
1305
src/main/kotlin/com/looker/droidify/screen/ProductAdapter.kt
Normal file
File diff suppressed because it is too large
Load Diff
475
src/main/kotlin/com/looker/droidify/screen/ProductFragment.kt
Normal file
475
src/main/kotlin/com/looker/droidify/screen/ProductFragment.kt
Normal file
@ -0,0 +1,475 @@
|
||||
package com.looker.droidify.screen
|
||||
|
||||
import android.app.AlertDialog
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.ComponentName
|
||||
import android.content.Intent
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.provider.Settings
|
||||
import android.view.LayoutInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.Toolbar
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
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 com.looker.droidify.R
|
||||
import com.looker.droidify.content.ProductPreferences
|
||||
import com.looker.droidify.database.Database
|
||||
import com.looker.droidify.entity.InstalledItem
|
||||
import com.looker.droidify.entity.Product
|
||||
import com.looker.droidify.entity.ProductPreference
|
||||
import com.looker.droidify.entity.Release
|
||||
import com.looker.droidify.entity.Repository
|
||||
import com.looker.droidify.service.Connection
|
||||
import com.looker.droidify.service.DownloadService
|
||||
import com.looker.droidify.utility.RxUtils
|
||||
import com.looker.droidify.utility.Utils
|
||||
import com.looker.droidify.utility.extension.android.*
|
||||
import com.looker.droidify.widget.DividerItemDecoration
|
||||
|
||||
class ProductFragment(): ScreenFragment(), ProductAdapter.Callbacks {
|
||||
companion object {
|
||||
private const val EXTRA_PACKAGE_NAME = "packageName"
|
||||
|
||||
private const val STATE_LAYOUT_MANAGER = "layoutManager"
|
||||
private const val STATE_ADAPTER = "adapter"
|
||||
}
|
||||
|
||||
constructor(packageName: String): this() {
|
||||
arguments = Bundle().apply {
|
||||
putString(EXTRA_PACKAGE_NAME, packageName)
|
||||
}
|
||||
}
|
||||
|
||||
private class Nullable<T>(val value: T?)
|
||||
|
||||
private enum class Action(val id: Int, val adapterAction: ProductAdapter.Action, val iconResId: Int) {
|
||||
INSTALL(1, ProductAdapter.Action.INSTALL, R.drawable.ic_archive),
|
||||
UPDATE(2, ProductAdapter.Action.UPDATE, R.drawable.ic_archive),
|
||||
LAUNCH(3, ProductAdapter.Action.LAUNCH, R.drawable.ic_launch),
|
||||
DETAILS(4, ProductAdapter.Action.DETAILS, R.drawable.ic_tune),
|
||||
UNINSTALL(5, ProductAdapter.Action.UNINSTALL, R.drawable.ic_delete)
|
||||
}
|
||||
|
||||
private class Installed(val installedItem: InstalledItem, val isSystem: Boolean,
|
||||
val launcherActivities: List<Pair<String, String>>)
|
||||
|
||||
val packageName: String
|
||||
get() = requireArguments().getString(EXTRA_PACKAGE_NAME)!!
|
||||
|
||||
private var layoutManagerState: LinearLayoutManager.SavedState? = null
|
||||
|
||||
private var actions = Pair(emptySet<Action>(), null as Action?)
|
||||
private var products = emptyList<Pair<Product, Repository>>()
|
||||
private var installed: Installed? = null
|
||||
private var downloading = false
|
||||
|
||||
private var toolbar: Toolbar? = null
|
||||
private var recyclerView: RecyclerView? = null
|
||||
|
||||
private var productDisposable: Disposable? = null
|
||||
private var downloadDisposable: Disposable? = null
|
||||
private val downloadConnection = Connection(DownloadService::class.java, onBind = { _, binder ->
|
||||
updateDownloadState(binder.getState(packageName))
|
||||
downloadDisposable = binder.events(packageName).subscribe { updateDownloadState(it) }
|
||||
}, onUnbind = { _, _ ->
|
||||
downloadDisposable?.dispose()
|
||||
downloadDisposable = null
|
||||
})
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
return inflater.inflate(R.layout.fragment, container, false)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
val toolbar = view.findViewById<Toolbar>(R.id.toolbar)!!
|
||||
screenActivity.onToolbarCreated(toolbar)
|
||||
toolbar.setTitle(R.string.application)
|
||||
this.toolbar = toolbar
|
||||
|
||||
toolbar.menu.apply {
|
||||
for (action in Action.values()) {
|
||||
add(0, action.id, 0, action.adapterAction.titleResId)
|
||||
.setIcon(Utils.getToolbarIcon(toolbar.context, action.iconResId))
|
||||
.setVisible(false)
|
||||
.setShowAsActionFlags(MenuItem.SHOW_AS_ACTION_ALWAYS)
|
||||
.setOnMenuItemClickListener {
|
||||
onActionClick(action.adapterAction)
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val content = view.findViewById<FrameLayout>(R.id.fragment_content)!!
|
||||
content.addView(RecyclerView(content.context).apply {
|
||||
id = android.R.id.list
|
||||
val columns = (resources.configuration.screenWidthDp / 120).coerceIn(3, 5)
|
||||
val layoutManager = GridLayoutManager(context, columns)
|
||||
this.layoutManager = layoutManager
|
||||
isMotionEventSplittingEnabled = false
|
||||
isVerticalScrollBarEnabled = false
|
||||
val adapter = ProductAdapter(this@ProductFragment, columns)
|
||||
this.adapter = adapter
|
||||
layoutManager.spanSizeLookup = object: GridLayoutManager.SpanSizeLookup() {
|
||||
override fun getSpanSize(position: Int): Int {
|
||||
return if (adapter.requiresGrid(position)) 1 else layoutManager.spanCount
|
||||
}
|
||||
}
|
||||
addOnScrollListener(scrollListener)
|
||||
addItemDecoration(adapter.gridItemDecoration)
|
||||
addItemDecoration(DividerItemDecoration(context, adapter::configureDivider))
|
||||
savedInstanceState?.getParcelable<ProductAdapter.SavedState>(STATE_ADAPTER)?.let(adapter::restoreState)
|
||||
layoutManagerState = savedInstanceState?.getParcelable(STATE_LAYOUT_MANAGER)
|
||||
recyclerView = this
|
||||
}, FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)
|
||||
|
||||
var first = true
|
||||
productDisposable = Observable.just(Unit)
|
||||
.concatWith(Database.observable(Database.Subject.Products))
|
||||
.observeOn(Schedulers.io())
|
||||
.flatMapSingle { RxUtils.querySingle { Database.ProductAdapter.get(packageName, it) } }
|
||||
.flatMapSingle { products -> RxUtils
|
||||
.querySingle { Database.RepositoryAdapter.getAll(it) }
|
||||
.map { it.asSequence().map { Pair(it.id, it) }.toMap()
|
||||
.let { products.mapNotNull { product -> it[product.repositoryId]?.let { Pair(product, it) } } } } }
|
||||
.flatMapSingle { products -> RxUtils
|
||||
.querySingle { Nullable(Database.InstalledAdapter.get(packageName, it)) }
|
||||
.map { Pair(products, it) } }
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe {
|
||||
val (products, installedItem) = it
|
||||
val firstChanged = first
|
||||
first = false
|
||||
val productChanged = this.products != products
|
||||
val installedItemChanged = this.installed?.installedItem != installedItem.value
|
||||
if (firstChanged || productChanged || installedItemChanged) {
|
||||
layoutManagerState?.let { recyclerView?.layoutManager!!.onRestoreInstanceState(it) }
|
||||
layoutManagerState = null
|
||||
if (firstChanged || productChanged) {
|
||||
this.products = products
|
||||
}
|
||||
if (firstChanged || installedItemChanged) {
|
||||
installed = installedItem.value?.let {
|
||||
val isSystem = try {
|
||||
((requireContext().packageManager.getApplicationInfo(packageName, 0).flags)
|
||||
and ApplicationInfo.FLAG_SYSTEM) != 0
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
val launcherActivities = if (packageName == requireContext().packageName) {
|
||||
// Don't allow to launch self
|
||||
emptyList()
|
||||
} else {
|
||||
val packageManager = requireContext().packageManager
|
||||
packageManager
|
||||
.queryIntentActivities(Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER), 0)
|
||||
.asSequence().mapNotNull { it.activityInfo }.filter { it.packageName == packageName }
|
||||
.mapNotNull { activityInfo ->
|
||||
val label = try {
|
||||
activityInfo.loadLabel(packageManager).toString()
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
label?.let { Pair(activityInfo.name, it) }
|
||||
}
|
||||
.toList()
|
||||
}
|
||||
Installed(it, isSystem, launcherActivities)
|
||||
}
|
||||
}
|
||||
val recyclerView = recyclerView!!
|
||||
val adapter = recyclerView.adapter as ProductAdapter
|
||||
if (firstChanged || productChanged || installedItemChanged) {
|
||||
adapter.setProducts(recyclerView.context, packageName, products, installedItem.value)
|
||||
}
|
||||
updateButtons()
|
||||
}
|
||||
}
|
||||
|
||||
downloadConnection.bind(requireContext())
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
|
||||
toolbar = null
|
||||
recyclerView = null
|
||||
|
||||
productDisposable?.dispose()
|
||||
productDisposable = null
|
||||
downloadDisposable?.dispose()
|
||||
downloadDisposable = null
|
||||
downloadConnection.unbind(requireContext())
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
|
||||
val layoutManagerState = layoutManagerState ?: recyclerView?.layoutManager?.onSaveInstanceState()
|
||||
layoutManagerState?.let { outState.putParcelable(STATE_LAYOUT_MANAGER, it) }
|
||||
val adapterState = (recyclerView?.adapter as? ProductAdapter)?.saveState()
|
||||
adapterState?.let { outState.putParcelable(STATE_ADAPTER, it) }
|
||||
}
|
||||
|
||||
private fun updateButtons() {
|
||||
updateButtons(ProductPreferences[packageName])
|
||||
}
|
||||
|
||||
private fun updateButtons(preference: ProductPreference) {
|
||||
val installed = installed
|
||||
val product = Product.findSuggested(products, installed?.installedItem) { it.first }?.first
|
||||
val compatible = product != null && product.selectedReleases.firstOrNull()
|
||||
.let { it != null && it.incompatibilities.isEmpty() }
|
||||
val canInstall = product != null && installed == null && compatible
|
||||
val canUpdate = product != null && compatible && product.canUpdate(installed?.installedItem) &&
|
||||
!preference.shouldIgnoreUpdate(product.versionCode)
|
||||
val canUninstall = product != null && installed != null && !installed.isSystem
|
||||
val canLaunch = product != null && installed != null && installed.launcherActivities.isNotEmpty()
|
||||
|
||||
val actions = mutableSetOf<Action>()
|
||||
if (canInstall) {
|
||||
actions += Action.INSTALL
|
||||
}
|
||||
if (canUpdate) {
|
||||
actions += Action.UPDATE
|
||||
}
|
||||
if (canLaunch) {
|
||||
actions += Action.LAUNCH
|
||||
}
|
||||
if (installed != null) {
|
||||
actions += Action.DETAILS
|
||||
}
|
||||
if (canUninstall) {
|
||||
actions += Action.UNINSTALL
|
||||
}
|
||||
val primaryAction = when {
|
||||
canUpdate -> Action.UPDATE
|
||||
canLaunch -> Action.LAUNCH
|
||||
canInstall -> Action.INSTALL
|
||||
installed != null -> Action.DETAILS
|
||||
else -> null
|
||||
}
|
||||
|
||||
val adapterAction = if (downloading) ProductAdapter.Action.CANCEL else primaryAction?.adapterAction
|
||||
(recyclerView?.adapter as? ProductAdapter)?.setAction(adapterAction)
|
||||
|
||||
val toolbar = toolbar
|
||||
if (toolbar != null) {
|
||||
for (action in sequenceOf(Action.INSTALL, Action.UPDATE, Action.UNINSTALL)) {
|
||||
toolbar.menu.findItem(action.id).isEnabled = !downloading
|
||||
}
|
||||
}
|
||||
this.actions = Pair(actions, primaryAction)
|
||||
updateToolbarButtons()
|
||||
}
|
||||
|
||||
private fun updateToolbarButtons() {
|
||||
val (actions, primaryAction) = actions
|
||||
val showPrimaryAction = recyclerView
|
||||
?.let { (it.layoutManager as LinearLayoutManager).findFirstVisibleItemPosition() != 0 } == true
|
||||
val displayActions = actions.toMutableSet()
|
||||
if (!showPrimaryAction && primaryAction != null) {
|
||||
displayActions -= primaryAction
|
||||
}
|
||||
if (displayActions.size >= 4 && resources.configuration.screenWidthDp < 400) {
|
||||
displayActions -= Action.DETAILS
|
||||
}
|
||||
val toolbar = toolbar
|
||||
if (toolbar != null) {
|
||||
for (action in Action.values()) {
|
||||
toolbar.menu.findItem(action.id).isVisible = action in displayActions
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateDownloadState(state: DownloadService.State?) {
|
||||
val status = when (state) {
|
||||
is DownloadService.State.Pending -> ProductAdapter.Status.Pending
|
||||
is DownloadService.State.Connecting -> ProductAdapter.Status.Connecting
|
||||
is DownloadService.State.Downloading -> ProductAdapter.Status.Downloading(state.read, state.total)
|
||||
is DownloadService.State.Success, is DownloadService.State.Error, is DownloadService.State.Cancel, null -> null
|
||||
}
|
||||
val downloading = status != null
|
||||
if (this.downloading != downloading) {
|
||||
this.downloading = downloading
|
||||
updateButtons()
|
||||
}
|
||||
(recyclerView?.adapter as? ProductAdapter)?.setStatus(status)
|
||||
if (state is DownloadService.State.Success && isResumed) {
|
||||
state.consume()
|
||||
screenActivity.startPackageInstaller(state.release.cacheFileName)
|
||||
}
|
||||
}
|
||||
|
||||
private val scrollListener = object: RecyclerView.OnScrollListener() {
|
||||
private var lastPosition = -1
|
||||
|
||||
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
|
||||
val position = (recyclerView.layoutManager as LinearLayoutManager).findFirstVisibleItemPosition()
|
||||
val lastPosition = lastPosition
|
||||
this.lastPosition = position
|
||||
if ((lastPosition == 0) != (position == 0)) {
|
||||
updateToolbarButtons()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActionClick(action: ProductAdapter.Action) {
|
||||
when (action) {
|
||||
ProductAdapter.Action.INSTALL,
|
||||
ProductAdapter.Action.UPDATE -> {
|
||||
val installedItem = installed?.installedItem
|
||||
val productRepository = Product.findSuggested(products, installedItem) { it.first }
|
||||
val compatibleReleases = productRepository?.first?.selectedReleases.orEmpty()
|
||||
.filter { installedItem == null || installedItem.signature == it.signature }
|
||||
val release = if (compatibleReleases.size >= 2) {
|
||||
compatibleReleases
|
||||
.filter { it.platforms.contains(Android.primaryPlatform) }
|
||||
.minBy { it.platforms.size }
|
||||
?: compatibleReleases.minBy { it.platforms.size }
|
||||
?: compatibleReleases.firstOrNull()
|
||||
} else {
|
||||
compatibleReleases.firstOrNull()
|
||||
}
|
||||
val binder = downloadConnection.binder
|
||||
if (productRepository != null && release != null && binder != null) {
|
||||
binder.enqueue(packageName, productRepository.first.name, productRepository.second, release)
|
||||
}
|
||||
Unit
|
||||
}
|
||||
ProductAdapter.Action.LAUNCH -> {
|
||||
val launcherActivities = installed?.launcherActivities.orEmpty()
|
||||
if (launcherActivities.size >= 2) {
|
||||
LaunchDialog(launcherActivities).show(childFragmentManager, LaunchDialog::class.java.name)
|
||||
} else {
|
||||
launcherActivities.firstOrNull()?.let { startLauncherActivity(it.first) }
|
||||
}
|
||||
Unit
|
||||
}
|
||||
ProductAdapter.Action.DETAILS -> {
|
||||
startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
|
||||
.setData(Uri.parse("package:$packageName")))
|
||||
}
|
||||
ProductAdapter.Action.UNINSTALL -> {
|
||||
// TODO Handle deprecation
|
||||
@Suppress("DEPRECATION")
|
||||
startActivity(Intent(Intent.ACTION_UNINSTALL_PACKAGE)
|
||||
.setData(Uri.parse("package:$packageName")))
|
||||
}
|
||||
ProductAdapter.Action.CANCEL -> {
|
||||
val binder = downloadConnection.binder
|
||||
if (downloading && binder != null) {
|
||||
binder.cancel(packageName)
|
||||
}
|
||||
Unit
|
||||
}
|
||||
}::class
|
||||
}
|
||||
|
||||
private fun startLauncherActivity(name: String) {
|
||||
try {
|
||||
startActivity(Intent(Intent.ACTION_MAIN)
|
||||
.addCategory(Intent.CATEGORY_LAUNCHER)
|
||||
.setComponent(ComponentName(packageName, name))
|
||||
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPreferenceChanged(preference: ProductPreference) {
|
||||
updateButtons(preference)
|
||||
}
|
||||
|
||||
override fun onPermissionsClick(group: String?, permissions: List<String>) {
|
||||
MessageDialog(MessageDialog.Message.Permissions(group, permissions)).show(childFragmentManager)
|
||||
}
|
||||
|
||||
override fun onScreenshotClick(screenshot: Product.Screenshot) {
|
||||
val pair = products.asSequence()
|
||||
.map { Pair(it.second, it.first.screenshots.find { it === screenshot }?.identifier) }
|
||||
.filter { it.second != null }.firstOrNull()
|
||||
if (pair != null) {
|
||||
val (repository, identifier) = pair
|
||||
if (identifier != null) {
|
||||
ScreenshotsFragment(packageName, repository.id, identifier).show(childFragmentManager)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onReleaseClick(release: Release) {
|
||||
val installedItem = installed?.installedItem
|
||||
when {
|
||||
release.incompatibilities.isNotEmpty() -> {
|
||||
MessageDialog(MessageDialog.Message.ReleaseIncompatible(release.incompatibilities,
|
||||
release.platforms, release.minSdkVersion, release.maxSdkVersion)).show(childFragmentManager)
|
||||
}
|
||||
installedItem != null && installedItem.versionCode > release.versionCode -> {
|
||||
MessageDialog(MessageDialog.Message.ReleaseOlder).show(childFragmentManager)
|
||||
}
|
||||
installedItem != null && installedItem.signature != release.signature -> {
|
||||
MessageDialog(MessageDialog.Message.ReleaseSignatureMismatch).show(childFragmentManager)
|
||||
}
|
||||
else -> {
|
||||
val productRepository = products.asSequence().filter { it.first.releases.any { it === release } }.firstOrNull()
|
||||
if (productRepository != null) {
|
||||
downloadConnection.binder?.enqueue(packageName, productRepository.first.name,
|
||||
productRepository.second, release)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onUriClick(uri: Uri, shouldConfirm: Boolean): Boolean {
|
||||
return if (shouldConfirm && (uri.scheme == "http" || uri.scheme == "https")) {
|
||||
MessageDialog(MessageDialog.Message.Link(uri)).show(childFragmentManager)
|
||||
true
|
||||
} else {
|
||||
try {
|
||||
startActivity(Intent(Intent.ACTION_VIEW, uri))
|
||||
true
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
e.printStackTrace()
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class LaunchDialog(): DialogFragment() {
|
||||
companion object {
|
||||
private const val EXTRA_NAMES = "names"
|
||||
private const val EXTRA_LABELS = "labels"
|
||||
}
|
||||
|
||||
constructor(launcherActivities: List<Pair<String, String>>): this() {
|
||||
arguments = Bundle().apply {
|
||||
putStringArrayList(EXTRA_NAMES, ArrayList(launcherActivities.map { it.first }))
|
||||
putStringArrayList(EXTRA_LABELS, ArrayList(launcherActivities.map { it.second }))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): AlertDialog {
|
||||
val names = requireArguments().getStringArrayList(EXTRA_NAMES)!!
|
||||
val labels = requireArguments().getStringArrayList(EXTRA_LABELS)!!
|
||||
return AlertDialog.Builder(requireContext())
|
||||
.setTitle(R.string.launch)
|
||||
.setItems(labels.toTypedArray()) { _, position -> (parentFragment as ProductFragment)
|
||||
.startLauncherActivity(names[position]) }
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.create()
|
||||
}
|
||||
}
|
||||
}
|
184
src/main/kotlin/com/looker/droidify/screen/ProductsAdapter.kt
Normal file
184
src/main/kotlin/com/looker/droidify/screen/ProductsAdapter.kt
Normal file
@ -0,0 +1,184 @@
|
||||
package com.looker.droidify.screen
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageView
|
||||
import android.widget.ProgressBar
|
||||
import android.widget.TextView
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.looker.droidify.R
|
||||
import com.looker.droidify.database.Database
|
||||
import com.looker.droidify.entity.ProductItem
|
||||
import com.looker.droidify.entity.Repository
|
||||
import com.looker.droidify.network.PicassoDownloader
|
||||
import com.looker.droidify.utility.Utils
|
||||
import com.looker.droidify.utility.extension.resources.*
|
||||
import com.looker.droidify.utility.extension.text.*
|
||||
import com.looker.droidify.widget.CursorRecyclerAdapter
|
||||
import com.looker.droidify.widget.DividerItemDecoration
|
||||
|
||||
class ProductsAdapter(private val onClick: (ProductItem) -> Unit):
|
||||
CursorRecyclerAdapter<ProductsAdapter.ViewType, RecyclerView.ViewHolder>() {
|
||||
enum class ViewType { PRODUCT, LOADING, EMPTY }
|
||||
|
||||
private class ProductViewHolder(itemView: View): RecyclerView.ViewHolder(itemView) {
|
||||
val name = itemView.findViewById<TextView>(R.id.name)!!
|
||||
val status = itemView.findViewById<TextView>(R.id.status)!!
|
||||
val summary = itemView.findViewById<TextView>(R.id.summary)!!
|
||||
val icon = itemView.findViewById<ImageView>(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(FrameLayout(context)) {
|
||||
init {
|
||||
itemView as FrameLayout
|
||||
val progressBar = ProgressBar(itemView.context)
|
||||
itemView.addView(progressBar, FrameLayout.LayoutParams(FrameLayout.LayoutParams.WRAP_CONTENT,
|
||||
FrameLayout.LayoutParams.WRAP_CONTENT).apply { gravity = Gravity.CENTER })
|
||||
itemView.layoutParams = RecyclerView.LayoutParams(RecyclerView.LayoutParams.MATCH_PARENT,
|
||||
RecyclerView.LayoutParams.MATCH_PARENT)
|
||||
}
|
||||
}
|
||||
|
||||
private class EmptyViewHolder(context: Context): RecyclerView.ViewHolder(TextView(context)) {
|
||||
val text: TextView
|
||||
get() = itemView as TextView
|
||||
|
||||
init {
|
||||
itemView as TextView
|
||||
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.textColorPrimary))
|
||||
itemView.setTextSizeScaled(20)
|
||||
itemView.layoutParams = RecyclerView.LayoutParams(RecyclerView.LayoutParams.MATCH_PARENT,
|
||||
RecyclerView.LayoutParams.MATCH_PARENT)
|
||||
}
|
||||
}
|
||||
|
||||
fun configureDivider(context: Context, position: Int, configuration: DividerItemDecoration.Configuration) {
|
||||
val currentItem = if (getItemEnumViewType(position) == ViewType.PRODUCT) getProductItem(position) else null
|
||||
val nextItem = if (position + 1 < itemCount && getItemEnumViewType(position + 1) == ViewType.PRODUCT)
|
||||
getProductItem(position + 1) else null
|
||||
when {
|
||||
currentItem != null && nextItem != null && currentItem.matchRank != nextItem.matchRank -> {
|
||||
configuration.set(true, false, 0, 0)
|
||||
}
|
||||
else -> {
|
||||
configuration.set(true, false, context.resources.sizeScaled(72), 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 Database.ProductAdapter.transformItem(moveTo(position))
|
||||
}
|
||||
|
||||
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 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]
|
||||
if ((productItem.icon.isNotEmpty() || productItem.metadataIcon.isNotEmpty()) && repository != null) {
|
||||
holder.icon.load(PicassoDownloader.createIconUri(holder.icon, productItem.packageName,
|
||||
productItem.icon, productItem.metadataIcon, repository)) {
|
||||
placeholder(holder.progressIcon)
|
||||
error(holder.defaultIcon)
|
||||
}
|
||||
} else {
|
||||
holder.icon.clear()
|
||||
holder.icon.setImageDrawable(holder.defaultIcon)
|
||||
}
|
||||
holder.status.apply {
|
||||
if (productItem.canUpdate) {
|
||||
text = productItem.version
|
||||
if (background == null) {
|
||||
resources.sizeScaled(4).let { setPadding(it, 0, it, 0) }
|
||||
setTextColor(holder.status.context.getColorFromAttr(android.R.attr.colorBackground))
|
||||
background = GradientDrawable(GradientDrawable.Orientation.TOP_BOTTOM, null).apply {
|
||||
color = holder.status.context.getColorFromAttr(android.R.attr.colorAccent)
|
||||
cornerRadius = holder.status.resources.sizeScaled(2).toFloat()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
text = productItem.installedVersion.nullIfEmpty() ?: productItem.version
|
||||
if (background != null) {
|
||||
setPadding(0, 0, 0, 0)
|
||||
setTextColor(holder.status.context.getColorFromAttr(android.R.attr.textColorPrimary))
|
||||
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
|
||||
}
|
||||
}
|
181
src/main/kotlin/com/looker/droidify/screen/ProductsFragment.kt
Normal file
181
src/main/kotlin/com/looker/droidify/screen/ProductsFragment.kt
Normal file
@ -0,0 +1,181 @@
|
||||
package com.looker.droidify.screen
|
||||
|
||||
import android.database.Cursor
|
||||
import android.os.Bundle
|
||||
import android.os.Parcelable
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
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 com.looker.droidify.R
|
||||
import com.looker.droidify.database.CursorOwner
|
||||
import com.looker.droidify.database.Database
|
||||
import com.looker.droidify.entity.ProductItem
|
||||
import com.looker.droidify.utility.RxUtils
|
||||
import com.looker.droidify.widget.DividerItemDecoration
|
||||
import com.looker.droidify.widget.RecyclerFastScroller
|
||||
|
||||
class ProductsFragment(): ScreenFragment(), CursorOwner.Callback {
|
||||
companion object {
|
||||
private const val EXTRA_SOURCE = "source"
|
||||
|
||||
private const val STATE_CURRENT_SEARCH_QUERY = "currentSearchQuery"
|
||||
private const val STATE_CURRENT_SECTION = "currentSection"
|
||||
private const val STATE_CURRENT_ORDER = "currentOrder"
|
||||
private const val STATE_LAYOUT_MANAGER = "layoutManager"
|
||||
}
|
||||
|
||||
enum class Source(val titleResId: Int, val sections: Boolean, val order: Boolean) {
|
||||
AVAILABLE(R.string.available, true, true),
|
||||
INSTALLED(R.string.installed, false, false),
|
||||
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 searchQuery = ""
|
||||
private var section: ProductItem.Section = ProductItem.Section.All
|
||||
private var order = ProductItem.Order.NAME
|
||||
|
||||
private var currentSearchQuery = ""
|
||||
private var currentSection: ProductItem.Section = ProductItem.Section.All
|
||||
private var currentOrder = ProductItem.Order.NAME
|
||||
private var layoutManagerState: Parcelable? = null
|
||||
|
||||
private var recyclerView: RecyclerView? = null
|
||||
|
||||
private var repositoriesDisposable: Disposable? = null
|
||||
|
||||
private val request: CursorOwner.Request
|
||||
get() {
|
||||
val searchQuery = searchQuery
|
||||
val section = if (source.sections) section else ProductItem.Section.All
|
||||
val order = if (source.order) order else ProductItem.Order.NAME
|
||||
return when (source) {
|
||||
Source.AVAILABLE -> CursorOwner.Request.ProductsAvailable(searchQuery, section, order)
|
||||
Source.INSTALLED -> CursorOwner.Request.ProductsInstalled(searchQuery, section, order)
|
||||
Source.UPDATES -> CursorOwner.Request.ProductsUpdates(searchQuery, section, order)
|
||||
}
|
||||
}
|
||||
|
||||
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(ProductsAdapter.ViewType.PRODUCT.ordinal, 30)
|
||||
val adapter = ProductsAdapter { screenActivity.navigateProduct(it.packageName) }
|
||||
this.adapter = adapter
|
||||
addItemDecoration(DividerItemDecoration(context, adapter::configureDivider))
|
||||
RecyclerFastScroller(this)
|
||||
recyclerView = this
|
||||
}
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
currentSearchQuery = savedInstanceState?.getString(STATE_CURRENT_SEARCH_QUERY).orEmpty()
|
||||
currentSection = savedInstanceState?.getParcelable(STATE_CURRENT_SECTION) ?: ProductItem.Section.All
|
||||
currentOrder = savedInstanceState?.getString(STATE_CURRENT_ORDER)
|
||||
?.let(ProductItem.Order::valueOf) ?: ProductItem.Order.NAME
|
||||
layoutManagerState = savedInstanceState?.getParcelable(STATE_LAYOUT_MANAGER)
|
||||
|
||||
screenActivity.cursorOwner.attach(this, request)
|
||||
repositoriesDisposable = Observable.just(Unit)
|
||||
.concatWith(Database.observable(Database.Subject.Repositories))
|
||||
.observeOn(Schedulers.io())
|
||||
.flatMapSingle { RxUtils.querySingle { Database.RepositoryAdapter.getAll(it) } }
|
||||
.map { it.asSequence().map { Pair(it.id, it) }.toMap() }
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe { (recyclerView?.adapter as? ProductsAdapter)?.repositories = it }
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
|
||||
recyclerView = null
|
||||
|
||||
screenActivity.cursorOwner.detach(this)
|
||||
repositoriesDisposable?.dispose()
|
||||
repositoriesDisposable = null
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
|
||||
outState.putString(STATE_CURRENT_SEARCH_QUERY, currentSearchQuery)
|
||||
outState.putParcelable(STATE_CURRENT_SECTION, currentSection)
|
||||
outState.putString(STATE_CURRENT_ORDER, currentOrder.name)
|
||||
(layoutManagerState ?: recyclerView?.layoutManager?.onSaveInstanceState())
|
||||
?.let { outState.putParcelable(STATE_LAYOUT_MANAGER, it) }
|
||||
}
|
||||
|
||||
override fun onCursorData(request: CursorOwner.Request, cursor: Cursor?) {
|
||||
(recyclerView?.adapter as? ProductsAdapter)?.apply {
|
||||
this.cursor = cursor
|
||||
emptyText = when {
|
||||
cursor == null -> ""
|
||||
searchQuery.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
layoutManagerState?.let {
|
||||
layoutManagerState = null
|
||||
recyclerView?.layoutManager?.onRestoreInstanceState(it)
|
||||
}
|
||||
|
||||
if (currentSearchQuery != searchQuery || currentSection != section || currentOrder != order) {
|
||||
currentSearchQuery = searchQuery
|
||||
currentSection = section
|
||||
currentOrder = order
|
||||
recyclerView?.scrollToPosition(0)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun setSearchQuery(searchQuery: String) {
|
||||
if (this.searchQuery != searchQuery) {
|
||||
this.searchQuery = searchQuery
|
||||
if (view != null) {
|
||||
screenActivity.cursorOwner.attach(this, request)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun setSection(section: ProductItem.Section) {
|
||||
if (this.section != section) {
|
||||
this.section = section
|
||||
if (view != null) {
|
||||
screenActivity.cursorOwner.attach(this, request)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun setOrder(order: ProductItem.Order) {
|
||||
if (this.order != order) {
|
||||
this.order = order
|
||||
if (view != null) {
|
||||
screenActivity.cursorOwner.attach(this, request)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,61 @@
|
||||
package com.looker.droidify.screen
|
||||
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Switch
|
||||
import android.widget.TextView
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.looker.droidify.R
|
||||
import com.looker.droidify.database.Database
|
||||
import com.looker.droidify.entity.Repository
|
||||
import com.looker.droidify.utility.extension.resources.*
|
||||
import com.looker.droidify.widget.CursorRecyclerAdapter
|
||||
|
||||
class RepositoriesAdapter(private val onClick: (Repository) -> Unit,
|
||||
private val onSwitch: (repository: Repository, isEnabled: Boolean) -> Boolean):
|
||||
CursorRecyclerAdapter<RepositoriesAdapter.ViewType, RecyclerView.ViewHolder>() {
|
||||
enum class ViewType { REPOSITORY }
|
||||
|
||||
private class ViewHolder(itemView: View): RecyclerView.ViewHolder(itemView) {
|
||||
val name = itemView.findViewById<TextView>(R.id.name)!!
|
||||
val enabled = itemView.findViewById<Switch>(R.id.enabled)!!
|
||||
|
||||
var listenSwitch = true
|
||||
}
|
||||
|
||||
override val viewTypeClass: Class<ViewType>
|
||||
get() = ViewType::class.java
|
||||
|
||||
override fun getItemEnumViewType(position: Int): ViewType {
|
||||
return ViewType.REPOSITORY
|
||||
}
|
||||
|
||||
private fun getRepository(position: Int): Repository {
|
||||
return Database.RepositoryAdapter.transform(moveTo(position))
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: ViewType): RecyclerView.ViewHolder {
|
||||
return ViewHolder(parent.inflate(R.layout.repository_item)).apply {
|
||||
itemView.setOnClickListener { onClick(getRepository(adapterPosition)) }
|
||||
enabled.setOnCheckedChangeListener { _, isChecked ->
|
||||
if (listenSwitch) {
|
||||
if (!onSwitch(getRepository(adapterPosition), isChecked)) {
|
||||
listenSwitch = false
|
||||
enabled.isChecked = !isChecked
|
||||
listenSwitch = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
holder as ViewHolder
|
||||
val repository = getRepository(position)
|
||||
val lastListenSwitch = holder.listenSwitch
|
||||
holder.listenSwitch = false
|
||||
holder.enabled.isChecked = repository.enabled
|
||||
holder.listenSwitch = lastListenSwitch
|
||||
holder.name.text = repository.name
|
||||
}
|
||||
}
|
@ -0,0 +1,73 @@
|
||||
package com.looker.droidify.screen
|
||||
|
||||
import android.database.Cursor
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.Toolbar
|
||||
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.service.Connection
|
||||
import com.looker.droidify.service.SyncService
|
||||
import com.looker.droidify.utility.Utils
|
||||
|
||||
class RepositoriesFragment: ScreenFragment(), CursorOwner.Callback {
|
||||
private var recyclerView: RecyclerView? = null
|
||||
|
||||
private val syncConnection = Connection(SyncService::class.java)
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
return inflater.inflate(R.layout.fragment, container, false).apply {
|
||||
val content = findViewById<FrameLayout>(R.id.fragment_content)!!
|
||||
content.addView(RecyclerView(content.context).apply {
|
||||
id = android.R.id.list
|
||||
layoutManager = LinearLayoutManager(context)
|
||||
isMotionEventSplittingEnabled = false
|
||||
setHasFixedSize(true)
|
||||
adapter = RepositoriesAdapter({ screenActivity.navigateRepository(it.id) },
|
||||
{ repository, isEnabled -> repository.enabled != isEnabled &&
|
||||
syncConnection.binder?.setEnabled(repository, isEnabled) == true })
|
||||
recyclerView = this
|
||||
}, FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
syncConnection.bind(requireContext())
|
||||
screenActivity.cursorOwner.attach(this, CursorOwner.Request.Repositories)
|
||||
|
||||
val toolbar = view.findViewById<Toolbar>(R.id.toolbar)!!
|
||||
screenActivity.onToolbarCreated(toolbar)
|
||||
toolbar.setTitle(R.string.repositories)
|
||||
|
||||
toolbar.menu.apply {
|
||||
add(R.string.add_repository)
|
||||
.setIcon(Utils.getToolbarIcon(toolbar.context, R.drawable.ic_add))
|
||||
.setShowAsActionFlags(MenuItem.SHOW_AS_ACTION_ALWAYS)
|
||||
.setOnMenuItemClickListener {
|
||||
view.post { screenActivity.navigateAddRepository() }
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
|
||||
recyclerView = null
|
||||
|
||||
syncConnection.unbind(requireContext())
|
||||
screenActivity.cursorOwner.detach(this)
|
||||
}
|
||||
|
||||
override fun onCursorData(request: CursorOwner.Request, cursor: Cursor?) {
|
||||
(recyclerView?.adapter as? RepositoriesAdapter)?.cursor = cursor
|
||||
}
|
||||
}
|
166
src/main/kotlin/com/looker/droidify/screen/RepositoryFragment.kt
Normal file
166
src/main/kotlin/com/looker/droidify/screen/RepositoryFragment.kt
Normal file
@ -0,0 +1,166 @@
|
||||
package com.looker.droidify.screen
|
||||
|
||||
import android.os.Bundle
|
||||
import android.text.SpannableStringBuilder
|
||||
import android.text.format.DateUtils
|
||||
import android.text.style.ForegroundColorSpan
|
||||
import android.text.style.TypefaceSpan
|
||||
import android.view.LayoutInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.ScrollView
|
||||
import android.widget.TextView
|
||||
import android.widget.Toolbar
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.disposables.Disposable
|
||||
import com.looker.droidify.R
|
||||
import com.looker.droidify.database.Database
|
||||
import com.looker.droidify.service.Connection
|
||||
import com.looker.droidify.service.SyncService
|
||||
import com.looker.droidify.utility.Utils
|
||||
import com.looker.droidify.utility.extension.resources.*
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
class RepositoryFragment(): ScreenFragment() {
|
||||
companion object {
|
||||
private const val EXTRA_REPOSITORY_ID = "repositoryId"
|
||||
}
|
||||
|
||||
constructor(repositoryId: Long): this() {
|
||||
arguments = Bundle().apply {
|
||||
putLong(EXTRA_REPOSITORY_ID, repositoryId)
|
||||
}
|
||||
}
|
||||
|
||||
private val repositoryId: Long
|
||||
get() = requireArguments().getLong(EXTRA_REPOSITORY_ID)
|
||||
|
||||
private var layout: LinearLayout? = null
|
||||
|
||||
private val syncConnection = Connection(SyncService::class.java)
|
||||
private var repositoryDisposable: Disposable? = null
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
return inflater.inflate(R.layout.fragment, container, false)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
syncConnection.bind(requireContext())
|
||||
repositoryDisposable = Observable.just(Unit)
|
||||
.concatWith(Database.observable(Database.Subject.Repository(repositoryId)))
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe { updateRepositoryView() }
|
||||
|
||||
val toolbar = view.findViewById<Toolbar>(R.id.toolbar)!!
|
||||
screenActivity.onToolbarCreated(toolbar)
|
||||
toolbar.setTitle(R.string.repository)
|
||||
|
||||
toolbar.menu.apply {
|
||||
add(R.string.edit_repository)
|
||||
.setIcon(Utils.getToolbarIcon(toolbar.context, R.drawable.ic_edit))
|
||||
.setShowAsActionFlags(MenuItem.SHOW_AS_ACTION_ALWAYS)
|
||||
.setOnMenuItemClickListener {
|
||||
view.post { screenActivity.navigateEditRepository(repositoryId) }
|
||||
true
|
||||
}
|
||||
|
||||
add(R.string.delete)
|
||||
.setIcon(Utils.getToolbarIcon(toolbar.context, R.drawable.ic_delete))
|
||||
.setShowAsActionFlags(MenuItem.SHOW_AS_ACTION_ALWAYS)
|
||||
.setOnMenuItemClickListener {
|
||||
MessageDialog(MessageDialog.Message.DeleteRepositoryConfirm).show(childFragmentManager)
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
val content = view.findViewById<FrameLayout>(R.id.fragment_content)!!
|
||||
val scroll = ScrollView(content.context)
|
||||
scroll.id = android.R.id.list
|
||||
scroll.isFillViewport = true
|
||||
content.addView(scroll, FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)
|
||||
val layout = LinearLayout(scroll.context)
|
||||
layout.orientation = LinearLayout.VERTICAL
|
||||
resources.sizeScaled(8).let { layout.setPadding(0, it, 0, it) }
|
||||
this.layout = layout
|
||||
scroll.addView(layout, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
|
||||
layout = null
|
||||
syncConnection.unbind(requireContext())
|
||||
repositoryDisposable?.dispose()
|
||||
repositoryDisposable = null
|
||||
}
|
||||
|
||||
private fun updateRepositoryView() {
|
||||
val repository = Database.RepositoryAdapter.get(repositoryId)
|
||||
val layout = layout!!
|
||||
layout.removeAllViews()
|
||||
if (repository == null) {
|
||||
layout.addTitleText(R.string.address, getString(R.string.unknown))
|
||||
} else {
|
||||
layout.addTitleText(R.string.address, repository.address)
|
||||
if (repository.updated > 0L) {
|
||||
layout.addTitleText(R.string.name, repository.name)
|
||||
layout.addTitleText(R.string.description, repository.description.replace('\n', ' '))
|
||||
layout.addTitleText(R.string.last_update, run {
|
||||
val lastUpdated = repository.updated
|
||||
if (lastUpdated > 0L) {
|
||||
val date = Date(repository.updated)
|
||||
val format = if (DateUtils.isToday(date.time)) DateUtils.FORMAT_SHOW_TIME else
|
||||
DateUtils.FORMAT_SHOW_TIME or DateUtils.FORMAT_SHOW_DATE
|
||||
DateUtils.formatDateTime(layout.context, date.time, format)
|
||||
} else {
|
||||
getString(R.string.unknown)
|
||||
}
|
||||
})
|
||||
if (repository.enabled && (repository.lastModified.isNotEmpty() || repository.entityTag.isNotEmpty())) {
|
||||
layout.addTitleText(R.string.number_of_applications,
|
||||
Database.ProductAdapter.getCount(repository.id).toString())
|
||||
}
|
||||
} else {
|
||||
layout.addTitleText(R.string.description, getString(R.string.repository_not_used_DESC))
|
||||
}
|
||||
if (repository.fingerprint.isEmpty()) {
|
||||
if (repository.updated > 0L) {
|
||||
val builder = SpannableStringBuilder(getString(R.string.repository_unsigned_DESC))
|
||||
builder.setSpan(ForegroundColorSpan(layout.context.getColorFromAttr(R.attr.colorError).defaultColor),
|
||||
0, builder.length, SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||
layout.addTitleText(R.string.fingerprint, builder)
|
||||
}
|
||||
} else {
|
||||
val fingerprint = SpannableStringBuilder(repository.fingerprint.windowed(2, 2, false)
|
||||
.take(32).joinToString(separator = " ") { it.toUpperCase(Locale.US) })
|
||||
fingerprint.setSpan(TypefaceSpan("monospace"), 0, fingerprint.length,
|
||||
SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||
layout.addTitleText(R.string.fingerprint, fingerprint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun LinearLayout.addTitleText(titleResId: Int, text: CharSequence) {
|
||||
if (text.isNotEmpty()) {
|
||||
val layout = inflate(R.layout.title_text_item)
|
||||
val titleView = layout.findViewById<TextView>(R.id.title)!!
|
||||
titleView.setText(titleResId)
|
||||
val textView = layout.findViewById<TextView>(R.id.text)!!
|
||||
textView.text = text
|
||||
addView(layout)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun onDeleteConfirm() {
|
||||
if (syncConnection.binder?.deleteRepository(repositoryId) == true) {
|
||||
requireActivity().onBackPressed()
|
||||
}
|
||||
}
|
||||
}
|
250
src/main/kotlin/com/looker/droidify/screen/ScreenActivity.kt
Normal file
250
src/main/kotlin/com/looker/droidify/screen/ScreenActivity.kt
Normal file
@ -0,0 +1,250 @@
|
||||
package com.looker.droidify.screen
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.os.Parcel
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.Toolbar
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import com.looker.droidify.R
|
||||
import com.looker.droidify.content.Cache
|
||||
import com.looker.droidify.content.Preferences
|
||||
import com.looker.droidify.database.CursorOwner
|
||||
import com.looker.droidify.utility.KParcelable
|
||||
import com.looker.droidify.utility.Utils
|
||||
import com.looker.droidify.utility.extension.android.*
|
||||
import com.looker.droidify.utility.extension.resources.*
|
||||
import com.looker.droidify.utility.extension.text.*
|
||||
|
||||
abstract class ScreenActivity: FragmentActivity() {
|
||||
companion object {
|
||||
private const val STATE_FRAGMENT_STACK = "fragmentStack"
|
||||
}
|
||||
|
||||
sealed class SpecialIntent {
|
||||
object Updates: SpecialIntent()
|
||||
class Install(val packageName: String?, val cacheFileName: String?): 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 attachBaseContext(base: Context) {
|
||||
super.attachBaseContext(Utils.configureLocale(base))
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
setTheme(Preferences[Preferences.Key.Theme].getResId(resources.configuration))
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
window.decorView.systemUiVisibility = window.decorView.systemUiVisibility or View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
|
||||
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
|
||||
addContentView(FrameLayout(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
|
||||
if (!packageName.isNullOrEmpty()) {
|
||||
val fragment = currentFragment
|
||||
if (fragment !is ProductFragment || fragment.packageName != packageName) {
|
||||
pushFragment(ProductFragment(packageName))
|
||||
}
|
||||
specialIntent.cacheFileName?.let(::startPackageInstaller)
|
||||
}
|
||||
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 ProductFragment || fragment.packageName != packageName) {
|
||||
pushFragment(ProductFragment(packageName))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun startPackageInstaller(cacheFileName: String) {
|
||||
val (uri, flags) = if (Android.sdk(24)) {
|
||||
Pair(Cache.getReleaseUri(this, cacheFileName), Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
} else {
|
||||
Pair(Uri.fromFile(Cache.getReleaseFile(this, cacheFileName)), 0)
|
||||
}
|
||||
// TODO Handle deprecation
|
||||
@Suppress("DEPRECATION")
|
||||
startActivity(Intent(Intent.ACTION_INSTALL_PACKAGE)
|
||||
.setDataAndType(uri, "application/vnd.android.package-archive").setFlags(flags))
|
||||
}
|
||||
|
||||
internal fun navigateProduct(packageName: String) = pushFragment(ProductFragment(packageName))
|
||||
internal fun navigateRepositories() = pushFragment(RepositoriesFragment())
|
||||
internal fun navigatePreferences() = pushFragment(PreferencesFragment())
|
||||
internal fun navigateAddRepository() = pushFragment(EditRepositoryFragment(null))
|
||||
internal fun navigateRepository(repositoryId: Long) = pushFragment(RepositoryFragment(repositoryId))
|
||||
internal fun navigateEditRepository(repositoryId: Long) = pushFragment(EditRepositoryFragment(repositoryId))
|
||||
}
|
10
src/main/kotlin/com/looker/droidify/screen/ScreenFragment.kt
Normal file
10
src/main/kotlin/com/looker/droidify/screen/ScreenFragment.kt
Normal file
@ -0,0 +1,10 @@
|
||||
package com.looker.droidify.screen
|
||||
|
||||
import androidx.fragment.app.Fragment
|
||||
|
||||
open class ScreenFragment: Fragment() {
|
||||
val screenActivity: ScreenActivity
|
||||
get() = requireActivity() as ScreenActivity
|
||||
|
||||
open fun onBackPressed(): Boolean = false
|
||||
}
|
@ -0,0 +1,233 @@
|
||||
package com.looker.droidify.screen
|
||||
|
||||
import android.app.Dialog
|
||||
import android.content.Context
|
||||
import android.graphics.PixelFormat
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.WindowManager
|
||||
import android.widget.ImageView
|
||||
import androidx.core.graphics.ColorUtils
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.viewpager2.widget.MarginPageTransformer
|
||||
import androidx.viewpager2.widget.ViewPager2
|
||||
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 com.looker.droidify.R
|
||||
import com.looker.droidify.database.Database
|
||||
import com.looker.droidify.entity.Product
|
||||
import com.looker.droidify.entity.Repository
|
||||
import com.looker.droidify.graphics.PaddingDrawable
|
||||
import com.looker.droidify.network.PicassoDownloader
|
||||
import com.looker.droidify.utility.RxUtils
|
||||
import com.looker.droidify.utility.extension.android.*
|
||||
import com.looker.droidify.utility.extension.resources.*
|
||||
import com.looker.droidify.widget.StableRecyclerAdapter
|
||||
|
||||
class ScreenshotsFragment(): DialogFragment() {
|
||||
companion object {
|
||||
private const val EXTRA_PACKAGE_NAME = "packageName"
|
||||
private const val EXTRA_REPOSITORY_ID = "repositoryId"
|
||||
private const val EXTRA_IDENTIFIER = "identifier"
|
||||
|
||||
private const val STATE_IDENTIFIER = "identifier"
|
||||
}
|
||||
|
||||
constructor(packageName: String, repositoryId: Long, identifier: String): this() {
|
||||
arguments = Bundle().apply {
|
||||
putString(EXTRA_PACKAGE_NAME, packageName)
|
||||
putLong(EXTRA_REPOSITORY_ID, repositoryId)
|
||||
putString(EXTRA_IDENTIFIER, identifier)
|
||||
}
|
||||
}
|
||||
|
||||
fun show(fragmentManager: FragmentManager) {
|
||||
show(fragmentManager, this::class.java.name)
|
||||
}
|
||||
|
||||
private var viewPager: ViewPager2? = null
|
||||
|
||||
private var productDisposable: Disposable? = null
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
val packageName = requireArguments().getString(EXTRA_PACKAGE_NAME)!!
|
||||
val repositoryId = requireArguments().getLong(EXTRA_REPOSITORY_ID)
|
||||
val dialog = Dialog(requireContext(), R.style.Theme_Main_Dark)
|
||||
|
||||
val window = dialog.window!!
|
||||
val decorView = window.decorView
|
||||
val background = dialog.context.getColorFromAttr(android.R.attr.colorBackground).defaultColor
|
||||
decorView.setBackgroundColor(background.let { ColorUtils.blendARGB(0x00ffffff and it, it, 0.9f) })
|
||||
decorView.setPadding(0, 0, 0, 0)
|
||||
background.let { ColorUtils.blendARGB(0x00ffffff and it, it, 0.8f) }.let {
|
||||
window.statusBarColor = it
|
||||
window.navigationBarColor = it
|
||||
}
|
||||
window.attributes = window.attributes.apply {
|
||||
title = ScreenshotsFragment::class.java.name
|
||||
format = PixelFormat.TRANSLUCENT
|
||||
windowAnimations = run {
|
||||
val typedArray = dialog.context.obtainStyledAttributes(null,
|
||||
intArrayOf(android.R.attr.windowAnimationStyle), android.R.attr.dialogTheme, 0)
|
||||
try {
|
||||
typedArray.getResourceId(0, 0)
|
||||
} finally {
|
||||
typedArray.recycle()
|
||||
}
|
||||
}
|
||||
if (Android.sdk(28)) {
|
||||
layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
|
||||
}
|
||||
}
|
||||
|
||||
val hideFlags = View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or
|
||||
View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_IMMERSIVE
|
||||
decorView.systemUiVisibility = decorView.systemUiVisibility or View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
|
||||
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
|
||||
val applyHide = Runnable { decorView.systemUiVisibility = decorView.systemUiVisibility or hideFlags }
|
||||
val handleClick = {
|
||||
decorView.removeCallbacks(applyHide)
|
||||
if ((decorView.systemUiVisibility and hideFlags) == hideFlags) {
|
||||
decorView.systemUiVisibility = decorView.systemUiVisibility and hideFlags.inv()
|
||||
} else {
|
||||
decorView.systemUiVisibility = decorView.systemUiVisibility or hideFlags
|
||||
}
|
||||
}
|
||||
decorView.postDelayed(applyHide, 2000L)
|
||||
decorView.setOnClickListener { handleClick() }
|
||||
|
||||
val viewPager = ViewPager2(dialog.context)
|
||||
viewPager.adapter = Adapter(packageName) { handleClick() }
|
||||
viewPager.setPageTransformer(MarginPageTransformer(resources.sizeScaled(16)))
|
||||
viewPager.viewTreeObserver.addOnGlobalLayoutListener {
|
||||
(viewPager.adapter as Adapter).size = Pair(viewPager.width, viewPager.height)
|
||||
}
|
||||
dialog.addContentView(viewPager, ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT))
|
||||
this.viewPager = viewPager
|
||||
|
||||
var restored = false
|
||||
productDisposable = Observable.just(Unit)
|
||||
.concatWith(Database.observable(Database.Subject.Products))
|
||||
.observeOn(Schedulers.io())
|
||||
.flatMapSingle { RxUtils.querySingle { Database.ProductAdapter.get(packageName, it) } }
|
||||
.map { Pair(it.find { it.repositoryId == repositoryId }, Database.RepositoryAdapter.get(repositoryId)) }
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe {
|
||||
val (product, repository) = it
|
||||
val screenshots = product?.screenshots.orEmpty()
|
||||
(viewPager.adapter as Adapter).update(repository, screenshots)
|
||||
if (!restored) {
|
||||
restored = true
|
||||
val identifier = savedInstanceState?.getString(STATE_IDENTIFIER)
|
||||
?: requireArguments().getString(STATE_IDENTIFIER)
|
||||
if (identifier != null) {
|
||||
val index = screenshots.indexOfFirst { it.identifier == identifier }
|
||||
if (index >= 0) {
|
||||
viewPager.setCurrentItem(index, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dialog
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
|
||||
viewPager = null
|
||||
|
||||
productDisposable?.dispose()
|
||||
productDisposable = null
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
|
||||
val viewPager = viewPager
|
||||
if (viewPager != null) {
|
||||
val identifier = (viewPager.adapter as Adapter).getCurrentIdentifier(viewPager)
|
||||
identifier?.let { outState.putString(STATE_IDENTIFIER, it) }
|
||||
}
|
||||
}
|
||||
|
||||
private class Adapter(private val packageName: String, private val onClick: () -> Unit):
|
||||
StableRecyclerAdapter<Adapter.ViewType, RecyclerView.ViewHolder>() {
|
||||
enum class ViewType { SCREENSHOT }
|
||||
|
||||
private class ViewHolder(context: Context): RecyclerView.ViewHolder(ImageView(context)) {
|
||||
val image: ImageView
|
||||
get() = itemView as ImageView
|
||||
|
||||
val placeholder: Drawable
|
||||
|
||||
init {
|
||||
itemView.layoutParams = RecyclerView.LayoutParams(RecyclerView.LayoutParams.MATCH_PARENT,
|
||||
RecyclerView.LayoutParams.MATCH_PARENT)
|
||||
|
||||
val placeholder = itemView.context.getDrawableCompat(R.drawable.ic_photo_camera).mutate()
|
||||
placeholder.setTint(itemView.context.getColorFromAttr(android.R.attr.textColorPrimary).defaultColor
|
||||
.let { ColorUtils.blendARGB(0x00ffffff and it, it, 0.25f) })
|
||||
this.placeholder = PaddingDrawable(placeholder, 4f)
|
||||
}
|
||||
}
|
||||
|
||||
private var repository: Repository? = null
|
||||
private var screenshots = emptyList<Product.Screenshot>()
|
||||
|
||||
fun update(repository: Repository?, screenshots: List<Product.Screenshot>) {
|
||||
this.repository = repository
|
||||
this.screenshots = screenshots
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
var size = Pair(0, 0)
|
||||
set(value) {
|
||||
if (field != value) {
|
||||
field = value
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
|
||||
fun getCurrentIdentifier(viewPager: ViewPager2): String? {
|
||||
val position = viewPager.currentItem
|
||||
return screenshots.getOrNull(position)?.identifier
|
||||
}
|
||||
|
||||
override val viewTypeClass: Class<ViewType>
|
||||
get() = ViewType::class.java
|
||||
|
||||
override fun getItemCount(): Int = screenshots.size
|
||||
override fun getItemDescriptor(position: Int): String = screenshots[position].identifier
|
||||
override fun getItemEnumViewType(position: Int): ViewType = ViewType.SCREENSHOT
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: ViewType): RecyclerView.ViewHolder {
|
||||
return ViewHolder(parent.context).apply {
|
||||
itemView.setOnClickListener { onClick() }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
holder as ViewHolder
|
||||
val screenshot = screenshots[position]
|
||||
val (width, height) = size
|
||||
if (width > 0 && height > 0) {
|
||||
holder.image.load(PicassoDownloader.createScreenshotUri(repository!!, packageName, screenshot)) {
|
||||
placeholder(holder.placeholder)
|
||||
error(holder.placeholder)
|
||||
resize(width, height)
|
||||
centerInside()
|
||||
}
|
||||
} else {
|
||||
holder.image.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
612
src/main/kotlin/com/looker/droidify/screen/TabsFragment.kt
Normal file
612
src/main/kotlin/com/looker/droidify/screen/TabsFragment.kt
Normal file
@ -0,0 +1,612 @@
|
||||
package com.looker.droidify.screen
|
||||
|
||||
import android.animation.ValueAnimator
|
||||
import android.content.Context
|
||||
import android.content.res.ColorStateList
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.ColorFilter
|
||||
import android.graphics.Paint
|
||||
import android.graphics.PixelFormat
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.os.Bundle
|
||||
import android.view.Gravity
|
||||
import android.view.LayoutInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.animation.AccelerateInterpolator
|
||||
import android.view.animation.DecelerateInterpolator
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.SearchView
|
||||
import android.widget.TextView
|
||||
import android.widget.Toolbar
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.viewpager2.adapter.FragmentStateAdapter
|
||||
import androidx.viewpager2.widget.ViewPager2
|
||||
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 com.looker.droidify.R
|
||||
import com.looker.droidify.content.Preferences
|
||||
import com.looker.droidify.database.Database
|
||||
import com.looker.droidify.entity.ProductItem
|
||||
import com.looker.droidify.service.Connection
|
||||
import com.looker.droidify.service.SyncService
|
||||
import com.looker.droidify.utility.RxUtils
|
||||
import com.looker.droidify.utility.Utils
|
||||
import com.looker.droidify.utility.extension.android.*
|
||||
import com.looker.droidify.utility.extension.resources.*
|
||||
import com.looker.droidify.widget.DividerItemDecoration
|
||||
import com.looker.droidify.widget.FocusSearchView
|
||||
import com.looker.droidify.widget.StableRecyclerAdapter
|
||||
import kotlin.math.*
|
||||
|
||||
class TabsFragment: ScreenFragment() {
|
||||
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: View) {
|
||||
val tabs = view.findViewById<LinearLayout>(R.id.tabs)!!
|
||||
val sectionLayout = view.findViewById<ViewGroup>(R.id.section_layout)!!
|
||||
val sectionChange = view.findViewById<View>(R.id.section_change)!!
|
||||
val sectionName = view.findViewById<TextView>(R.id.section_name)!!
|
||||
val sectionIcon = view.findViewById<ImageView>(R.id.section_icon)!!
|
||||
}
|
||||
|
||||
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<ProductItem.Section>(ProductItem.Section.All)
|
||||
private var section: ProductItem.Section = ProductItem.Section.All
|
||||
|
||||
private val syncConnection = Connection(SyncService::class.java, onBind = { _, _ ->
|
||||
viewPager?.let {
|
||||
val source = ProductsFragment.Source.values()[it.currentItem]
|
||||
updateUpdateNotificationBlocker(source)
|
||||
}
|
||||
})
|
||||
|
||||
private var sortOrderDisposable: Disposable? = null
|
||||
private var categoriesDisposable: Disposable? = null
|
||||
private var repositoriesDisposable: Disposable? = null
|
||||
private var sectionsAnimator: ValueAnimator? = null
|
||||
|
||||
private var needSelectUpdates = false
|
||||
|
||||
private val productFragments: Sequence<ProductsFragment>
|
||||
get() = if (host == null) emptySequence() else
|
||||
childFragmentManager.fragments.asSequence().mapNotNull { it as? ProductsFragment }
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
return inflater.inflate(R.layout.fragment, container, false)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
syncConnection.bind(requireContext())
|
||||
|
||||
val toolbar = view.findViewById<Toolbar>(R.id.toolbar)!!
|
||||
screenActivity.onToolbarCreated(toolbar)
|
||||
toolbar.setTitle(R.string.application_name)
|
||||
// Move focus from SearchView to Toolbar
|
||||
toolbar.isFocusableInTouchMode = true
|
||||
|
||||
val searchView = FocusSearchView(toolbar.context)
|
||||
searchView.allowFocus = savedInstanceState?.getBoolean(STATE_SEARCH_FOCUSED) == true
|
||||
searchView.maxWidth = Int.MAX_VALUE
|
||||
searchView.queryHint = getString(R.string.search)
|
||||
searchView.setOnQueryTextListener(object: SearchView.OnQueryTextListener {
|
||||
override fun onQueryTextSubmit(query: String?): Boolean {
|
||||
searchView.clearFocus()
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onQueryTextChange(newText: String?): Boolean {
|
||||
if (isResumed) {
|
||||
searchQuery = newText.orEmpty()
|
||||
productFragments.forEach { it.setSearchQuery(newText.orEmpty()) }
|
||||
}
|
||||
return 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.repositories)
|
||||
.setOnMenuItemClickListener {
|
||||
view.post { screenActivity.navigateRepositories() }
|
||||
true
|
||||
}
|
||||
|
||||
add(1, 0, 0, R.string.preferences)
|
||||
.setOnMenuItemClickListener {
|
||||
view.post { screenActivity.navigatePreferences() }
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
searchQuery = savedInstanceState?.getString(STATE_SEARCH_QUERY).orEmpty()
|
||||
productFragments.forEach { it.setSearchQuery(searchQuery) }
|
||||
|
||||
val toolbarExtra = view.findViewById<FrameLayout>(R.id.toolbar_extra)!!
|
||||
toolbarExtra.addView(toolbarExtra.inflate(R.layout.tabs_toolbar))
|
||||
val layout = Layout(view)
|
||||
this.layout = layout
|
||||
|
||||
layout.tabs.background = TabsBackgroundDrawable(layout.tabs.context,
|
||||
layout.tabs.layoutDirection == View.LAYOUT_DIRECTION_RTL)
|
||||
ProductsFragment.Source.values().forEach {
|
||||
val tab = TextView(layout.tabs.context)
|
||||
val selectedColor = tab.context.getColorFromAttr(android.R.attr.textColorPrimary).defaultColor
|
||||
val normalColor = tab.context.getColorFromAttr(android.R.attr.textColorSecondary).defaultColor
|
||||
tab.gravity = Gravity.CENTER
|
||||
tab.typeface = TypefaceExtra.medium
|
||||
tab.setTextColor(ColorStateList(arrayOf(intArrayOf(android.R.attr.state_selected), intArrayOf()),
|
||||
intArrayOf(selectedColor, normalColor)))
|
||||
tab.setTextSizeScaled(14)
|
||||
tab.isAllCaps = true
|
||||
tab.text = getString(it.titleResId)
|
||||
tab.background = tab.context.getDrawableFromAttr(android.R.attr.selectableItemBackground)
|
||||
tab.setOnClickListener { _ ->
|
||||
setSelectedTab(it)
|
||||
viewPager!!.setCurrentItem(it.ordinal, Utils.areAnimationsEnabled(tab.context))
|
||||
}
|
||||
layout.tabs.addView(tab, 0, LinearLayout.LayoutParams.MATCH_PARENT)
|
||||
(tab.layoutParams as LinearLayout.LayoutParams).weight = 1f
|
||||
}
|
||||
|
||||
showSections = savedInstanceState?.getByte(STATE_SHOW_SECTIONS)?.toInt() ?: 0 != 0
|
||||
sections = savedInstanceState?.getParcelableArrayList<ProductItem.Section>(STATE_SECTIONS).orEmpty()
|
||||
section = savedInstanceState?.getParcelable(STATE_SECTION) ?: ProductItem.Section.All
|
||||
layout.sectionChange.setOnClickListener { showSections = sections
|
||||
.any { it !is ProductItem.Section.All } && !showSections }
|
||||
|
||||
updateOrder()
|
||||
sortOrderDisposable = Preferences.observable.subscribe {
|
||||
if (it == Preferences.Key.SortOrder) {
|
||||
updateOrder()
|
||||
}
|
||||
}
|
||||
|
||||
val content = view.findViewById<FrameLayout>(R.id.fragment_content)!!
|
||||
|
||||
viewPager = ViewPager2(content.context).apply {
|
||||
id = R.id.fragment_pager
|
||||
adapter = object: FragmentStateAdapter(this@TabsFragment) {
|
||||
override fun getItemCount(): Int = ProductsFragment.Source.values().size
|
||||
override fun createFragment(position: Int): Fragment = ProductsFragment(ProductsFragment
|
||||
.Source.values()[position])
|
||||
}
|
||||
content.addView(this, FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)
|
||||
registerOnPageChangeCallback(pageChangeCallback)
|
||||
offscreenPageLimit = 1
|
||||
}
|
||||
|
||||
categoriesDisposable = Observable.just(Unit)
|
||||
.concatWith(Database.observable(Database.Subject.Products))
|
||||
.observeOn(Schedulers.io())
|
||||
.flatMapSingle { RxUtils.querySingle { Database.CategoryAdapter.getAll(it) } }
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe { setSectionsAndUpdate(it.asSequence().sorted()
|
||||
.map(ProductItem.Section::Category).toList(), null) }
|
||||
repositoriesDisposable = Observable.just(Unit)
|
||||
.concatWith(Database.observable(Database.Subject.Repositories))
|
||||
.observeOn(Schedulers.io())
|
||||
.flatMapSingle { RxUtils.querySingle { Database.RepositoryAdapter.getAll(it) } }
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe { setSectionsAndUpdate(null, it.asSequence().filter { it.enabled }
|
||||
.map { ProductItem.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))
|
||||
setBackgroundColor(context.getColorFromAttr(android.R.attr.colorPrimaryDark).defaultColor)
|
||||
elevation = resources.sizeScaled(4).toFloat()
|
||||
content.addView(this, FrameLayout.LayoutParams.MATCH_PARENT, 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())
|
||||
sortOrderDisposable?.dispose()
|
||||
sortOrderDisposable = null
|
||||
categoriesDisposable?.dispose()
|
||||
categoriesDisposable = null
|
||||
repositoriesDisposable?.dispose()
|
||||
repositoriesDisposable = null
|
||||
sectionsAnimator?.cancel()
|
||||
sectionsAnimator = 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 onAttachFragment(childFragment: Fragment) {
|
||||
super.onAttachFragment(childFragment)
|
||||
|
||||
if (view != null && childFragment is ProductsFragment) {
|
||||
childFragment.setSearchQuery(searchQuery)
|
||||
childFragment.setSection(section)
|
||||
childFragment.setOrder(Preferences[Preferences.Key.SortOrder].order)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBackPressed(): Boolean {
|
||||
return when {
|
||||
searchMenuItem?.isActionViewExpanded == true -> {
|
||||
searchMenuItem?.collapseActionView()
|
||||
true
|
||||
}
|
||||
showSections -> {
|
||||
showSections = false
|
||||
true
|
||||
}
|
||||
else -> {
|
||||
super.onBackPressed()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setSelectedTab(source: ProductsFragment.Source) {
|
||||
val layout = layout!!
|
||||
(0 until layout.tabs.childCount).forEach { layout.tabs.getChildAt(it).isSelected = it == source.ordinal }
|
||||
}
|
||||
|
||||
internal fun selectUpdates() = selectUpdatesInternal(true)
|
||||
|
||||
private fun selectUpdatesInternal(allowSmooth: Boolean) {
|
||||
if (view != null) {
|
||||
val viewPager = viewPager
|
||||
viewPager?.setCurrentItem(ProductsFragment.Source.UPDATES.ordinal, allowSmooth && viewPager.isLaidOut)
|
||||
} else {
|
||||
needSelectUpdates = true
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateUpdateNotificationBlocker(activeSource: ProductsFragment.Source) {
|
||||
val blockerFragment = if (activeSource == ProductsFragment.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: ProductItem.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<ProductItem.Section.Category>?,
|
||||
repositories: List<ProductItem.Section.Repository>?) {
|
||||
val oldCategories = collectOldSections(categories)
|
||||
val oldRepositories = collectOldSections(repositories)
|
||||
if (oldCategories == null || oldRepositories == null) {
|
||||
sections = listOf(ProductItem.Section.All) +
|
||||
(categories ?: oldCategories).orEmpty() +
|
||||
(repositories ?: oldRepositories).orEmpty()
|
||||
updateSection()
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateSection() {
|
||||
if (section !in sections) {
|
||||
section = ProductItem.Section.All
|
||||
}
|
||||
layout?.sectionName?.text = when (val section = section) {
|
||||
is ProductItem.Section.All -> getString(R.string.all_applications)
|
||||
is ProductItem.Section.Category -> section.name
|
||||
is ProductItem.Section.Repository -> section.name
|
||||
}
|
||||
layout?.sectionIcon?.visibility = if (sections.any { it !is ProductItem.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) 1f 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 = ProductsFragment.Source.values()[position].sections
|
||||
val toSections = if (positionOffset <= 0f) fromSections else
|
||||
ProductsFragment.Source.values()[position + 1].sections
|
||||
val offset = if (fromSections != toSections) {
|
||||
if (fromSections) 1f - positionOffset else positionOffset
|
||||
} else {
|
||||
if (fromSections) 1f else 0f
|
||||
}
|
||||
(layout.tabs.background as TabsBackgroundDrawable)
|
||||
.update(position + positionOffset, layout.tabs.childCount)
|
||||
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 = ProductsFragment.Source.values()[position]
|
||||
updateUpdateNotificationBlocker(source)
|
||||
sortOrderMenu!!.first.isVisible = source.order
|
||||
syncRepositoriesMenuItem!!.setShowAsActionFlags(if (!source.order ||
|
||||
resources.configuration.screenWidthDp >= 400) MenuItem.SHOW_AS_ACTION_ALWAYS else 0)
|
||||
setSelectedTab(source)
|
||||
if (showSections && !source.sections) {
|
||||
showSections = false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPageScrollStateChanged(state: Int) {
|
||||
val source = ProductsFragment.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 TabsBackgroundDrawable(context: Context, private val rtl: Boolean): Drawable() {
|
||||
private val height = context.resources.sizeScaled(2)
|
||||
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = context.getColorFromAttr(android.R.attr.colorPrimary).defaultColor
|
||||
}
|
||||
|
||||
private var position = 0f
|
||||
private var total = 0
|
||||
|
||||
fun update(position: Float, total: Int) {
|
||||
this.position = position
|
||||
this.total = total
|
||||
invalidateSelf()
|
||||
}
|
||||
|
||||
override fun draw(canvas: Canvas) {
|
||||
if (total > 0) {
|
||||
val bounds = bounds
|
||||
val width = bounds.width() / total.toFloat()
|
||||
val x = width * position
|
||||
if (rtl) {
|
||||
canvas.drawRect(bounds.right - width - x, (bounds.bottom - height).toFloat(),
|
||||
bounds.right - x, bounds.bottom.toFloat(), paint)
|
||||
} else {
|
||||
canvas.drawRect(bounds.left + x, (bounds.bottom - height).toFloat(),
|
||||
bounds.left + x + width, bounds.bottom.toFloat(), paint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun setAlpha(alpha: Int) = Unit
|
||||
override fun setColorFilter(colorFilter: ColorFilter?) = Unit
|
||||
override fun getOpacity(): Int = PixelFormat.TRANSLUCENT
|
||||
}
|
||||
|
||||
private class SectionsAdapter(private val sections: () -> List<ProductItem.Section>,
|
||||
private val onClick: (ProductItem.Section) -> Unit): StableRecyclerAdapter<SectionsAdapter.ViewType,
|
||||
RecyclerView.ViewHolder>() {
|
||||
enum class ViewType { SECTION }
|
||||
|
||||
private class SectionViewHolder(context: Context): RecyclerView.ViewHolder(TextView(context)) {
|
||||
val title: TextView
|
||||
get() = itemView as TextView
|
||||
|
||||
init {
|
||||
itemView as TextView
|
||||
itemView.gravity = Gravity.CENTER_VERTICAL
|
||||
itemView.resources.sizeScaled(16).let { itemView.setPadding(it, 0, it, 0) }
|
||||
itemView.setTextColor(context.getColorFromAttr(android.R.attr.textColorPrimary))
|
||||
itemView.setTextSizeScaled(16)
|
||||
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(true, false, padding, padding)
|
||||
}
|
||||
else -> {
|
||||
configuration.set(false, false, 0, 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 ProductItem.Section.All -> holder.itemView.resources.getString(R.string.all_applications)
|
||||
is ProductItem.Section.Category -> section.name
|
||||
is ProductItem.Section.Repository -> section.name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user