First step syncronization implementation

This commit is contained in:
Koitharu
2022-04-28 15:02:29 +03:00
parent e34acf010e
commit 837fb91133
36 changed files with 1160 additions and 23 deletions

View File

@@ -9,6 +9,10 @@
<uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.VIBRATE" /> <uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.GET_ACCOUNTS" />
<uses-permission android:name="android.permission.MANAGE_ACCOUNTS" />
<uses-permission android:name="android.permission.AUTHENTICATE_ACCOUNTS" />
<uses-permission android:name="android.permission.USE_CREDENTIALS" />
<application <application
android:name="org.koitharu.kotatsu.KotatsuApp" android:name="org.koitharu.kotatsu.KotatsuApp"
@@ -53,7 +57,8 @@
<activity <activity
android:name="org.koitharu.kotatsu.search.ui.SearchActivity" android:name="org.koitharu.kotatsu.search.ui.SearchActivity"
android:label="@string/search" /> android:label="@string/search" />
<activity android:name="org.koitharu.kotatsu.search.ui.MangaListActivity" <activity
android:name="org.koitharu.kotatsu.search.ui.MangaListActivity"
android:label="@string/search_manga" /> android:label="@string/search_manga" />
<activity <activity
android:name="org.koitharu.kotatsu.settings.SettingsActivity" android:name="org.koitharu.kotatsu.settings.SettingsActivity"
@@ -95,9 +100,11 @@
android:windowSoftInputMode="adjustResize" /> android:windowSoftInputMode="adjustResize" />
<activity <activity
android:name="org.koitharu.kotatsu.download.ui.DownloadsActivity" android:name="org.koitharu.kotatsu.download.ui.DownloadsActivity"
android:launchMode="singleTop" android:label="@string/downloads"
android:label="@string/downloads" /> android:launchMode="singleTop" />
<activity android:name="org.koitharu.kotatsu.image.ui.ImageActivity"/> <activity android:name="org.koitharu.kotatsu.image.ui.ImageActivity" />
<activity android:name="org.koitharu.kotatsu.sync.ui.SyncAuthActivity"
android:label="@string/sync"/>
<service <service
android:name="org.koitharu.kotatsu.download.ui.service.DownloadService" android:name="org.koitharu.kotatsu.download.ui.service.DownloadService"
@@ -109,6 +116,41 @@
<service <service
android:name="org.koitharu.kotatsu.widget.recent.RecentWidgetService" android:name="org.koitharu.kotatsu.widget.recent.RecentWidgetService"
android:permission="android.permission.BIND_REMOTEVIEWS" /> android:permission="android.permission.BIND_REMOTEVIEWS" />
<service
android:name="org.koitharu.kotatsu.sync.ui.SyncAuthenticatorService"
android:exported="true"
tools:ignore="ExportedService">
<intent-filter>
<action android:name="android.accounts.AccountAuthenticator" />
</intent-filter>
<meta-data
android:name="android.accounts.AccountAuthenticator"
android:resource="@xml/authenticator_sync" />
</service>
<service
android:name="org.koitharu.kotatsu.sync.ui.favourites.FavouritesSyncService"
android:exported="false"
android:label="@string/favourites"
android:process=":sync">
<intent-filter>
<action android:name="android.content.SyncAdapter" />
</intent-filter>
<meta-data
android:name="android.content.SyncAdapter"
android:resource="@xml/sync_favourites" />
</service>
<service
android:name="org.koitharu.kotatsu.sync.ui.history.HistorySyncService"
android:exported="false"
android:label="@string/history"
android:process=":sync">
<intent-filter>
<action android:name="android.content.SyncAdapter" />
</intent-filter>
<meta-data
android:name="android.content.SyncAdapter"
android:resource="@xml/sync_history" />
</service>
<provider <provider
android:name="org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider" android:name="org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider"
@@ -123,6 +165,18 @@
android:name="android.support.FILE_PROVIDER_PATHS" android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/filepaths" /> android:resource="@xml/filepaths" />
</provider> </provider>
<provider
android:name="org.koitharu.kotatsu.sync.ui.favourites.FavouritesSyncProvider"
android:authorities="org.koitharu.kotatsu.favourites"
android:exported="false"
android:label="@string/favourites"
android:syncable="true" />
<provider
android:name="org.koitharu.kotatsu.sync.ui.history.HistorySyncProvider"
android:authorities="org.koitharu.kotatsu.history"
android:exported="false"
android:label="@string/history"
android:syncable="true" />
<receiver <receiver
android:name="org.koitharu.kotatsu.widget.shelf.ShelfWidgetProvider" android:name="org.koitharu.kotatsu.widget.shelf.ShelfWidgetProvider"

View File

@@ -27,6 +27,7 @@ import org.koitharu.kotatsu.remotelist.remoteListModule
import org.koitharu.kotatsu.search.searchModule import org.koitharu.kotatsu.search.searchModule
import org.koitharu.kotatsu.settings.settingsModule import org.koitharu.kotatsu.settings.settingsModule
import org.koitharu.kotatsu.suggestions.suggestionsModule import org.koitharu.kotatsu.suggestions.suggestionsModule
import org.koitharu.kotatsu.sync.syncModule
import org.koitharu.kotatsu.tracker.trackerModule import org.koitharu.kotatsu.tracker.trackerModule
import org.koitharu.kotatsu.widget.WidgetUpdater import org.koitharu.kotatsu.widget.WidgetUpdater
import org.koitharu.kotatsu.widget.appWidgetModule import org.koitharu.kotatsu.widget.appWidgetModule
@@ -67,6 +68,7 @@ class KotatsuApp : Application() {
readerModule, readerModule,
appWidgetModule, appWidgetModule,
suggestionsModule, suggestionsModule,
syncModule,
) )
} }
} }

View File

@@ -46,6 +46,13 @@ abstract class MangaDatabase : RoomDatabase() {
companion object { companion object {
const val TABLE_FAVOURITES = "favourites"
const val TABLE_MANGA = "manga"
const val TABLE_TAGS = "tags"
const val TABLE_FAVOURITE_CATEGORIES = "favourite_categories"
const val TABLE_HISTORY = "history"
const val TABLE_MANGA_TAGS = "manga_tags"
fun create(context: Context): MangaDatabase = Room.databaseBuilder( fun create(context: Context): MangaDatabase = Room.databaseBuilder(
context, context,
MangaDatabase::class.java, MangaDatabase::class.java,

View File

@@ -3,8 +3,9 @@ package org.koitharu.kotatsu.core.db.entity
import androidx.room.ColumnInfo import androidx.room.ColumnInfo
import androidx.room.Entity import androidx.room.Entity
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import org.koitharu.kotatsu.core.db.MangaDatabase.Companion.TABLE_MANGA
@Entity(tableName = "manga") @Entity(tableName = TABLE_MANGA)
class MangaEntity( class MangaEntity(
@PrimaryKey(autoGenerate = false) @PrimaryKey(autoGenerate = false)
@ColumnInfo(name = "manga_id") val id: Long, @ColumnInfo(name = "manga_id") val id: Long,

View File

@@ -3,25 +3,27 @@ package org.koitharu.kotatsu.core.db.entity
import androidx.room.ColumnInfo import androidx.room.ColumnInfo
import androidx.room.Entity import androidx.room.Entity
import androidx.room.ForeignKey import androidx.room.ForeignKey
import org.koitharu.kotatsu.core.db.MangaDatabase.Companion.TABLE_MANGA_TAGS
@Entity( @Entity(
tableName = "manga_tags", primaryKeys = ["manga_id", "tag_id"], tableName = TABLE_MANGA_TAGS,
primaryKeys = ["manga_id", "tag_id"],
foreignKeys = [ foreignKeys = [
ForeignKey( ForeignKey(
entity = MangaEntity::class, entity = MangaEntity::class,
parentColumns = ["manga_id"], parentColumns = ["manga_id"],
childColumns = ["manga_id"], childColumns = ["manga_id"],
onDelete = ForeignKey.CASCADE onDelete = ForeignKey.CASCADE,
), ),
ForeignKey( ForeignKey(
entity = TagEntity::class, entity = TagEntity::class,
parentColumns = ["tag_id"], parentColumns = ["tag_id"],
childColumns = ["tag_id"], childColumns = ["tag_id"],
onDelete = ForeignKey.CASCADE onDelete = ForeignKey.CASCADE,
) )
] ]
) )
class MangaTagsEntity( class MangaTagsEntity(
@ColumnInfo(name = "manga_id", index = true) val mangaId: Long, @ColumnInfo(name = "manga_id", index = true) val mangaId: Long,
@ColumnInfo(name = "tag_id", index = true) val tagId: Long @ColumnInfo(name = "tag_id", index = true) val tagId: Long,
) )

View File

@@ -3,8 +3,9 @@ package org.koitharu.kotatsu.core.db.entity
import androidx.room.ColumnInfo import androidx.room.ColumnInfo
import androidx.room.Entity import androidx.room.Entity
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import org.koitharu.kotatsu.core.db.MangaDatabase.Companion.TABLE_TAGS
@Entity(tableName = "tags") @Entity(tableName = TABLE_TAGS)
class TagEntity( class TagEntity(
@PrimaryKey(autoGenerate = false) @PrimaryKey(autoGenerate = false)
@ColumnInfo(name = "tag_id") val id: Long, @ColumnInfo(name = "tag_id") val id: Long,

View File

@@ -3,8 +3,9 @@ package org.koitharu.kotatsu.favourites.data
import androidx.room.ColumnInfo import androidx.room.ColumnInfo
import androidx.room.Entity import androidx.room.Entity
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import org.koitharu.kotatsu.core.db.MangaDatabase.Companion.TABLE_FAVOURITE_CATEGORIES
@Entity(tableName = "favourite_categories") @Entity(tableName = TABLE_FAVOURITE_CATEGORIES)
class FavouriteCategoryEntity( class FavouriteCategoryEntity(
@PrimaryKey(autoGenerate = true) @PrimaryKey(autoGenerate = true)
@ColumnInfo(name = "category_id") val categoryId: Int, @ColumnInfo(name = "category_id") val categoryId: Int,

View File

@@ -3,10 +3,12 @@ package org.koitharu.kotatsu.favourites.data
import androidx.room.ColumnInfo import androidx.room.ColumnInfo
import androidx.room.Entity import androidx.room.Entity
import androidx.room.ForeignKey import androidx.room.ForeignKey
import org.koitharu.kotatsu.core.db.MangaDatabase.Companion.TABLE_FAVOURITES
import org.koitharu.kotatsu.core.db.entity.MangaEntity import org.koitharu.kotatsu.core.db.entity.MangaEntity
@Entity( @Entity(
tableName = "favourites", primaryKeys = ["manga_id", "category_id"], foreignKeys = [ tableName = TABLE_FAVOURITES, primaryKeys = ["manga_id", "category_id"],
foreignKeys = [
ForeignKey( ForeignKey(
entity = MangaEntity::class, entity = MangaEntity::class,
parentColumns = ["manga_id"], parentColumns = ["manga_id"],

View File

@@ -8,9 +8,6 @@ import org.koitharu.kotatsu.core.db.entity.TagEntity
@Dao @Dao
abstract class HistoryDao { abstract class HistoryDao {
/**
* @hide
*/
@Transaction @Transaction
@Query("SELECT * FROM history ORDER BY updated_at DESC LIMIT :limit OFFSET :offset") @Query("SELECT * FROM history ORDER BY updated_at DESC LIMIT :limit OFFSET :offset")
abstract suspend fun findAll(offset: Int, limit: Int): List<HistoryWithManga> abstract suspend fun findAll(offset: Int, limit: Int): List<HistoryWithManga>

View File

@@ -4,16 +4,17 @@ import androidx.room.ColumnInfo
import androidx.room.Entity import androidx.room.Entity
import androidx.room.ForeignKey import androidx.room.ForeignKey
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import org.koitharu.kotatsu.core.db.MangaDatabase.Companion.TABLE_HISTORY
import org.koitharu.kotatsu.core.db.entity.MangaEntity import org.koitharu.kotatsu.core.db.entity.MangaEntity
@Entity( @Entity(
tableName = "history", tableName = TABLE_HISTORY,
foreignKeys = [ foreignKeys = [
ForeignKey( ForeignKey(
entity = MangaEntity::class, entity = MangaEntity::class,
parentColumns = ["manga_id"], parentColumns = ["manga_id"],
childColumns = ["manga_id"], childColumns = ["manga_id"],
onDelete = ForeignKey.CASCADE onDelete = ForeignKey.CASCADE,
) )
] ]
) )

View File

@@ -0,0 +1,12 @@
package org.koitharu.kotatsu.sync
import org.koin.android.ext.koin.androidContext
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module
import org.koitharu.kotatsu.sync.ui.SyncAuthViewModel
val syncModule
get() = module {
viewModel { SyncAuthViewModel(androidContext(), get()) }
}

View File

@@ -0,0 +1,29 @@
package org.koitharu.kotatsu.sync.data
import android.accounts.Account
import android.accounts.AccountManager
import android.content.Context
import okhttp3.Credentials
import okhttp3.Interceptor
import okhttp3.Response
class AccountInterceptor(
context: Context,
private val account: Account,
) : Interceptor {
private val accountManager = AccountManager.get(context)
override fun intercept(chain: Interceptor.Chain): Response {
val password = accountManager.getPassword(account)
val request = if (password != null) {
val credential: String = Credentials.basic(account.name, password)
chain.request().newBuilder()
.header("Authorization", credential)
.build()
} else {
chain.request()
}
return chain.proceed(request)
}
}

View File

@@ -0,0 +1,28 @@
package org.koitharu.kotatsu.sync.domain
class SyncAuthResult(
val email: String,
val password: String,
val token: String,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as SyncAuthResult
if (email != other.email) return false
if (password != other.password) return false
if (token != other.token) return false
return true
}
override fun hashCode(): Int {
var result = email.hashCode()
result = 31 * result + password.hashCode()
result = 31 * result + token.hashCode()
return result
}
}

View File

@@ -0,0 +1,206 @@
package org.koitharu.kotatsu.sync.domain
import android.accounts.Account
import android.content.ContentProviderClient
import android.content.ContentProviderOperation
import android.content.Context
import android.content.SyncResult
import android.net.Uri
import androidx.annotation.WorkerThread
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONArray
import org.json.JSONObject
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.db.MangaDatabase.Companion.TABLE_FAVOURITES
import org.koitharu.kotatsu.core.db.MangaDatabase.Companion.TABLE_FAVOURITE_CATEGORIES
import org.koitharu.kotatsu.core.db.MangaDatabase.Companion.TABLE_HISTORY
import org.koitharu.kotatsu.core.db.MangaDatabase.Companion.TABLE_MANGA
import org.koitharu.kotatsu.core.db.MangaDatabase.Companion.TABLE_MANGA_TAGS
import org.koitharu.kotatsu.core.db.MangaDatabase.Companion.TABLE_TAGS
import org.koitharu.kotatsu.parsers.util.json.mapJSONTo
import org.koitharu.kotatsu.parsers.util.parseJson
import org.koitharu.kotatsu.sync.data.AccountInterceptor
import org.koitharu.kotatsu.utils.ext.toContentValues
import org.koitharu.kotatsu.utils.ext.toJson
import org.koitharu.kotatsu.utils.ext.toRequestBody
private const val AUTHORITY_HISTORY = "org.koitharu.kotatsu.history"
private const val AUTHORITY_FAVOURITES = "org.koitharu.kotatsu.favourites"
/**
* Warning! This class may be used in another process
*/
class SyncRepository(
context: Context,
account: Account,
private val provider: ContentProviderClient,
) {
private val httpClient = OkHttpClient.Builder()
.addInterceptor(AccountInterceptor(context, account))
.build()
private val baseUrl = context.getString(R.string.url_sync_server)
@WorkerThread
fun syncFavouriteCategories(syncResult: SyncResult) {
val uri = uri(AUTHORITY_FAVOURITES, TABLE_FAVOURITE_CATEGORIES)
val data = JSONObject()
provider.query(uri, null, null, null, null)?.use { cursor ->
val favourites = JSONArray()
if (cursor.moveToFirst()) {
do {
favourites.put(cursor.toJson())
} while (cursor.moveToNext())
}
data.put(TABLE_FAVOURITES, favourites)
}
data.put("timestamp", System.currentTimeMillis())
val request = Request.Builder()
.url("$baseUrl/resource/$TABLE_FAVOURITE_CATEGORIES")
.post(data.toRequestBody())
.build()
val response = httpClient.newCall(request).execute().parseJson()
val operations = ArrayList<ContentProviderOperation>()
val timestamp = response.getLong("timestamp")
operations += ContentProviderOperation.newDelete(uri)
.withSelection("created_at < ?", arrayOf(timestamp.toString()))
.build()
val ja = response.getJSONArray(TABLE_FAVOURITE_CATEGORIES)
ja.mapJSONTo(operations) { jo ->
ContentProviderOperation.newInsert(uri)
.withValues(jo.toContentValues())
.build()
}
val result = provider.applyBatch(operations)
syncResult.stats.numDeletes = result.first().count?.toLong() ?: 0L
syncResult.stats.numInserts = result.drop(1).sumOf { it.count?.toLong() ?: 0L }
}
@WorkerThread
fun syncFavourites(syncResult: SyncResult) {
val uri = uri(AUTHORITY_FAVOURITES, TABLE_FAVOURITES)
val data = JSONObject()
provider.query(uri, null, null, null, null)?.use { cursor ->
val jsonArray = JSONArray()
if (cursor.moveToFirst()) {
do {
val jo = cursor.toJson()
jo.put("manga", getManga(AUTHORITY_FAVOURITES, jo.getLong("manga_id")))
jsonArray.put(jo)
} while (cursor.moveToNext())
}
data.put(TABLE_FAVOURITES, jsonArray)
}
data.put("timestamp", System.currentTimeMillis())
val request = Request.Builder()
.url("$baseUrl/resource/$TABLE_FAVOURITES")
.post(data.toRequestBody())
.build()
val response = httpClient.newCall(request).execute().parseJson()
val operations = ArrayList<ContentProviderOperation>()
val timestamp = response.getLong("timestamp")
operations += ContentProviderOperation.newDelete(uri)
.withSelection("created_at < ?", arrayOf(timestamp.toString()))
.build()
val ja = response.getJSONArray(TABLE_FAVOURITES)
ja.mapJSONTo(operations) { jo ->
ContentProviderOperation.newInsert(uri)
.withValues(jo.toContentValues())
.build()
}
val result = provider.applyBatch(operations)
syncResult.stats.numDeletes = result.first().count?.toLong() ?: 0L
syncResult.stats.numInserts = result.drop(1).sumOf { it.count?.toLong() ?: 0L }
}
@WorkerThread
fun syncHistory(syncResult: SyncResult) {
val uri = uri(AUTHORITY_HISTORY, TABLE_HISTORY)
val data = JSONObject()
provider.query(uri, null, null, null, null)?.use { cursor ->
val jsonArray = JSONArray()
if (cursor.moveToFirst()) {
do {
val jo = cursor.toJson()
jo.put("manga", getManga(AUTHORITY_HISTORY, jo.getLong("manga_id")))
jsonArray.put(jo)
} while (cursor.moveToNext())
}
data.put(TABLE_HISTORY, jsonArray)
}
data.put("timestamp", System.currentTimeMillis())
val request = Request.Builder()
.url("$baseUrl/resource/$TABLE_HISTORY")
.post(data.toRequestBody())
.build()
val response = httpClient.newCall(request).execute().parseJson()
val operations = ArrayList<ContentProviderOperation>()
val timestamp = response.getLong("timestamp")
operations += ContentProviderOperation.newDelete(uri)
.withSelection("updated_at < ?", arrayOf(timestamp.toString()))
.build()
val ja = response.getJSONArray(TABLE_HISTORY)
ja.mapJSONTo(operations) { jo ->
ContentProviderOperation.newInsert(uri)
.withValues(jo.toContentValues())
.build()
}
val result = provider.applyBatch(operations)
syncResult.stats.numDeletes = result.first().count?.toLong() ?: 0L
syncResult.stats.numInserts = result.drop(1).sumOf { it.count?.toLong() ?: 0L }
}
private fun getManga(authority: String, id: Long): JSONObject {
val manga = provider.query(
uri(authority, TABLE_MANGA),
null,
"manga_id = ?",
arrayOf(id.toString()),
null,
)?.use { cursor ->
cursor.moveToFirst()
cursor.toJson()
}
requireNotNull(manga)
val tags = provider.query(
uri(authority, TABLE_MANGA_TAGS),
arrayOf("tag_id"),
"manga_id = ?",
arrayOf(id.toString()),
null,
)?.use { cursor ->
val json = JSONArray()
if (cursor.moveToFirst()) {
do {
val tagId = cursor.getLong(0)
json.put(getTag(authority, tagId))
} while (cursor.moveToNext())
}
json
}
manga.put("tags", requireNotNull(tags))
return manga
}
private fun getTag(authority: String, tagId: Long): JSONObject {
val tag = provider.query(
uri(authority, TABLE_TAGS),
null,
"tag_id = ?",
arrayOf(tagId.toString()),
null,
)?.use { cursor ->
if (cursor.moveToFirst()) {
cursor.toJson()
} else {
null
}
}
return requireNotNull(tag)
}
private fun uri(authority: String, table: String) = Uri.parse("content://$authority/$table")
}

View File

@@ -0,0 +1,159 @@
package org.koitharu.kotatsu.sync.ui
import android.accounts.Account
import android.accounts.AccountAuthenticatorResponse
import android.accounts.AccountManager
import android.os.Bundle
import android.text.Editable
import android.text.TextWatcher
import android.view.View
import android.widget.Button
import androidx.core.graphics.Insets
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.transition.Fade
import androidx.transition.TransitionManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.databinding.ActivitySyncAuthBinding
import org.koitharu.kotatsu.sync.domain.SyncAuthResult
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
class SyncAuthActivity : BaseActivity<ActivitySyncAuthBinding>(), View.OnClickListener {
private var accountAuthenticatorResponse: AccountAuthenticatorResponse? = null
private var resultBundle: Bundle? = null
private val viewModel by viewModel<SyncAuthViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(ActivitySyncAuthBinding.inflate(layoutInflater))
accountAuthenticatorResponse = intent.getParcelableExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE)
accountAuthenticatorResponse?.onRequestContinued()
binding.buttonCancel.setOnClickListener(this)
binding.buttonNext.setOnClickListener(this)
binding.buttonBack.setOnClickListener(this)
binding.buttonDone.setOnClickListener(this)
binding.editEmail.addTextChangedListener(EmailTextWatcher(binding.buttonNext))
binding.editPassword.addTextChangedListener(PasswordTextWatcher(binding.buttonDone))
viewModel.onTokenObtained.observe(this, ::onTokenReceived)
viewModel.onError.observe(this, ::onError)
viewModel.isLoading.observe(this, ::onLoadingStateChanged)
}
override fun onWindowInsetsChanged(insets: Insets) {
val basePadding = resources.getDimensionPixelOffset(R.dimen.screen_padding)
binding.root.setPadding(
basePadding + insets.left,
basePadding + insets.top,
basePadding + insets.right,
basePadding + insets.bottom,
)
}
override fun onBackPressed() {
if (binding.switcher.isVisible && binding.switcher.displayedChild > 0) {
binding.switcher.showPrevious()
} else {
super.onBackPressed()
}
}
override fun onClick(v: View) {
when (v.id) {
R.id.button_cancel -> {
setResult(RESULT_CANCELED)
finish()
}
R.id.button_next -> {
binding.switcher.showNext()
}
R.id.button_back -> {
binding.switcher.showPrevious()
}
R.id.button_done -> {
viewModel.obtainToken(
email = binding.editEmail.text.toString(),
password = binding.editPassword.text.toString(),
)
}
}
}
override fun finish() {
accountAuthenticatorResponse?.let { response ->
resultBundle?.also {
response.onResult(it)
} ?: response.onError(AccountManager.ERROR_CODE_CANCELED, getString(R.string.canceled))
}
super.finish()
}
private fun onLoadingStateChanged(isLoading: Boolean) {
if (isLoading == binding.layoutProgress.isVisible) {
return
}
TransitionManager.beginDelayedTransition(binding.root, Fade())
binding.switcher.isGone = isLoading
binding.layoutProgress.isVisible = isLoading
}
private fun onError(error: Throwable) {
MaterialAlertDialogBuilder(this)
.setTitle(R.string.error)
.setMessage(error.getDisplayMessage(resources))
.setNegativeButton(R.string.close, null)
.show()
}
private fun onTokenReceived(authResult: SyncAuthResult) {
val am = AccountManager.get(this)
val account = Account(authResult.email, getString(R.string.account_type_sync))
val result = Bundle()
if (am.addAccountExplicitly(account, authResult.password, Bundle())) {
result.putString(AccountManager.KEY_ACCOUNT_NAME, account.name)
result.putString(AccountManager.KEY_ACCOUNT_TYPE, account.type)
result.putString(AccountManager.KEY_AUTHTOKEN, authResult.token)
am.setAuthToken(account, account.type, authResult.token)
} else {
result.putString(AccountManager.KEY_ERROR_MESSAGE, getString(R.string.account_already_exists))
}
resultBundle = result
setResult(RESULT_OK)
finish()
}
private class EmailTextWatcher(
private val button: Button,
) : TextWatcher {
private val regexEmail = Regex("^[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,6}$", RegexOption.IGNORE_CASE)
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit
override fun afterTextChanged(s: Editable?) {
val text = s?.toString()
button.isEnabled = !text.isNullOrEmpty() && regexEmail.matches(text)
}
}
private class PasswordTextWatcher(
private val button: Button,
) : TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit
override fun afterTextChanged(s: Editable?) {
val text = s?.toString()
button.isEnabled = text != null && text.length >= 4
}
}
}

View File

@@ -0,0 +1,44 @@
package org.koitharu.kotatsu.sync.ui
import android.content.Context
import kotlinx.coroutines.Dispatchers
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.parsers.util.parseJson
import org.koitharu.kotatsu.sync.domain.SyncAuthResult
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.toRequestBody
import java.util.*
class SyncAuthViewModel(
context: Context,
private val okHttpClient: OkHttpClient,
) : BaseViewModel() {
private val baseUrl = context.getString(R.string.url_sync_server)
val onTokenObtained = SingleLiveEvent<SyncAuthResult>()
fun obtainToken(email: String, password: String) {
launchLoadingJob(Dispatchers.Default) {
authenticate(email, password)
val token = UUID.randomUUID().toString()
val result = SyncAuthResult(email, password, token)
onTokenObtained.postCall(result)
}
}
private suspend fun authenticate(email: String, password: String) {
val body = JSONObject(
mapOf("email" to email, "password" to password)
).toRequestBody()
val request = Request.Builder()
.url("$baseUrl/register")
.post(body)
.build()
val response = okHttpClient.newCall(request).await().parseJson()
}
}

View File

@@ -0,0 +1,79 @@
package org.koitharu.kotatsu.sync.ui
import android.accounts.AbstractAccountAuthenticator
import android.accounts.Account
import android.accounts.AccountAuthenticatorResponse
import android.accounts.AccountManager
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.text.TextUtils
class SyncAuthenticator(private val context: Context) : AbstractAccountAuthenticator(context) {
override fun editProperties(response: AccountAuthenticatorResponse?, accountType: String?): Bundle? = null
override fun addAccount(
response: AccountAuthenticatorResponse?,
accountType: String?,
authTokenType: String?,
requiredFeatures: Array<out String>?,
options: Bundle?,
): Bundle {
val intent = Intent(context, SyncAuthActivity::class.java)
intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response)
val bundle = Bundle()
if (options != null) {
bundle.putAll(options)
}
bundle.putParcelable(AccountManager.KEY_INTENT, intent)
return bundle
}
override fun confirmCredentials(
response: AccountAuthenticatorResponse?,
account: Account?,
options: Bundle?,
): Bundle? = null
override fun getAuthToken(
response: AccountAuthenticatorResponse?,
account: Account,
authTokenType: String?,
options: Bundle?,
): Bundle {
val result = Bundle()
val am = AccountManager.get(context.applicationContext)
val authToken = am.peekAuthToken(account, authTokenType)
if (!TextUtils.isEmpty(authToken)) {
result.putString(AccountManager.KEY_ACCOUNT_NAME, account.name)
result.putString(AccountManager.KEY_ACCOUNT_TYPE, account.type)
result.putString(AccountManager.KEY_AUTHTOKEN, authToken)
} else {
val intent = Intent(context, SyncAuthActivity::class.java)
intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response)
// intent.putExtra(SyncAuthActivity.EXTRA_TOKEN_TYPE, authTokenType)
val bundle = Bundle()
if (options != null) {
bundle.putAll(options)
}
bundle.putParcelable(AccountManager.KEY_INTENT, intent)
}
return result
}
override fun getAuthTokenLabel(authTokenType: String?): String? = null
override fun updateCredentials(
response: AccountAuthenticatorResponse?,
account: Account?,
authTokenType: String?,
options: Bundle?,
): Bundle? = null
override fun hasFeatures(
response: AccountAuthenticatorResponse?,
account: Account?,
features: Array<out String>?,
): Bundle? = null
}

View File

@@ -0,0 +1,19 @@
package org.koitharu.kotatsu.sync.ui
import android.app.Service
import android.content.Intent
import android.os.IBinder
class SyncAuthenticatorService : Service() {
private lateinit var authenticator: SyncAuthenticator
override fun onCreate() {
super.onCreate()
authenticator = SyncAuthenticator(this)
}
override fun onBind(intent: Intent?): IBinder? {
return authenticator.iBinder
}
}

View File

@@ -0,0 +1,96 @@
package org.koitharu.kotatsu.sync.ui
import android.content.ContentProvider
import android.content.ContentProviderOperation
import android.content.ContentProviderResult
import android.content.ContentValues
import android.database.Cursor
import android.database.sqlite.SQLiteDatabase
import android.net.Uri
import androidx.sqlite.db.SupportSQLiteQueryBuilder
import java.util.concurrent.Callable
import org.koin.android.ext.android.inject
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.MangaDatabase.Companion.TABLE_FAVOURITES
import org.koitharu.kotatsu.core.db.MangaDatabase.Companion.TABLE_FAVOURITE_CATEGORIES
import org.koitharu.kotatsu.core.db.MangaDatabase.Companion.TABLE_HISTORY
import org.koitharu.kotatsu.core.db.MangaDatabase.Companion.TABLE_MANGA
import org.koitharu.kotatsu.core.db.MangaDatabase.Companion.TABLE_MANGA_TAGS
import org.koitharu.kotatsu.core.db.MangaDatabase.Companion.TABLE_TAGS
abstract class SyncProvider : ContentProvider() {
private val database by inject<MangaDatabase>()
private val supportedTables = setOf(
TABLE_FAVOURITES,
TABLE_MANGA,
TABLE_TAGS,
TABLE_FAVOURITE_CATEGORIES,
TABLE_HISTORY,
TABLE_MANGA_TAGS,
)
override fun onCreate(): Boolean {
return true
}
override fun query(
uri: Uri,
projection: Array<out String>?,
selection: String?,
selectionArgs: Array<out String>?,
sortOrder: String?
): Cursor? = if (getTableName(uri) != null) {
val sqlQuery = SupportSQLiteQueryBuilder.builder(uri.lastPathSegment)
.columns(projection)
.selection(selection, selectionArgs)
.orderBy(sortOrder)
.create()
database.openHelper.readableDatabase.query(sqlQuery)
} else {
null
}
override fun getType(uri: Uri): String? {
return getTableName(uri)?.let { "vnd.android.cursor.dir/" }
}
override fun insert(uri: Uri, values: ContentValues?): Uri? {
val table = getTableName(uri)
if (values == null || table == null) {
return null
}
val db = database.openHelper.writableDatabase
db.insert(table, SQLiteDatabase.CONFLICT_REPLACE, values)
return null
}
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int {
val table = getTableName(uri)
if (table == null) {
return 0
}
return database.openHelper.writableDatabase.delete(table, selection, selectionArgs)
}
override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<out String>?): Int {
val table = getTableName(uri)
if (values == null || table == null) {
return 0
}
return database.openHelper.writableDatabase
.update(table, SQLiteDatabase.CONFLICT_IGNORE, values, selection, selectionArgs)
}
override fun applyBatch(operations: ArrayList<ContentProviderOperation>): Array<ContentProviderResult> {
return database.runInTransaction(Callable { super.applyBatch(operations) })
}
override fun bulkInsert(uri: Uri, values: Array<out ContentValues>): Int {
return database.runInTransaction(Callable { super.bulkInsert(uri, values) })
}
private fun getTableName(uri: Uri): String? {
return uri.pathSegments.singleOrNull()?.takeIf { it in supportedTables }
}
}

View File

@@ -0,0 +1,28 @@
package org.koitharu.kotatsu.sync.ui.favourites
import android.accounts.Account
import android.content.AbstractThreadedSyncAdapter
import android.content.ContentProviderClient
import android.content.Context
import android.content.SyncResult
import android.os.Bundle
import org.koitharu.kotatsu.sync.domain.SyncRepository
import org.koitharu.kotatsu.utils.ext.onError
class FavouritesSyncAdapter(context: Context) : AbstractThreadedSyncAdapter(context, true) {
override fun onPerformSync(
account: Account,
extras: Bundle,
authority: String,
provider: ContentProviderClient,
syncResult: SyncResult,
) {
// Debug.waitForDebugger()
val repository = SyncRepository(context, account, provider)
runCatching {
repository.syncFavouriteCategories(syncResult)
repository.syncFavourites(syncResult)
}.onFailure(syncResult::onError)
}
}

View File

@@ -0,0 +1,5 @@
package org.koitharu.kotatsu.sync.ui.favourites
import org.koitharu.kotatsu.sync.ui.SyncProvider
class FavouritesSyncProvider : SyncProvider()

View File

@@ -0,0 +1,19 @@
package org.koitharu.kotatsu.sync.ui.favourites
import android.app.Service
import android.content.Intent
import android.os.IBinder
class FavouritesSyncService : Service() {
private lateinit var syncAdapter: FavouritesSyncAdapter
override fun onCreate() {
super.onCreate()
syncAdapter = FavouritesSyncAdapter(applicationContext)
}
override fun onBind(intent: Intent?): IBinder {
return syncAdapter.syncAdapterBinder
}
}

View File

@@ -0,0 +1,27 @@
package org.koitharu.kotatsu.sync.ui.history
import android.accounts.Account
import android.content.AbstractThreadedSyncAdapter
import android.content.ContentProviderClient
import android.content.Context
import android.content.SyncResult
import android.os.Bundle
import org.koitharu.kotatsu.sync.domain.SyncRepository
import org.koitharu.kotatsu.utils.ext.onError
class HistorySyncAdapter(context: Context) : AbstractThreadedSyncAdapter(context, true) {
override fun onPerformSync(
account: Account,
extras: Bundle,
authority: String,
provider: ContentProviderClient,
syncResult: SyncResult,
) {
// Debug.waitForDebugger()
val repository = SyncRepository(context, account, provider)
runCatching {
repository.syncHistory(syncResult)
}.onFailure(syncResult::onError)
}
}

View File

@@ -0,0 +1,5 @@
package org.koitharu.kotatsu.sync.ui.history
import org.koitharu.kotatsu.sync.ui.SyncProvider
class HistorySyncProvider : SyncProvider()

View File

@@ -0,0 +1,19 @@
package org.koitharu.kotatsu.sync.ui.history
import android.app.Service
import android.content.Intent
import android.os.IBinder
class HistorySyncService : Service() {
private lateinit var syncAdapter: HistorySyncAdapter
override fun onCreate() {
super.onCreate()
syncAdapter = HistorySyncAdapter(applicationContext)
}
override fun onBind(intent: Intent?): IBinder {
return syncAdapter.syncAdapterBinder
}
}

View File

@@ -1,14 +1,20 @@
package org.koitharu.kotatsu.utils.ext package org.koitharu.kotatsu.utils.ext
import android.content.Context import android.content.Context
import android.content.OperationApplicationException
import android.content.SyncResult
import android.database.SQLException
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.net.Network import android.net.Network
import android.net.NetworkRequest import android.net.NetworkRequest
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import androidx.work.CoroutineWorker import androidx.work.CoroutineWorker
import kotlin.coroutines.resume
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import okio.IOException
import org.json.JSONException
import org.koitharu.kotatsu.BuildConfig
import kotlin.coroutines.resume
val Context.connectivityManager: ConnectivityManager val Context.connectivityManager: ConnectivityManager
get() = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager get() = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
@@ -40,4 +46,14 @@ fun String.toUriOrNull() = if (isEmpty()) null else Uri.parse(this)
suspend fun CoroutineWorker.trySetForeground(): Boolean = runCatching { suspend fun CoroutineWorker.trySetForeground(): Boolean = runCatching {
val info = getForegroundInfo() val info = getForegroundInfo()
setForeground(info) setForeground(info)
}.isSuccess }.isSuccess
fun SyncResult.onError(error: Throwable) {
when (error) {
is IOException -> stats.numIoExceptions++
is OperationApplicationException,
is SQLException -> databaseError = true
is JSONException -> stats.numParseExceptions++
else -> if (BuildConfig.DEBUG) throw error
}
}

View File

@@ -0,0 +1,39 @@
package org.koitharu.kotatsu.utils.ext
import android.content.ContentValues
import android.database.Cursor
import org.json.JSONObject
fun Cursor.toJson(): JSONObject {
val jo = JSONObject()
for (i in 0 until columnCount) {
val name = getColumnName(i)
when (getType(i)) {
Cursor.FIELD_TYPE_STRING -> jo.put(name, getString(i))
Cursor.FIELD_TYPE_FLOAT -> jo.put(name, getDouble(i))
Cursor.FIELD_TYPE_INTEGER -> jo.put(name, getLong(i))
Cursor.FIELD_TYPE_NULL -> jo.put(name, null)
Cursor.FIELD_TYPE_BLOB -> jo.put(name, getBlob(i))
}
}
return jo
}
fun JSONObject.toContentValues(): ContentValues {
val cv = ContentValues(length())
for (key in keys()) {
val name = key.escapeName()
when (val value = get(key)) {
null -> cv.putNull(name)
is String -> cv.put(name, value)
is Float -> cv.put(name, value)
is Double -> cv.put(name, value)
is Int -> cv.put(name, value)
is Long -> cv.put(name, value)
else -> throw IllegalArgumentException("Value $value cannot be putted in ContentValues")
}
}
return cv
}
private fun String.escapeName() = "`$this`"

View File

@@ -0,0 +1,9 @@
package org.koitharu.kotatsu.utils.ext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
private val TYPE_JSON = "application/json".toMediaType()
fun JSONObject.toRequestBody() = toString().toRequestBody(TYPE_JSON)

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#000"
android:pathData="M12,18A6,6 0 0,1 6,12C6,11 6.25,10.03 6.7,9.2L5.24,7.74C4.46,8.97 4,10.43 4,12A8,8 0 0,0 12,20V23L16,19L12,15M12,4V1L8,5L12,9V6A6,6 0 0,1 18,12C18,13 17.75,13.97 17.3,14.8L18.76,16.26C19.54,15.03 20,13.57 20,12A8,8 0 0,0 12,4Z" />
</vector>

View File

@@ -0,0 +1,177 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
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"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="@dimen/screen_padding">
<TextView
android:id="@+id/textView_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:drawablePadding="16dp"
android:gravity="center_horizontal"
android:text="@string/sync_title"
android:textAppearance="?textAppearanceHeadline5"
app:drawableTint="?colorPrimary"
app:drawableTopCompat="@drawable/ic_sync"
app:layout_constraintEnd_toEndOf="parent"
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">
<RelativeLayout
android:id="@+id/page_email"
android:layout_width="match_parent"
android:layout_height="match_parent">
<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
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="You can sign in into an existing account or create a new one"
app:hintEnabled="false">
<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" />
</com.google.android.material.textfield.TextInputLayout>
<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" />
<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"
android:layout_width="match_parent"
android:layout_height="match_parent">
<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="Enter your email to continue"
android:textAppearance="?textAppearanceSubtitle1" />
<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:errorIconDrawable="@null"
app:helperText="You can sign in into an existing account or create a new one"
app:hintEnabled="false">
<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" />
</com.google.android.material.textfield.TextInputLayout>
<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>
<FrameLayout
android:id="@+id/layout_progress"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone">
<com.google.android.material.progressindicator.CircularProgressIndicator
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:indeterminate="true" />
</FrameLayout>
</LinearLayout>

View File

@@ -1,9 +1,11 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="url_github_issues">https://github.com/nv95/Kotatsu/issues</string> <string name="url_github_issues" translatable="false">https://github.com/nv95/Kotatsu/issues</string>
<string name="url_discord">https://discord.gg/NNJ5RgVBC5</string> <string name="url_discord" translatable="false">https://discord.gg/NNJ5RgVBC5</string>
<string name="url_forpda">https://4pda.to/forum/index.php?showtopic=697669</string> <string name="url_forpda" translatable="false">https://4pda.to/forum/index.php?showtopic=697669</string>
<string name="url_weblate">https://hosted.weblate.org/engage/kotatsu</string> <string name="url_weblate" translatable="false">https://hosted.weblate.org/engage/kotatsu</string>
<string name="account_type_sync" translatable="false">org.kotatsu.sync</string>
<string name="url_sync_server" translatable="false">http://192.168.0.113:8080</string>
<string-array name="values_theme" translatable="false"> <string-array name="values_theme" translatable="false">
<item>-1</item> <item>-1</item>
<item>1</item> <item>1</item>

View File

@@ -279,4 +279,10 @@
<string name="download_slowdown_summary">Helps avoid blocking your IP address</string> <string name="download_slowdown_summary">Helps avoid blocking your IP address</string>
<string name="local_manga_processing">Saved manga processing</string> <string name="local_manga_processing">Saved manga processing</string>
<string name="chapters_will_removed_background">Chapters will be removed in the background. It can take some time</string> <string name="chapters_will_removed_background">Chapters will be removed in the background. It can take some time</string>
<string name="canceled">Canceled</string>
<string name="account_already_exists">Account already exists</string>
<string name="back">Back</string>
<string name="sync">Synchronization</string>
<string name="sync_title">Sync your data</string>
<string name="email_enter_hint">Enter your email to continue</string>
</resources> </resources>

View File

@@ -0,0 +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:accountType="@string/account_type_sync"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" />

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen
xmlns:android="http://schemas.android.com/apk/res/android">
<Preference
android:persistent="false"
android:summary="Preference stub"
android:title="TODO" />
</PreferenceScreen>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<sync-adapter xmlns:android="http://schemas.android.com/apk/res/android"
android:accountType="@string/account_type_sync"
android:allowParallelSyncs="false"
android:contentAuthority="org.koitharu.kotatsu.favourites"
android:isAlwaysSyncable="true"
android:supportsUploading="true"
android:userVisible="true" />

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<sync-adapter xmlns:android="http://schemas.android.com/apk/res/android"
android:accountType="@string/account_type_sync"
android:allowParallelSyncs="false"
android:contentAuthority="org.koitharu.kotatsu.history"
android:isAlwaysSyncable="true"
android:supportsUploading="true"
android:userVisible="true" />