Add: EditRepositorySheet

This commit is contained in:
machiav3lli 2022-01-27 02:06:37 +01:00
parent 4692952a2e
commit 985de2fb68
4 changed files with 562 additions and 0 deletions

View File

@ -0,0 +1,430 @@
package com.looker.droidify.ui.fragments
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.Selection
import android.util.Base64
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AlertDialog
import androidx.core.widget.doAfterTextChanged
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.viewModels
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.looker.droidify.EXTRA_REPOSITORY_ID
import com.looker.droidify.R
import com.looker.droidify.databinding.SheetEditRepositoryBinding
import com.looker.droidify.network.Downloader
import com.looker.droidify.screen.MessageDialog
import com.looker.droidify.service.Connection
import com.looker.droidify.service.SyncService
import com.looker.droidify.ui.activities.PrefsActivityX
import com.looker.droidify.ui.viewmodels.RepositoryViewModelX
import com.looker.droidify.utility.RxUtils
import com.looker.droidify.utility.extension.resources.getColorFromAttr
import com.looker.droidify.utility.extension.text.nullIfEmpty
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.schedulers.Schedulers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Request
import java.net.URI
import java.net.URL
import java.nio.charset.Charset
import java.util.*
import kotlin.math.min
class EditRepositorySheetX() : FullscreenBottomSheetDialogFragment() {
private lateinit var binding: SheetEditRepositoryBinding
val viewModel: RepositoryViewModelX by viewModels {
RepositoryViewModelX.Factory((requireActivity() as PrefsActivityX).db, repositoryId)
}
companion object {
private val checkPaths = listOf("", "fdroid/repo", "repo")
}
constructor(repositoryId: Long?) : this() {
arguments = Bundle().apply {
repositoryId?.let { putLong(EXTRA_REPOSITORY_ID, it) }
}
}
private val repositoryId: Long
get() = requireArguments().getLong(EXTRA_REPOSITORY_ID)
private lateinit var errorColorFilter: PorterDuffColorFilter
private val syncConnection = Connection(SyncService::class.java)
private var checkDisposable: Disposable? = null
private var takenAddresses = emptySet<String>()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = SheetEditRepositoryBinding.inflate(layoutInflater)
syncConnection.bind(requireContext())
return binding.root
}
override fun setupLayout() {
errorColorFilter = PorterDuffColorFilter(
requireContext().getColorFromAttr(R.attr.colorError).defaultColor,
PorterDuff.Mode.SRC_IN
)
val validChar: (Char) -> Boolean =
{ it in '0'..'9' || it in 'a'..'f' || it in 'A'..'F' }
binding.fingerprint.doAfterTextChanged { text ->
fun logicalPosition(text: String, position: Int): Int {
return if (position > 0) text.asSequence().take(position)
.count(validChar) else position
}
fun realPosition(text: String, position: Int): Int {
return if (position > 0) {
var left = position
val index = text.indexOfFirst {
validChar(it) && run {
left -= 1
left <= 0
}
}
if (index >= 0) min(index + 1, text.length) else text.length
} else {
position
}
}
val inputString = text.toString()
val outputString = inputString.uppercase(Locale.US)
.filter(validChar).windowed(2, 2, true).take(32).joinToString(separator = " ")
if (inputString != outputString) {
val inputStart = logicalPosition(inputString, Selection.getSelectionStart(text))
val inputEnd = logicalPosition(inputString, Selection.getSelectionEnd(text))
text?.replace(0, text.length, outputString)
Selection.setSelection(
text,
realPosition(outputString, inputStart),
realPosition(outputString, inputEnd)
)
}
}
binding.address.doAfterTextChanged { invalidateAddress() }
binding.fingerprint.doAfterTextChanged { invalidateFingerprint() }
binding.username.doAfterTextChanged { invalidateUsernamePassword() }
binding.password.doAfterTextChanged { invalidateUsernamePassword() }
viewModel.repo.observe(viewLifecycleOwner) { updateSheet() }
binding.save.setOnClickListener { onSaveRepositoryClick() }
GlobalScope.launch {
val list = viewModel.db.repositoryDao.all.mapNotNull { it.trueData }
takenAddresses = list.asSequence().filter { it.id != repositoryId }
.flatMap { (it.mirrors + it.address).asSequence() }
.map { it.withoutKnownPath }.toSet()
MainScope().launch { invalidateAddress() }
}
}
override fun updateSheet() {
val repository = viewModel.repo.value?.trueData
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)
}
binding.address.setText(addressText)
binding.fingerprint.setText(fingerprintText)
} else {
binding.address.setText(repository.address)
val mirrors = repository.mirrors.map { it.withoutKnownPath }
if (mirrors.isNotEmpty()) {
binding.addressMirror.visibility = View.VISIBLE
binding.address.apply {
setPaddingRelative(
paddingStart, paddingTop,
paddingEnd + binding.addressMirror.layoutParams.width, paddingBottom
)
}
binding.addressMirror.setOnClickListener {
SelectMirrorDialog(mirrors)
.show(childFragmentManager, SelectMirrorDialog::class.java.name)
}
}
binding.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)
binding.username.setText(usernameText)
binding.password.setText(passwordText)
}
}
override fun onDestroyView() {
super.onDestroyView()
syncConnection.unbind(requireContext())
checkDisposable?.dispose()
checkDisposable = null
}
private var addressError = false
private var fingerprintError = false
private var usernamePasswordError = false
private fun invalidateAddress() {
invalidateAddress(binding.address.text.toString())
}
private fun invalidateAddress(addressText: String) {
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
}
addressError = addressErrorResId != null
addressErrorResId?.let { binding.address.error = getString(it) }
invalidateState()
}
private fun invalidateFingerprint() {
val fingerprint = binding.fingerprint.text.toString().replace(" ", "")
val fingerprintInvalid = fingerprint.isNotEmpty() && fingerprint.length != 64
fingerprintError = fingerprintInvalid
invalidateState()
}
private fun invalidateUsernamePassword() {
val username = binding.username.text.toString()
val password = binding.password.text.toString()
val usernameInvalid = username.contains(':')
val usernameEmpty = username.isEmpty() && password.isNotEmpty()
val passwordEmpty = username.isNotEmpty() && password.isEmpty()
usernamePasswordError = usernameInvalid || usernameEmpty || passwordEmpty
invalidateState()
}
private fun invalidateState() {
binding.save.isEnabled =
!addressError && !fingerprintError && !usernamePasswordError && checkDisposable == null
binding.apply {
sequenceOf(address, addressMirror, fingerprint, username, password)
.forEach { it.isEnabled = checkDisposable == null }
}
}
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) {
binding.address.setText(address)
}
private fun onSaveRepositoryClick() {
if (checkDisposable == null) {
val address = normalizeAddress(binding.address.text.toString())!!
val fingerprint = binding.fingerprint.text.toString().replace(" ", "")
val username = binding.username.text.toString().nullIfEmpty()
val password = binding.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()
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?.nullIfEmpty() ?: address
val allow = resultAddress == address || run {
binding.address.setText(resultAddress)
invalidateAddress(resultAddress)
!addressError
}
if (allow) {
onSaveRepositoryProceedInvalidate(
resultAddress,
fingerprint,
authentication
)
} else {
invalidateState()
}
}
invalidateState()
}
}
private fun onSaveRepositoryProceedInvalidate(
address: String,
fingerprint: String,
authentication: String,
) = GlobalScope.launch {
val binder = syncConnection.binder
if (binder != null) {
if (binder.isCurrentlySyncing(repositoryId)) {
MessageDialog(MessageDialog.Message.CantEditSyncing).show(childFragmentManager)
invalidateState()
} else {
viewModel.updateRepo(
viewModel.repo.value?.trueData?.copy(
address = address,
fingerprint = fingerprint,
authentication = authentication
)
)
dismissAllowingStateLoss()
}
} else {
invalidateState()
}
}
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 MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.select_mirror)
.setItems(mirrors.toTypedArray()) { _, position ->
(parentFragment as EditRepositorySheetX)
.setMirror(mirrors[position])
}
.setNegativeButton(R.string.cancel, null)
.create()
}
}
}

View File

@ -57,6 +57,12 @@ class RepositorySheetX() : FullscreenBottomSheetDialogFragment() {
childFragmentManager childFragmentManager
) )
} }
binding.editRepository.setOnClickListener {
EditRepositorySheetX(repositoryId).showNow(
parentFragmentManager,
"Edit repository ${it.id}"
)
}
} }
override fun onDestroyView() { override fun onDestroyView() {

View File

@ -3,8 +3,12 @@ package com.looker.droidify.ui.viewmodels
import androidx.lifecycle.MediatorLiveData import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.looker.droidify.database.DatabaseX import com.looker.droidify.database.DatabaseX
import com.looker.droidify.database.entity.Repository import com.looker.droidify.database.entity.Repository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class RepositoryViewModelX(val db: DatabaseX, val repositoryId: Long) : ViewModel() { class RepositoryViewModelX(val db: DatabaseX, val repositoryId: Long) : ViewModel() {
@ -16,6 +20,20 @@ class RepositoryViewModelX(val db: DatabaseX, val repositoryId: Long) : ViewMode
appsCount.addSource(db.productDao.countForRepositoryLive(repositoryId), appsCount::setValue) appsCount.addSource(db.productDao.countForRepositoryLive(repositoryId), appsCount::setValue)
} }
fun updateRepo(newValue: com.looker.droidify.entity.Repository?) {
newValue?.let {
viewModelScope.launch {
update(it)
}
}
}
private suspend fun update(newValue: com.looker.droidify.entity.Repository) {
withContext(Dispatchers.IO) {
db.repositoryDao.put(newValue)
}
}
class Factory(val db: DatabaseX, val repositoryId: Long) : ViewModelProvider.Factory { class Factory(val db: DatabaseX, val repositoryId: Long) : ViewModelProvider.Factory {
@Suppress("unchecked_cast") @Suppress("unchecked_cast")
override fun <T : ViewModel?> create(modelClass: Class<T>): T { override fun <T : ViewModel?> create(modelClass: Class<T>): T {

View File

@ -0,0 +1,108 @@
<?xml version="1.0" encoding="UTF-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<data>
</data>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:animateLayoutChanges="true">
<androidx.core.widget.NestedScrollView
android:id="@android:id/list"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true">
<androidx.appcompat.widget.LinearLayoutCompat
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingHorizontal="12dp"
android:paddingTop="4dp"
android:paddingBottom="12dp">
<com.google.android.material.circularreveal.CircularRevealFrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/address"
android:paddingVertical="12dp">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/address"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/address_mirror"
android:layout_width="36dp"
android:layout_height="match_parent"
android:layout_gravity="end"
android:background="?android:attr/actionBarItemBackground"
android:scaleType="center"
android:src="@drawable/ic_arrow_down"
android:tint="?android:attr/textColorSecondary"
android:tintMode="src_in"
android:visibility="gone"
tools:ignore="ContentDescription" />
</com.google.android.material.circularreveal.CircularRevealFrameLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/fingerprint"
android:paddingVertical="12dp">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/fingerprint"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/username"
android:paddingVertical="12dp">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/username"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/password"
android:paddingVertical="12dp">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/password"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/save"
style="@style/ThemeOverlay.Material3.Button.ElevatedButton"
android:layout_width="wrap_content"
android:layout_height="52dp"
android:layout_gravity="end"
android:layout_marginHorizontal="4dp"
android:backgroundTint="?colorPrimary"
android:text="@string/save"
android:textColor="?colorSurface" />
</androidx.appcompat.widget.LinearLayoutCompat>
</androidx.core.widget.NestedScrollView>
</FrameLayout>
</layout>