mirror of
https://github.com/Michatec/michas-droid.git
synced 2026-05-31 02:12:42 +02:00
Initial commit
This commit is contained in:
@@ -0,0 +1,485 @@
|
||||
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.Binder>(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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user