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:host="about" />
<data android:host="sync-settings" />
</intent-filter>
</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_INCOGNITO_MODE = "incognito"
const val KEY_SYNC = "sync"
const val KEY_SYNC_SETTINGS = "sync_settings"
const val KEY_READER_BAR = "reader_bar"
const val KEY_READER_SLIDER = "reader_slider"
const val KEY_SHORTCUTS = "dynamic_shortcuts"

View File

@@ -1,20 +1,17 @@
package org.koitharu.kotatsu.settings
import okhttp3.HttpUrl
import okhttp3.internal.toCanonicalHost
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.utils.EditTextValidator
class DomainValidator : EditTextValidator() {
private val urlBuilder = HttpUrl.Builder()
override fun validate(text: String): ValidationResult {
val trimmed = text.trim()
if (trimmed.isEmpty()) {
return ValidationResult.Success
}
return if (!checkCharacters(trimmed) || trimmed.toCanonicalHost() == null) {
return if (!checkCharacters(trimmed)) {
ValidationResult.Failed(context.getString(R.string.invalid_domain_message))
} else {
ValidationResult.Success
@@ -22,6 +19,12 @@ class DomainValidator : EditTextValidator() {
}
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
}

View File

@@ -153,6 +153,7 @@ class ServicesSettingsFragment : BasePreferenceFragment(R.string.services) {
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 -> {
when (intent.data?.host) {
HOST_ABOUT -> AboutSettingsFragment()
HOST_SYNC_SETTINGS -> SyncSettingsFragment()
else -> SettingsHeadersFragment()
}
}
@@ -159,6 +160,7 @@ class SettingsActivity :
private const val ACTION_MANAGE_SOURCES = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SOURCES_LIST"
private const val EXTRA_SOURCE = "source"
private const val HOST_ABOUT = "about"
private const val HOST_SYNC_SETTINGS = "sync-settings"
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.AttrRes
import androidx.annotation.StyleRes
import androidx.core.content.withStyledAttributes
import androidx.preference.EditTextPreference
import org.koitharu.kotatsu.R
@@ -25,6 +26,12 @@ class AutoCompleteTextViewPreference @JvmOverloads constructor(
init {
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) {
@@ -55,4 +62,4 @@ class AutoCompleteTextViewPreference @JvmOverloads constructor(
}
}
}
}
}

View File

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

View File

@@ -13,6 +13,7 @@ import org.koitharu.kotatsu.R
class SyncAuthenticator(
context: Context,
private val account: Account,
private val syncSettings: SyncSettings,
private val authApi: SyncAuthApi,
) : Authenticator {
@@ -30,6 +31,7 @@ class SyncAuthenticator(
private fun tryRefreshToken() = runCatching {
runBlocking {
authApi.authenticate(
syncSettings.host,
account.name,
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
class SyncAuthResult(
val host: String,
val email: String,
val password: String,
val token: String,
@@ -12,17 +13,17 @@ class SyncAuthResult(
other as SyncAuthResult
if (host != other.host) return false
if (email != other.email) return false
if (password != other.password) return false
if (token != other.token) return false
return true
return token == other.token
}
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 + token.hashCode()
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.SyncAuthenticator
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.ext.parseJsonOrNull
import org.koitharu.kotatsu.utils.ext.toContentValues
@@ -41,18 +42,20 @@ private const val FIELD_TIMESTAMP = "timestamp"
@WorkerThread
class SyncHelper(
context: Context,
account: Account,
private val account: Account,
private val provider: ContentProviderClient,
) {
private val authorityHistory = context.getString(R.string.sync_authority_history)
private val authorityFavourites = context.getString(R.string.sync_authority_favourites)
private val settings = SyncSettings(context, account)
private val httpClient = OkHttpClient.Builder()
.authenticator(SyncAuthenticator(context, account, SyncAuthApi(context, OkHttpClient())))
.authenticator(SyncAuthenticator(context, account, settings, SyncAuthApi(OkHttpClient())))
.addInterceptor(SyncInterceptor(context, account))
.addInterceptor(GZipInterceptor())
.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
get() = TimeUnit.DAYS.toMillis(4)

View File

@@ -11,8 +11,8 @@ import android.widget.Button
import androidx.activity.OnBackPressedCallback
import androidx.activity.viewModels
import androidx.core.graphics.Insets
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.fragment.app.FragmentResultListener
import androidx.transition.Fade
import androidx.transition.TransitionManager
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.base.ui.BaseActivity
import org.koitharu.kotatsu.databinding.ActivitySyncAuthBinding
import org.koitharu.kotatsu.sync.data.SyncSettings
import org.koitharu.kotatsu.sync.domain.SyncAuthResult
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.getParcelableExtraCompat
@AndroidEntryPoint
class SyncAuthActivity : BaseActivity<ActivitySyncAuthBinding>(), View.OnClickListener {
class SyncAuthActivity : BaseActivity<ActivitySyncAuthBinding>(), View.OnClickListener, FragmentResultListener {
private var accountAuthenticatorResponse: AccountAuthenticatorResponse? = null
private var resultBundle: Bundle? = null
@@ -43,6 +44,8 @@ class SyncAuthActivity : BaseActivity<ActivitySyncAuthBinding>(), View.OnClickLi
binding.buttonNext.setOnClickListener(this)
binding.buttonBack.setOnClickListener(this)
binding.buttonDone.setOnClickListener(this)
binding.layoutProgress.setOnClickListener(this)
binding.buttonSettings.setOnClickListener(this)
binding.editEmail.addTextChangedListener(EmailTextWatcher(binding.buttonNext))
binding.editPassword.addTextChangedListener(PasswordTextWatcher(binding.buttonDone))
@@ -52,6 +55,7 @@ class SyncAuthActivity : BaseActivity<ActivitySyncAuthBinding>(), View.OnClickLi
viewModel.onError.observe(this, ::onError)
viewModel.isLoading.observe(this, ::onLoadingStateChanged)
supportFragmentManager.setFragmentResultListener(SyncHostDialogFragment.REQUEST_KEY, this, this)
pageBackCallback.update()
}
@@ -73,12 +77,14 @@ class SyncAuthActivity : BaseActivity<ActivitySyncAuthBinding>(), View.OnClickLi
}
R.id.button_next -> {
binding.switcher.showNext()
binding.groupLogin.isVisible = false
binding.groupPassword.isVisible = true
pageBackCallback.update()
}
R.id.button_back -> {
binding.switcher.showPrevious()
binding.groupPassword.isVisible = false
binding.groupLogin.isVisible = true
pageBackCallback.update()
}
@@ -88,9 +94,18 @@ class SyncAuthActivity : BaseActivity<ActivitySyncAuthBinding>(), View.OnClickLi
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() {
accountAuthenticatorResponse?.let { response ->
resultBundle?.also {
@@ -105,7 +120,6 @@ class SyncAuthActivity : BaseActivity<ActivitySyncAuthBinding>(), View.OnClickLi
return
}
TransitionManager.beginDelayedTransition(binding.root, Fade())
binding.switcher.isGone = isLoading
binding.layoutProgress.isVisible = isLoading
pageBackCallback.update()
}
@@ -121,8 +135,10 @@ class SyncAuthActivity : BaseActivity<ActivitySyncAuthBinding>(), View.OnClickLi
private fun onTokenReceived(authResult: SyncAuthResult) {
val am = AccountManager.get(this)
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()
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_TYPE, account.type)
result.putString(AccountManager.KEY_AUTHTOKEN, authResult.token)
@@ -168,12 +184,13 @@ class SyncAuthActivity : BaseActivity<ActivitySyncAuthBinding>(), View.OnClickLi
private inner class PageBackCallback : OnBackPressedCallback(false) {
override fun handleOnBackPressed() {
binding.switcher.showPrevious()
binding.groupLogin.isVisible = true
binding.groupPassword.isVisible = false
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
import android.content.Context
import androidx.lifecycle.MutableLiveData
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.sync.data.SyncAuthApi
import org.koitharu.kotatsu.sync.domain.SyncAuthResult
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.ifNullOrEmpty
import javax.inject.Inject
@HiltViewModel
class SyncAuthViewModel @Inject constructor(
@ApplicationContext context: Context,
private val api: SyncAuthApi,
) : BaseViewModel() {
val onTokenObtained = SingleLiveEvent<SyncAuthResult>()
val host = MutableLiveData("")
private val defaultHost = context.getString(R.string.sync_host_default)
fun obtainToken(email: String, password: String) {
val hostValue = host.value.ifNullOrEmpty { defaultHost }
launchLoadingJob(Dispatchers.Default) {
val token = api.authenticate(email, password)
val result = SyncAuthResult(email, password, token)
val token = api.authenticate(hostValue, email, password)
val result = SyncAuthResult(host.value.orEmpty(), email, password, token)
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"?>
<LinearLayout
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
@@ -10,9 +10,8 @@
<TextView
android:id="@+id/textView_title"
android:layout_width="match_parent"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:drawablePadding="16dp"
android:gravity="center_horizontal"
android:text="@string/sync_title"
@@ -23,150 +22,157 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ViewSwitcher
android:id="@+id/switcher"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ImageButton
android:id="@+id/button_settings"
android:layout_width="wrap_content"
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
android:id="@+id/page_email"
<TextView
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_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
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>
<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_below="@id/textView_subtitle"
android:layout_alignParentStart="true"
android:layout_alignParentEnd="true"
android:layout_marginTop="30dp"
app:errorIconDrawable="@null"
app:helperText="@string/sync_auth_hint"
app:hintEnabled="false">
<Button
android:id="@+id/button_cancel"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@android:string/cancel"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/edit_email"
android:layout_width="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" />
<Button
android:id="@+id/button_next"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:enabled="false"
android:text="@string/next"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
</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
android:id="@+id/button_cancel"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentStart="true"
android:layout_alignParentBottom="true"
android:text="@android:string/cancel" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/layout_password"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="30dp"
app:endIconMode="password_toggle"
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
android:id="@+id/button_next"
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"
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/edit_password"
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
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>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/layout_password"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_below="@id/textView_subtitle_2"
android:layout_alignParentStart="true"
android:layout_alignParentEnd="true"
android:layout_marginTop="30dp"
app:endIconMode="password_toggle"
app:errorIconDrawable="@null"
app:hintEnabled="false">
<Button
android:id="@+id/button_back"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/back"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/edit_password"
android:layout_width="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" />
<Button
android:id="@+id/button_done"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:enabled="false"
android:text="@string/done"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
</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
android:id="@+id/button_back"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentStart="true"
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>
<androidx.constraintlayout.widget.Group
android:id="@+id/group_password"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
app:constraint_referenced_ids="textView_subtitle_2,button_back,button_done,layout_password" />
<FrameLayout
android:id="@+id/layout_progress"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone">
android:layout_width="0dp"
android:layout_height="0dp"
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
android:id="@+id/circularProgressIndicator"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
@@ -174,4 +180,4 @@
</FrameLayout>
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

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

View File

@@ -7,7 +7,7 @@
<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="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_clientSecret" translatable="false">euBMt1GGRSDpVIFQVPxZrO7Kh6X4gWyv0dABuj4B-M8</string>
<string name="anilist_clientId" translatable="false">9887</string>
@@ -41,9 +41,13 @@
<item>block_nsfw</item>
<item>block_all</item>
</string-array>
<string-array name="values_network_policy">
<string-array name="values_network_policy" translatable="false">
<item>1</item>
<item>2</item>
<item>0</item>
</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>

View File

@@ -435,4 +435,7 @@
<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="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>

View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<account-authenticator
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:icon="@mipmap/ic_launcher_round"
android:label="@string/app_name" />

View File

@@ -10,6 +10,12 @@
android:summary="@string/sync_title"
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">
<Preference

View File

@@ -1,2 +1,9 @@
<?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>