Made synchronization server address configurable

This commit is contained in:
Koitharu
2023-05-07 19:02:43 +03:00
parent 07aa04aa4d
commit 1ead369ee2
23 changed files with 425 additions and 165 deletions

View File

@@ -95,6 +95,7 @@
<data android:scheme="kotatsu" /> <data android:scheme="kotatsu" />
<data android:host="about" /> <data android:host="about" />
<data android:host="sync-settings" />
</intent-filter> </intent-filter>
</activity> </activity>
<activity <activity

View File

@@ -388,6 +388,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_EXIT_CONFIRM = "exit_confirm" const val KEY_EXIT_CONFIRM = "exit_confirm"
const val KEY_INCOGNITO_MODE = "incognito" const val KEY_INCOGNITO_MODE = "incognito"
const val KEY_SYNC = "sync" const val KEY_SYNC = "sync"
const val KEY_SYNC_SETTINGS = "sync_settings"
const val KEY_READER_BAR = "reader_bar" const val KEY_READER_BAR = "reader_bar"
const val KEY_READER_SLIDER = "reader_slider" const val KEY_READER_SLIDER = "reader_slider"
const val KEY_SHORTCUTS = "dynamic_shortcuts" const val KEY_SHORTCUTS = "dynamic_shortcuts"

View File

@@ -1,20 +1,17 @@
package org.koitharu.kotatsu.settings package org.koitharu.kotatsu.settings
import okhttp3.HttpUrl import okhttp3.HttpUrl
import okhttp3.internal.toCanonicalHost
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.utils.EditTextValidator import org.koitharu.kotatsu.utils.EditTextValidator
class DomainValidator : EditTextValidator() { class DomainValidator : EditTextValidator() {
private val urlBuilder = HttpUrl.Builder()
override fun validate(text: String): ValidationResult { override fun validate(text: String): ValidationResult {
val trimmed = text.trim() val trimmed = text.trim()
if (trimmed.isEmpty()) { if (trimmed.isEmpty()) {
return ValidationResult.Success return ValidationResult.Success
} }
return if (!checkCharacters(trimmed) || trimmed.toCanonicalHost() == null) { return if (!checkCharacters(trimmed)) {
ValidationResult.Failed(context.getString(R.string.invalid_domain_message)) ValidationResult.Failed(context.getString(R.string.invalid_domain_message))
} else { } else {
ValidationResult.Success ValidationResult.Success
@@ -22,6 +19,12 @@ class DomainValidator : EditTextValidator() {
} }
private fun checkCharacters(value: String): Boolean = runCatching { private fun checkCharacters(value: String): Boolean = runCatching {
urlBuilder.host(value) val parts = value.split(':')
require(parts.size <= 2)
val urlBuilder = HttpUrl.Builder()
urlBuilder.host(parts.first())
if (parts.size == 2) {
urlBuilder.port(parts[1].toInt())
}
}.isSuccess }.isSuccess
} }

View File

@@ -153,6 +153,7 @@ class ServicesSettingsFragment : BasePreferenceFragment(R.string.services) {
else -> getString(R.string.disabled) else -> getString(R.string.disabled)
} }
} }
findPreference<Preference>(AppSettings.KEY_SYNC_SETTINGS)?.isEnabled = account != null
} }
} }
} }

View File

@@ -137,6 +137,7 @@ class SettingsActivity :
Intent.ACTION_VIEW -> { Intent.ACTION_VIEW -> {
when (intent.data?.host) { when (intent.data?.host) {
HOST_ABOUT -> AboutSettingsFragment() HOST_ABOUT -> AboutSettingsFragment()
HOST_SYNC_SETTINGS -> SyncSettingsFragment()
else -> SettingsHeadersFragment() else -> SettingsHeadersFragment()
} }
} }
@@ -159,6 +160,7 @@ class SettingsActivity :
private const val ACTION_MANAGE_SOURCES = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SOURCES_LIST" private const val ACTION_MANAGE_SOURCES = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SOURCES_LIST"
private const val EXTRA_SOURCE = "source" private const val EXTRA_SOURCE = "source"
private const val HOST_ABOUT = "about" private const val HOST_ABOUT = "about"
private const val HOST_SYNC_SETTINGS = "sync-settings"
fun newIntent(context: Context) = Intent(context, SettingsActivity::class.java) fun newIntent(context: Context) = Intent(context, SettingsActivity::class.java)

View File

@@ -0,0 +1,49 @@
package org.koitharu.kotatsu.settings
import android.os.Bundle
import android.view.View
import androidx.fragment.app.FragmentResultListener
import androidx.preference.Preference
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
import org.koitharu.kotatsu.sync.data.SyncSettings
import org.koitharu.kotatsu.sync.ui.SyncHostDialogFragment
import javax.inject.Inject
@AndroidEntryPoint
class SyncSettingsFragment : BasePreferenceFragment(R.string.sync_settings), FragmentResultListener {
@Inject
lateinit var syncSettings: SyncSettings
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_sync)
bindHostSummary()
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
childFragmentManager.setFragmentResultListener(SyncHostDialogFragment.REQUEST_KEY, viewLifecycleOwner, this)
}
override fun onPreferenceTreeClick(preference: Preference): Boolean {
return when (preference.key) {
SyncSettings.KEY_HOST -> {
SyncHostDialogFragment.show(childFragmentManager)
true
}
else -> super.onPreferenceTreeClick(preference)
}
}
override fun onFragmentResult(requestKey: String, result: Bundle) {
bindHostSummary()
}
private fun bindHostSummary() {
val preference = findPreference<Preference>(SyncSettings.KEY_HOST) ?: return
preference.summary = syncSettings.host
}
}

View File

@@ -10,6 +10,7 @@ import android.widget.EditText
import androidx.annotation.ArrayRes import androidx.annotation.ArrayRes
import androidx.annotation.AttrRes import androidx.annotation.AttrRes
import androidx.annotation.StyleRes import androidx.annotation.StyleRes
import androidx.core.content.withStyledAttributes
import androidx.preference.EditTextPreference import androidx.preference.EditTextPreference
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
@@ -25,6 +26,12 @@ class AutoCompleteTextViewPreference @JvmOverloads constructor(
init { init {
super.setOnBindEditTextListener(autoCompleteBindListener) super.setOnBindEditTextListener(autoCompleteBindListener)
context.withStyledAttributes(attrs, R.styleable.AutoCompleteTextViewPreference, defStyleAttr, defStyleRes) {
val entriesId = getResourceId(R.styleable.AutoCompleteTextViewPreference_android_entries, 0)
if (entriesId != 0) {
setEntries(entriesId)
}
}
} }
fun setEntries(@ArrayRes arrayResId: Int) { fun setEntries(@ArrayRes arrayResId: Int) {
@@ -55,4 +62,4 @@ class AutoCompleteTextViewPreference @JvmOverloads constructor(
} }
} }
} }
} }

View File

@@ -1,11 +1,9 @@
package org.koitharu.kotatsu.sync.data package org.koitharu.kotatsu.sync.data
import android.content.Context import dagger.Reusable
import dagger.hilt.android.qualifiers.ApplicationContext
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import org.json.JSONObject import org.json.JSONObject
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.SyncApiException import org.koitharu.kotatsu.core.exceptions.SyncApiException
import org.koitharu.kotatsu.parsers.util.await import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.parsers.util.parseJson import org.koitharu.kotatsu.parsers.util.parseJson
@@ -13,19 +11,17 @@ import org.koitharu.kotatsu.parsers.util.removeSurrounding
import org.koitharu.kotatsu.utils.ext.toRequestBody import org.koitharu.kotatsu.utils.ext.toRequestBody
import javax.inject.Inject import javax.inject.Inject
@Reusable
class SyncAuthApi @Inject constructor( class SyncAuthApi @Inject constructor(
@ApplicationContext context: Context,
private val okHttpClient: OkHttpClient, private val okHttpClient: OkHttpClient,
) { ) {
private val baseUrl = context.getString(R.string.url_sync_server) suspend fun authenticate(host: String, email: String, password: String): String {
suspend fun authenticate(email: String, password: String): String {
val body = JSONObject( val body = JSONObject(
mapOf("email" to email, "password" to password), mapOf("email" to email, "password" to password),
).toRequestBody() ).toRequestBody()
val request = Request.Builder() val request = Request.Builder()
.url("$baseUrl/auth") .url("http://$host/auth")
.post(body) .post(body)
.build() .build()
val response = okHttpClient.newCall(request).await() val response = okHttpClient.newCall(request).await()

View File

@@ -13,6 +13,7 @@ import org.koitharu.kotatsu.R
class SyncAuthenticator( class SyncAuthenticator(
context: Context, context: Context,
private val account: Account, private val account: Account,
private val syncSettings: SyncSettings,
private val authApi: SyncAuthApi, private val authApi: SyncAuthApi,
) : Authenticator { ) : Authenticator {
@@ -30,6 +31,7 @@ class SyncAuthenticator(
private fun tryRefreshToken() = runCatching { private fun tryRefreshToken() = runCatching {
runBlocking { runBlocking {
authApi.authenticate( authApi.authenticate(
syncSettings.host,
account.name, account.name,
accountManager.getPassword(account), accountManager.getPassword(account),
) )

View File

@@ -0,0 +1,46 @@
package org.koitharu.kotatsu.sync.data
import android.accounts.Account
import android.accounts.AccountManager
import android.content.Context
import androidx.annotation.WorkerThread
import dagger.Reusable
import dagger.hilt.android.qualifiers.ApplicationContext
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.utils.ext.ifNullOrEmpty
import javax.inject.Inject
@Reusable
class SyncSettings(
context: Context,
private val account: Account?,
) {
@Inject
constructor(@ApplicationContext context: Context) : this(
context,
AccountManager.get(context)
.getAccountsByType(context.getString(R.string.account_type_sync))
.firstOrNull(),
)
private val accountManager = AccountManager.get(context)
private val defaultHost = context.getString(R.string.sync_host_default)
@get:WorkerThread
@set:WorkerThread
var host: String
get() = account?.let {
accountManager.getUserData(it, KEY_HOST)
}.ifNullOrEmpty { defaultHost }
set(value) {
account?.let {
accountManager.setUserData(it, KEY_HOST, value)
}
}
companion object {
const val KEY_HOST = "host"
}
}

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.sync.domain package org.koitharu.kotatsu.sync.domain
class SyncAuthResult( class SyncAuthResult(
val host: String,
val email: String, val email: String,
val password: String, val password: String,
val token: String, val token: String,
@@ -12,17 +13,17 @@ class SyncAuthResult(
other as SyncAuthResult other as SyncAuthResult
if (host != other.host) return false
if (email != other.email) return false if (email != other.email) return false
if (password != other.password) return false if (password != other.password) return false
if (token != other.token) return false return token == other.token
return true
} }
override fun hashCode(): Int { override fun hashCode(): Int {
var result = email.hashCode() var result = host.hashCode()
result = 31 * result + email.hashCode()
result = 31 * result + password.hashCode() result = 31 * result + password.hashCode()
result = 31 * result + token.hashCode() result = 31 * result + token.hashCode()
return result return result
} }
} }

View File

@@ -26,6 +26,7 @@ import org.koitharu.kotatsu.parsers.util.json.mapJSONTo
import org.koitharu.kotatsu.sync.data.SyncAuthApi import org.koitharu.kotatsu.sync.data.SyncAuthApi
import org.koitharu.kotatsu.sync.data.SyncAuthenticator import org.koitharu.kotatsu.sync.data.SyncAuthenticator
import org.koitharu.kotatsu.sync.data.SyncInterceptor import org.koitharu.kotatsu.sync.data.SyncInterceptor
import org.koitharu.kotatsu.sync.data.SyncSettings
import org.koitharu.kotatsu.utils.GZipInterceptor import org.koitharu.kotatsu.utils.GZipInterceptor
import org.koitharu.kotatsu.utils.ext.parseJsonOrNull import org.koitharu.kotatsu.utils.ext.parseJsonOrNull
import org.koitharu.kotatsu.utils.ext.toContentValues import org.koitharu.kotatsu.utils.ext.toContentValues
@@ -41,18 +42,20 @@ private const val FIELD_TIMESTAMP = "timestamp"
@WorkerThread @WorkerThread
class SyncHelper( class SyncHelper(
context: Context, context: Context,
account: Account, private val account: Account,
private val provider: ContentProviderClient, private val provider: ContentProviderClient,
) { ) {
private val authorityHistory = context.getString(R.string.sync_authority_history) private val authorityHistory = context.getString(R.string.sync_authority_history)
private val authorityFavourites = context.getString(R.string.sync_authority_favourites) private val authorityFavourites = context.getString(R.string.sync_authority_favourites)
private val settings = SyncSettings(context, account)
private val httpClient = OkHttpClient.Builder() private val httpClient = OkHttpClient.Builder()
.authenticator(SyncAuthenticator(context, account, SyncAuthApi(context, OkHttpClient()))) .authenticator(SyncAuthenticator(context, account, settings, SyncAuthApi(OkHttpClient())))
.addInterceptor(SyncInterceptor(context, account)) .addInterceptor(SyncInterceptor(context, account))
.addInterceptor(GZipInterceptor()) .addInterceptor(GZipInterceptor())
.build() .build()
private val baseUrl = context.getString(R.string.url_sync_server) private val baseUrl: String
get() = "http://${settings.host}"
private val defaultGcPeriod: Long // gc period if sync enabled private val defaultGcPeriod: Long // gc period if sync enabled
get() = TimeUnit.DAYS.toMillis(4) get() = TimeUnit.DAYS.toMillis(4)

View File

@@ -11,8 +11,8 @@ import android.widget.Button
import androidx.activity.OnBackPressedCallback import androidx.activity.OnBackPressedCallback
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.view.isGone
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.fragment.app.FragmentResultListener
import androidx.transition.Fade import androidx.transition.Fade
import androidx.transition.TransitionManager import androidx.transition.TransitionManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
@@ -20,12 +20,13 @@ import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseActivity import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.databinding.ActivitySyncAuthBinding import org.koitharu.kotatsu.databinding.ActivitySyncAuthBinding
import org.koitharu.kotatsu.sync.data.SyncSettings
import org.koitharu.kotatsu.sync.domain.SyncAuthResult import org.koitharu.kotatsu.sync.domain.SyncAuthResult
import org.koitharu.kotatsu.utils.ext.getDisplayMessage import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.getParcelableExtraCompat import org.koitharu.kotatsu.utils.ext.getParcelableExtraCompat
@AndroidEntryPoint @AndroidEntryPoint
class SyncAuthActivity : BaseActivity<ActivitySyncAuthBinding>(), View.OnClickListener { class SyncAuthActivity : BaseActivity<ActivitySyncAuthBinding>(), View.OnClickListener, FragmentResultListener {
private var accountAuthenticatorResponse: AccountAuthenticatorResponse? = null private var accountAuthenticatorResponse: AccountAuthenticatorResponse? = null
private var resultBundle: Bundle? = null private var resultBundle: Bundle? = null
@@ -43,6 +44,8 @@ class SyncAuthActivity : BaseActivity<ActivitySyncAuthBinding>(), View.OnClickLi
binding.buttonNext.setOnClickListener(this) binding.buttonNext.setOnClickListener(this)
binding.buttonBack.setOnClickListener(this) binding.buttonBack.setOnClickListener(this)
binding.buttonDone.setOnClickListener(this) binding.buttonDone.setOnClickListener(this)
binding.layoutProgress.setOnClickListener(this)
binding.buttonSettings.setOnClickListener(this)
binding.editEmail.addTextChangedListener(EmailTextWatcher(binding.buttonNext)) binding.editEmail.addTextChangedListener(EmailTextWatcher(binding.buttonNext))
binding.editPassword.addTextChangedListener(PasswordTextWatcher(binding.buttonDone)) binding.editPassword.addTextChangedListener(PasswordTextWatcher(binding.buttonDone))
@@ -52,6 +55,7 @@ class SyncAuthActivity : BaseActivity<ActivitySyncAuthBinding>(), View.OnClickLi
viewModel.onError.observe(this, ::onError) viewModel.onError.observe(this, ::onError)
viewModel.isLoading.observe(this, ::onLoadingStateChanged) viewModel.isLoading.observe(this, ::onLoadingStateChanged)
supportFragmentManager.setFragmentResultListener(SyncHostDialogFragment.REQUEST_KEY, this, this)
pageBackCallback.update() pageBackCallback.update()
} }
@@ -73,12 +77,14 @@ class SyncAuthActivity : BaseActivity<ActivitySyncAuthBinding>(), View.OnClickLi
} }
R.id.button_next -> { R.id.button_next -> {
binding.switcher.showNext() binding.groupLogin.isVisible = false
binding.groupPassword.isVisible = true
pageBackCallback.update() pageBackCallback.update()
} }
R.id.button_back -> { R.id.button_back -> {
binding.switcher.showPrevious() binding.groupPassword.isVisible = false
binding.groupLogin.isVisible = true
pageBackCallback.update() pageBackCallback.update()
} }
@@ -88,9 +94,18 @@ class SyncAuthActivity : BaseActivity<ActivitySyncAuthBinding>(), View.OnClickLi
password = binding.editPassword.text.toString(), password = binding.editPassword.text.toString(),
) )
} }
R.id.button_settings -> {
SyncHostDialogFragment.show(supportFragmentManager)
}
} }
} }
override fun onFragmentResult(requestKey: String, result: Bundle) {
val host = result.getString(SyncHostDialogFragment.KEY_HOST) ?: return
viewModel.host.value = host
}
override fun finish() { override fun finish() {
accountAuthenticatorResponse?.let { response -> accountAuthenticatorResponse?.let { response ->
resultBundle?.also { resultBundle?.also {
@@ -105,7 +120,6 @@ class SyncAuthActivity : BaseActivity<ActivitySyncAuthBinding>(), View.OnClickLi
return return
} }
TransitionManager.beginDelayedTransition(binding.root, Fade()) TransitionManager.beginDelayedTransition(binding.root, Fade())
binding.switcher.isGone = isLoading
binding.layoutProgress.isVisible = isLoading binding.layoutProgress.isVisible = isLoading
pageBackCallback.update() pageBackCallback.update()
} }
@@ -121,8 +135,10 @@ class SyncAuthActivity : BaseActivity<ActivitySyncAuthBinding>(), View.OnClickLi
private fun onTokenReceived(authResult: SyncAuthResult) { private fun onTokenReceived(authResult: SyncAuthResult) {
val am = AccountManager.get(this) val am = AccountManager.get(this)
val account = Account(authResult.email, getString(R.string.account_type_sync)) val account = Account(authResult.email, getString(R.string.account_type_sync))
val userdata = Bundle(1)
userdata.putString(SyncSettings.KEY_HOST, authResult.host)
val result = Bundle() val result = Bundle()
if (am.addAccountExplicitly(account, authResult.password, Bundle())) { if (am.addAccountExplicitly(account, authResult.password, userdata)) {
result.putString(AccountManager.KEY_ACCOUNT_NAME, account.name) result.putString(AccountManager.KEY_ACCOUNT_NAME, account.name)
result.putString(AccountManager.KEY_ACCOUNT_TYPE, account.type) result.putString(AccountManager.KEY_ACCOUNT_TYPE, account.type)
result.putString(AccountManager.KEY_AUTHTOKEN, authResult.token) result.putString(AccountManager.KEY_AUTHTOKEN, authResult.token)
@@ -168,12 +184,13 @@ class SyncAuthActivity : BaseActivity<ActivitySyncAuthBinding>(), View.OnClickLi
private inner class PageBackCallback : OnBackPressedCallback(false) { private inner class PageBackCallback : OnBackPressedCallback(false) {
override fun handleOnBackPressed() { override fun handleOnBackPressed() {
binding.switcher.showPrevious() binding.groupLogin.isVisible = true
binding.groupPassword.isVisible = false
update() update()
} }
fun update() { fun update() {
isEnabled = binding.switcher.isVisible && binding.switcher.displayedChild > 0 isEnabled = !binding.layoutProgress.isVisible && binding.groupPassword.isVisible
} }
} }
} }

View File

@@ -1,24 +1,34 @@
package org.koitharu.kotatsu.sync.ui package org.koitharu.kotatsu.sync.ui
import android.content.Context
import androidx.lifecycle.MutableLiveData
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseViewModel import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.sync.data.SyncAuthApi import org.koitharu.kotatsu.sync.data.SyncAuthApi
import org.koitharu.kotatsu.sync.domain.SyncAuthResult import org.koitharu.kotatsu.sync.domain.SyncAuthResult
import org.koitharu.kotatsu.utils.SingleLiveEvent import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.ifNullOrEmpty
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class SyncAuthViewModel @Inject constructor( class SyncAuthViewModel @Inject constructor(
@ApplicationContext context: Context,
private val api: SyncAuthApi, private val api: SyncAuthApi,
) : BaseViewModel() { ) : BaseViewModel() {
val onTokenObtained = SingleLiveEvent<SyncAuthResult>() val onTokenObtained = SingleLiveEvent<SyncAuthResult>()
val host = MutableLiveData("")
private val defaultHost = context.getString(R.string.sync_host_default)
fun obtainToken(email: String, password: String) { fun obtainToken(email: String, password: String) {
val hostValue = host.value.ifNullOrEmpty { defaultHost }
launchLoadingJob(Dispatchers.Default) { launchLoadingJob(Dispatchers.Default) {
val token = api.authenticate(email, password) val token = api.authenticate(hostValue, email, password)
val result = SyncAuthResult(email, password, token) val result = SyncAuthResult(host.value.orEmpty(), email, password, token)
onTokenObtained.emitCall(result) onTokenObtained.emitCall(result)
} }
} }

View File

@@ -0,0 +1,79 @@
package org.koitharu.kotatsu.sync.ui
import android.content.DialogInterface
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.ViewGroup.MarginLayoutParams
import android.widget.ArrayAdapter
import androidx.core.os.bundleOf
import androidx.core.view.updateLayoutParams
import androidx.fragment.app.FragmentManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.AlertDialogFragment
import org.koitharu.kotatsu.databinding.PreferenceDialogAutocompletetextviewBinding
import org.koitharu.kotatsu.settings.DomainValidator
import org.koitharu.kotatsu.sync.data.SyncSettings
import javax.inject.Inject
@AndroidEntryPoint
class SyncHostDialogFragment : AlertDialogFragment<PreferenceDialogAutocompletetextviewBinding>(),
DialogInterface.OnClickListener {
@Inject
lateinit var syncSettings: SyncSettings
override fun onInflateView(
inflater: LayoutInflater,
container: ViewGroup?
) = PreferenceDialogAutocompletetextviewBinding.inflate(inflater, container, false)
override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder {
return super.onBuildDialog(builder)
.setPositiveButton(android.R.string.ok, this)
.setNegativeButton(android.R.string.cancel, this)
.setCancelable(false)
.setTitle(R.string.server_address)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.message.updateLayoutParams<MarginLayoutParams> {
topMargin = view.resources.getDimensionPixelOffset(R.dimen.screen_padding)
bottomMargin = topMargin
}
binding.message.setText(R.string.sync_host_description)
val entries = view.resources.getStringArray(R.array.sync_host_list)
val editText = binding.edit
editText.setText(syncSettings.host)
editText.threshold = 0
editText.setAdapter(ArrayAdapter(view.context, android.R.layout.simple_spinner_dropdown_item, entries))
binding.dropdown.setOnClickListener {
editText.showDropDown()
}
DomainValidator().attachToEditText(editText)
}
override fun onClick(dialog: DialogInterface, which: Int) {
when (which) {
DialogInterface.BUTTON_POSITIVE -> {
val result = binding.edit.text?.toString().orEmpty()
syncSettings.host = result
parentFragmentManager.setFragmentResult(REQUEST_KEY, bundleOf(KEY_HOST to result))
}
}
dialog.dismiss()
}
companion object {
private const val TAG = "SyncHostDialogFragment"
const val REQUEST_KEY = "sync_host"
const val KEY_HOST = "host"
fun show(fm: FragmentManager) = SyncHostDialogFragment().show(fm, TAG)
}
}

View File

@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<LinearLayout <androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
@@ -10,9 +10,8 @@
<TextView <TextView
android:id="@+id/textView_title" android:id="@+id/textView_title"
android:layout_width="match_parent" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:drawablePadding="16dp" android:drawablePadding="16dp"
android:gravity="center_horizontal" android:gravity="center_horizontal"
android:text="@string/sync_title" android:text="@string/sync_title"
@@ -23,150 +22,157 @@
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" /> app:layout_constraintTop_toTopOf="parent" />
<ViewSwitcher <ImageButton
android:id="@+id/switcher" android:id="@+id/button_settings"
android:layout_width="match_parent" android:layout_width="wrap_content"
android:layout_height="match_parent"> android:layout_height="wrap_content"
android:background="?selectableItemBackgroundBorderless"
android:padding="4dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_settings" />
<RelativeLayout <TextView
android:id="@+id/page_email" android:id="@+id/textView_subtitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:gravity="center_horizontal"
android:text="@string/email_enter_hint"
android:textAppearance="?textAppearanceSubtitle1"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/textView_title" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/layout_email"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="30dp"
app:errorIconDrawable="@null"
app:helperText="@string/sync_auth_hint"
app:hintEnabled="false"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/textView_subtitle">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/edit_email"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent"> android:layout_height="wrap_content"
android:autofillHints="emailAddress"
android:imeOptions="actionDone"
android:inputType="textEmailAddress"
android:singleLine="true"
android:textSize="16sp"
tools:text="test@mail.com" />
<TextView </com.google.android.material.textfield.TextInputLayout>
android:id="@+id/textView_subtitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_alignParentStart="true"
android:layout_alignParentTop="true"
android:layout_alignParentEnd="true"
android:layout_marginTop="12dp"
android:gravity="center_horizontal"
android:text="@string/email_enter_hint"
android:textAppearance="?textAppearanceSubtitle1" />
<com.google.android.material.textfield.TextInputLayout <Button
android:id="@+id/layout_email" android:id="@+id/button_cancel"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox" style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_below="@id/textView_subtitle" android:text="@android:string/cancel"
android:layout_alignParentStart="true" app:layout_constraintBottom_toBottomOf="parent"
android:layout_alignParentEnd="true" app:layout_constraintStart_toStartOf="parent" />
android:layout_marginTop="30dp"
app:errorIconDrawable="@null"
app:helperText="@string/sync_auth_hint"
app:hintEnabled="false">
<com.google.android.material.textfield.TextInputEditText <Button
android:id="@+id/edit_email" android:id="@+id/button_next"
android:layout_width="match_parent" style="@style/Widget.Material3.Button.TonalButton"
android:layout_height="wrap_content" android:layout_width="0dp"
android:autofillHints="emailAddress" android:layout_height="wrap_content"
android:imeOptions="actionDone" android:enabled="false"
android:inputType="textEmailAddress" android:text="@string/next"
android:singleLine="true" app:layout_constraintBottom_toBottomOf="parent"
android:textSize="16sp" app:layout_constraintEnd_toEndOf="parent" />
tools:text="test@mail.com" />
</com.google.android.material.textfield.TextInputLayout> <TextView
android:id="@+id/textView_subtitle_2"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:gravity="center_horizontal"
android:text="@string/enter_password"
android:textAppearance="?textAppearanceSubtitle1"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/textView_title" />
<Button <com.google.android.material.textfield.TextInputLayout
android:id="@+id/button_cancel" android:id="@+id/layout_password"
style="@style/Widget.Material3.Button.OutlinedButton" style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="wrap_content" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_alignParentStart="true" android:layout_marginTop="30dp"
android:layout_alignParentBottom="true" app:endIconMode="password_toggle"
android:text="@android:string/cancel" /> app:errorIconDrawable="@null"
app:hintEnabled="false"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/textView_subtitle_2">
<Button <com.google.android.material.textfield.TextInputEditText
android:id="@+id/button_next" android:id="@+id/edit_password"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:layout_alignParentBottom="true"
android:enabled="false"
android:text="@string/next"
tools:ignore="RelativeOverlap" />
</RelativeLayout>
<RelativeLayout
android:id="@+id/page_password"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent"> android:layout_height="wrap_content"
android:autofillHints="password"
android:imeOptions="actionDone"
android:inputType="textPassword"
android:maxLength="24"
android:singleLine="true"
android:textSize="16sp"
tools:text="qwerty" />
<TextView </com.google.android.material.textfield.TextInputLayout>
android:id="@+id/textView_subtitle_2"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_alignParentStart="true"
android:layout_alignParentTop="true"
android:layout_alignParentEnd="true"
android:layout_marginTop="12dp"
android:gravity="center_horizontal"
android:text="@string/enter_password"
android:textAppearance="?textAppearanceSubtitle1" />
<com.google.android.material.textfield.TextInputLayout <Button
android:id="@+id/layout_password" android:id="@+id/button_back"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox" style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_below="@id/textView_subtitle_2" android:text="@string/back"
android:layout_alignParentStart="true" app:layout_constraintBottom_toBottomOf="parent"
android:layout_alignParentEnd="true" app:layout_constraintStart_toStartOf="parent" />
android:layout_marginTop="30dp"
app:endIconMode="password_toggle"
app:errorIconDrawable="@null"
app:hintEnabled="false">
<com.google.android.material.textfield.TextInputEditText <Button
android:id="@+id/edit_password" android:id="@+id/button_done"
android:layout_width="match_parent" style="@style/Widget.Material3.Button.TonalButton"
android:layout_height="wrap_content" android:layout_width="wrap_content"
android:autofillHints="password" android:layout_height="wrap_content"
android:imeOptions="actionDone" android:enabled="false"
android:inputType="textPassword" android:text="@string/done"
android:maxLength="24" app:layout_constraintBottom_toBottomOf="parent"
android:singleLine="true" app:layout_constraintEnd_toEndOf="parent" />
android:textSize="16sp"
tools:text="qwerty" />
</com.google.android.material.textfield.TextInputLayout> <androidx.constraintlayout.widget.Group
android:id="@+id/group_login"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:constraint_referenced_ids="textView_subtitle,button_cancel,button_next,layout_email" />
<Button <androidx.constraintlayout.widget.Group
android:id="@+id/button_back" android:id="@+id/group_password"
style="@style/Widget.Material3.Button.OutlinedButton" android:layout_width="wrap_content"
android:layout_width="wrap_content" android:layout_height="wrap_content"
android:layout_height="wrap_content" android:visibility="gone"
android:layout_alignParentStart="true" app:constraint_referenced_ids="textView_subtitle_2,button_back,button_done,layout_password" />
android:layout_alignParentBottom="true"
android:text="@string/back" />
<Button
android:id="@+id/button_done"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:layout_alignParentBottom="true"
android:enabled="false"
android:text="@string/done"
tools:ignore="RelativeOverlap" />
</RelativeLayout>
</ViewSwitcher>
<FrameLayout <FrameLayout
android:id="@+id/layout_progress" android:id="@+id/layout_progress"
android:layout_width="match_parent" android:layout_width="0dp"
android:layout_height="match_parent" android:layout_height="0dp"
android:visibility="gone"> android:background="?android:windowBackground"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/textView_title">
<com.google.android.material.progressindicator.CircularProgressIndicator <com.google.android.material.progressindicator.CircularProgressIndicator
android:id="@+id/circularProgressIndicator"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="center" android:layout_gravity="center"
@@ -174,4 +180,4 @@
</FrameLayout> </FrameLayout>
</LinearLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -103,4 +103,8 @@
<attr name="cornerSizeBottomRight" /> <attr name="cornerSizeBottomRight" />
</declare-styleable> </declare-styleable>
<declare-styleable name="AutoCompleteTextViewPreference">
<attr name="android:entries" />
</declare-styleable>
</resources> </resources>

View File

@@ -7,7 +7,7 @@
<string name="url_weblate" translatable="false">https://hosted.weblate.org/engage/kotatsu</string> <string name="url_weblate" translatable="false">https://hosted.weblate.org/engage/kotatsu</string>
<string name="url_error_report" translatable="false">https://acra.kotatsu.app/report</string> <string name="url_error_report" translatable="false">https://acra.kotatsu.app/report</string>
<string name="account_type_sync" translatable="false">org.kotatsu.sync</string> <string name="account_type_sync" translatable="false">org.kotatsu.sync</string>
<string name="url_sync_server" translatable="false">https://sync.kotatsu.app</string> <string name="sync_host_default" translatable="false">sync.kotatsu.app</string>
<string name="shikimori_clientId" translatable="false">Mw6F0tPEOgyV7F9U9Twg50Q8SndMY7hzIOfXg0AX_XU</string> <string name="shikimori_clientId" translatable="false">Mw6F0tPEOgyV7F9U9Twg50Q8SndMY7hzIOfXg0AX_XU</string>
<string name="shikimori_clientSecret" translatable="false">euBMt1GGRSDpVIFQVPxZrO7Kh6X4gWyv0dABuj4B-M8</string> <string name="shikimori_clientSecret" translatable="false">euBMt1GGRSDpVIFQVPxZrO7Kh6X4gWyv0dABuj4B-M8</string>
<string name="anilist_clientId" translatable="false">9887</string> <string name="anilist_clientId" translatable="false">9887</string>
@@ -41,9 +41,13 @@
<item>block_nsfw</item> <item>block_nsfw</item>
<item>block_all</item> <item>block_all</item>
</string-array> </string-array>
<string-array name="values_network_policy"> <string-array name="values_network_policy" translatable="false">
<item>1</item> <item>1</item>
<item>2</item> <item>2</item>
<item>0</item> <item>0</item>
</string-array> </string-array>
<string-array name="sync_host_list" translatable="false">
<item>@string/sync_host_default</item>
<item>86.57.183.214:8081</item>
</string-array>
</resources> </resources>

View File

@@ -435,4 +435,7 @@
<string name="show_on_shelf">Show on the Shelf</string> <string name="show_on_shelf">Show on the Shelf</string>
<string name="sync_auth_hint">You can sign in into an existing account or create a new one</string> <string name="sync_auth_hint">You can sign in into an existing account or create a new one</string>
<string name="find_similar">Find similar</string> <string name="find_similar">Find similar</string>
<string name="sync_settings">Synchronization settings</string>
<string name="server_address">Server address</string>
<string name="sync_host_description">You can use a self-hosted synchronization server or a default one. Don\'t change this if you\'re not sure what you\'re doing.</string>
</resources> </resources>

View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<account-authenticator <account-authenticator
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
android:accountPreferences="@xml/pref_sync" android:accountPreferences="@xml/pref_sync_header"
android:accountType="@string/account_type_sync" android:accountType="@string/account_type_sync"
android:icon="@mipmap/ic_launcher_round" android:icon="@mipmap/ic_launcher_round"
android:label="@string/app_name" /> android:label="@string/app_name" />

View File

@@ -10,6 +10,12 @@
android:summary="@string/sync_title" android:summary="@string/sync_title"
android:title="@string/sync" /> android:title="@string/sync" />
<PreferenceScreen
android:enabled="false"
android:fragment="org.koitharu.kotatsu.settings.SyncSettingsFragment"
android:key="sync_settings"
android:title="@string/sync_settings" />
<PreferenceCategory android:title="@string/tracking"> <PreferenceCategory android:title="@string/tracking">
<Preference <Preference

View File

@@ -1,2 +1,9 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen /> <PreferenceScreen
xmlns:android="http://schemas.android.com/apk/res/android">
<Preference
android:key="host"
android:title="@string/server_address" />
</PreferenceScreen>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen
xmlns:android="http://schemas.android.com/apk/res/android">
<Preference android:title="@string/sync_settings">
<intent
android:action="android.intent.action.VIEW"
android:data="kotatsu://sync-settings" />
</Preference>
</PreferenceScreen>