First step syncronization implementation
This commit is contained in:
@@ -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"
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
12
app/src/main/java/org/koitharu/kotatsu/sync/SyncModule.kt
Normal file
12
app/src/main/java/org/koitharu/kotatsu/sync/SyncModule.kt
Normal 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()) }
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package org.koitharu.kotatsu.sync.ui.favourites
|
||||||
|
|
||||||
|
import org.koitharu.kotatsu.sync.ui.SyncProvider
|
||||||
|
|
||||||
|
class FavouritesSyncProvider : SyncProvider()
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package org.koitharu.kotatsu.sync.ui.history
|
||||||
|
|
||||||
|
import org.koitharu.kotatsu.sync.ui.SyncProvider
|
||||||
|
|
||||||
|
class HistorySyncProvider : SyncProvider()
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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`"
|
||||||
@@ -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)
|
||||||
12
app/src/main/res/drawable/ic_sync.xml
Normal file
12
app/src/main/res/drawable/ic_sync.xml
Normal 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>
|
||||||
177
app/src/main/res/layout/activity_sync_auth.xml
Normal file
177
app/src/main/res/layout/activity_sync_auth.xml
Normal 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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
7
app/src/main/res/xml/authenticator_sync.xml
Normal file
7
app/src/main/res/xml/authenticator_sync.xml
Normal 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" />
|
||||||
10
app/src/main/res/xml/pref_sync.xml
Normal file
10
app/src/main/res/xml/pref_sync.xml
Normal 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>
|
||||||
8
app/src/main/res/xml/sync_favourites.xml
Normal file
8
app/src/main/res/xml/sync_favourites.xml
Normal 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" />
|
||||||
8
app/src/main/res/xml/sync_history.xml
Normal file
8
app/src/main/res/xml/sync_history.xml
Normal 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" />
|
||||||
Reference in New Issue
Block a user