From 985de2fb68894f46090cc46d7909ad79d6c29f21 Mon Sep 17 00:00:00 2001 From: machiav3lli Date: Thu, 27 Jan 2022 02:06:37 +0100 Subject: [PATCH] Add: EditRepositorySheet --- .../ui/fragments/EditRepositorySheetX.kt | 430 ++++++++++++++++++ .../droidify/ui/fragments/RepositorySheetX.kt | 6 + .../ui/viewmodels/RepositoryViewModelX.kt | 18 + src/main/res/layout/sheet_edit_repository.xml | 108 +++++ 4 files changed, 562 insertions(+) create mode 100644 src/main/kotlin/com/looker/droidify/ui/fragments/EditRepositorySheetX.kt create mode 100644 src/main/res/layout/sheet_edit_repository.xml diff --git a/src/main/kotlin/com/looker/droidify/ui/fragments/EditRepositorySheetX.kt b/src/main/kotlin/com/looker/droidify/ui/fragments/EditRepositorySheetX.kt new file mode 100644 index 00000000..6371b8d0 --- /dev/null +++ b/src/main/kotlin/com/looker/droidify/ui/fragments/EditRepositorySheetX.kt @@ -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() + + 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) : 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() + } + } +} diff --git a/src/main/kotlin/com/looker/droidify/ui/fragments/RepositorySheetX.kt b/src/main/kotlin/com/looker/droidify/ui/fragments/RepositorySheetX.kt index 0d11e097..928705d0 100644 --- a/src/main/kotlin/com/looker/droidify/ui/fragments/RepositorySheetX.kt +++ b/src/main/kotlin/com/looker/droidify/ui/fragments/RepositorySheetX.kt @@ -57,6 +57,12 @@ class RepositorySheetX() : FullscreenBottomSheetDialogFragment() { childFragmentManager ) } + binding.editRepository.setOnClickListener { + EditRepositorySheetX(repositoryId).showNow( + parentFragmentManager, + "Edit repository ${it.id}" + ) + } } override fun onDestroyView() { diff --git a/src/main/kotlin/com/looker/droidify/ui/viewmodels/RepositoryViewModelX.kt b/src/main/kotlin/com/looker/droidify/ui/viewmodels/RepositoryViewModelX.kt index d35cace0..9963b735 100644 --- a/src/main/kotlin/com/looker/droidify/ui/viewmodels/RepositoryViewModelX.kt +++ b/src/main/kotlin/com/looker/droidify/ui/viewmodels/RepositoryViewModelX.kt @@ -3,8 +3,12 @@ package com.looker.droidify.ui.viewmodels import androidx.lifecycle.MediatorLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope import com.looker.droidify.database.DatabaseX 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() { @@ -16,6 +20,20 @@ class RepositoryViewModelX(val db: DatabaseX, val repositoryId: Long) : ViewMode 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 { @Suppress("unchecked_cast") override fun create(modelClass: Class): T { diff --git a/src/main/res/layout/sheet_edit_repository.xml b/src/main/res/layout/sheet_edit_repository.xml new file mode 100644 index 00000000..04e56ca9 --- /dev/null +++ b/src/main/res/layout/sheet_edit_repository.xml @@ -0,0 +1,108 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file