Files
michas-droid/src/main/kotlin/nya/kitsunyan/foxydroid/screen/EditRepositoryFragment.kt
T
2020-06-19 15:20:16 +03:00

486 lines
18 KiB
Kotlin

package nya.kitsunyan.foxydroid.screen
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 androidx.appcompat.app.AlertDialog
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment
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 nya.kitsunyan.foxydroid.R
import nya.kitsunyan.foxydroid.database.Database
import nya.kitsunyan.foxydroid.entity.Repository
import nya.kitsunyan.foxydroid.network.Downloader
import nya.kitsunyan.foxydroid.service.Connection
import nya.kitsunyan.foxydroid.service.SyncService
import nya.kitsunyan.foxydroid.utility.RxUtils
import nya.kitsunyan.foxydroid.utility.Utils
import nya.kitsunyan.foxydroid.utility.extension.resources.*
import nya.kitsunyan.foxydroid.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(): Fragment() {
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.onFragmentViewCreated(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_is_not_specified)
}
layout.username.setError(usernameEmpty)
if (passwordEmpty) {
layout.passwordError.setText(R.string.password_is_not_specified)
}
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_the_mirror)
.setItems(mirrors.toTypedArray()) { _, position -> (parentFragment as EditRepositoryFragment)
.setMirror(mirrors[position]) }
.setNegativeButton(R.string.cancel, null)
.create()
}
}
}