Merge branch 'feature/sync' into feature/nextgen

This commit is contained in:
Koitharu
2022-07-19 12:57:38 +03:00
64 changed files with 1721 additions and 160 deletions

View File

@@ -68,6 +68,7 @@ class MangaDatabaseTest {
Migration10To11(),
Migration11To12(),
Migration12To13(),
Migration13To14(),
)
=======
>>>>>>> devel

View File

@@ -10,6 +10,13 @@
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<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" />
<uses-permission android:name="android.permission.READ_SYNC_STATS" />
<uses-permission android:name="android.permission.READ_SYNC_SETTINGS" />
<uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" />
<application
android:name="org.koitharu.kotatsu.KotatsuApp"
@@ -122,6 +129,9 @@
<activity
android:name="org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity"
android:theme="@style/Theme.Kotatsu.DialogWhenLarge" />
<activity
android:name="org.koitharu.kotatsu.sync.ui.SyncAuthActivity"
android:label="@string/sync" />
<service
android:name="org.koitharu.kotatsu.download.ui.service.DownloadService"
@@ -133,6 +143,41 @@
<service
android:name="org.koitharu.kotatsu.widget.recent.RecentWidgetService"
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
android:name="org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider"
@@ -147,6 +192,18 @@
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/filepaths" />
</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
android:name="org.koitharu.kotatsu.widget.shelf.ShelfWidgetProvider"
@@ -180,4 +237,4 @@
</application>
</manifest>
</manifest>

View File

@@ -38,6 +38,7 @@ import org.koitharu.kotatsu.scrobbling.shikimori.shikimoriModule
import org.koitharu.kotatsu.search.searchModule
import org.koitharu.kotatsu.settings.settingsModule
import org.koitharu.kotatsu.suggestions.suggestionsModule
import org.koitharu.kotatsu.sync.syncModule
import org.koitharu.kotatsu.tracker.trackerModule
import org.koitharu.kotatsu.widget.appWidgetModule
@@ -74,6 +75,7 @@ class KotatsuApp : Application() {
readerModule,
appWidgetModule,
suggestionsModule,
syncModule,
shikimoriModule,
bookmarksModule,
libraryModule,
@@ -154,4 +156,4 @@ class KotatsuApp : Application() {
.detectFragmentTagUsage()
.build()
}
}
}

View File

@@ -19,6 +19,7 @@ class JsonDeserializer(private val json: JSONObject) {
categoryId = json.getLong("category_id"),
sortKey = json.getIntOrDefault("sort_key", 0),
createdAt = json.getLong("created_at"),
deletedAt = 0L,
)
fun toMangaEntity() = MangaEntity(
@@ -51,6 +52,7 @@ class JsonDeserializer(private val json: JSONObject) {
page = json.getInt("page"),
scroll = json.getDouble("scroll").toFloat(),
percent = json.getFloatOrDefault("percent", -1f),
deletedAt = 0L,
)
fun toFavouriteCategoryEntity() = FavouriteCategoryEntity(
@@ -61,5 +63,6 @@ class JsonDeserializer(private val json: JSONObject) {
order = json.getStringOrNull("order") ?: SortOrder.NEWEST.name,
track = json.getBooleanOrDefault("track", true),
isVisibleInLibrary = json.getBooleanOrDefault("show_in_lib", true),
deletedAt = 0L,
)
}
}

View File

@@ -6,4 +6,4 @@ import org.koin.dsl.module
val databaseModule
get() = module {
single { MangaDatabase(androidContext()) }
}
}

View File

@@ -10,8 +10,16 @@ class DatabasePrePopulateCallback(private val resources: Resources) : RoomDataba
override fun onCreate(db: SupportSQLiteDatabase) {
db.execSQL(
"INSERT INTO favourite_categories (created_at, sort_key, title, `order`, track, show_in_lib) VALUES (?,?,?,?,?,?)",
arrayOf(System.currentTimeMillis(), 1, resources.getString(R.string.read_later), SortOrder.NEWEST.name, 1, 1)
"INSERT INTO favourite_categories (created_at, sort_key, title, `order`, track, show_in_lib, `deleted_at`) VALUES (?,?,?,?,?,?,?)",
arrayOf(
System.currentTimeMillis(),
1,
resources.getString(R.string.read_later),
SortOrder.NEWEST.name,
1,
1,
0L,
)
)
}
}
}

View File

@@ -30,7 +30,7 @@ import org.koitharu.kotatsu.tracker.data.TrackEntity
import org.koitharu.kotatsu.tracker.data.TrackLogEntity
import org.koitharu.kotatsu.tracker.data.TracksDao
const val DATABASE_VERSION = 13
const val DATABASE_VERSION = 14
@Database(
entities = [
@@ -80,6 +80,7 @@ val databaseMigrations: Array<Migration>
Migration10To11(),
Migration11To12(),
Migration12To13(),
Migration13To14(),
)
fun MangaDatabase(context: Context): MangaDatabase = Room

View File

@@ -1,6 +1,5 @@
package org.koitharu.kotatsu.core.db
const val TABLE_FAVOURITES = "favourites"
const val TABLE_MANGA = "manga"
const val TABLE_TAGS = "tags"

View File

@@ -20,4 +20,4 @@ data class MangaEntity(
@ColumnInfo(name = "state") val state: String?,
@ColumnInfo(name = "author") val author: String?,
@ColumnInfo(name = "source") val source: String,
)
)

View File

@@ -13,13 +13,13 @@ import org.koitharu.kotatsu.core.db.TABLE_MANGA_TAGS
entity = MangaEntity::class,
parentColumns = ["manga_id"],
childColumns = ["manga_id"],
onDelete = ForeignKey.CASCADE
onDelete = ForeignKey.CASCADE,
),
ForeignKey(
entity = TagEntity::class,
parentColumns = ["tag_id"],
childColumns = ["tag_id"],
onDelete = ForeignKey.CASCADE
onDelete = ForeignKey.CASCADE,
)
]
)

View File

@@ -12,4 +12,4 @@ data class TagEntity(
@ColumnInfo(name = "title") val title: String,
@ColumnInfo(name = "key") val key: String,
@ColumnInfo(name = "source") val source: String,
)
)

View File

@@ -24,4 +24,4 @@ class Migration11To12 : Migration(11, 12) {
database.execSQL("ALTER TABLE history ADD COLUMN `percent` REAL NOT NULL DEFAULT -1")
database.execSQL("ALTER TABLE bookmarks ADD COLUMN `percent` REAL NOT NULL DEFAULT -1")
}
}
}

View File

@@ -0,0 +1,13 @@
package org.koitharu.kotatsu.core.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration13To14 : Migration(13, 14) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE favourite_categories ADD COLUMN `deleted_at` INTEGER NOT NULL DEFAULT 0")
database.execSQL("ALTER TABLE favourites ADD COLUMN `deleted_at` INTEGER NOT NULL DEFAULT 0")
database.execSQL("ALTER TABLE history ADD COLUMN `deleted_at` INTEGER NOT NULL DEFAULT 0")
}
}

View File

@@ -9,6 +9,7 @@ object CommonHeaders {
const val ACCEPT = "Accept"
const val CONTENT_DISPOSITION = "Content-Disposition"
const val COOKIE = "Cookie"
const val CONTENT_ENCODING = "Content-Encoding"
const val AUTHORIZATION = "Authorization"
val CACHE_CONTROL_DISABLED: CacheControl

View File

@@ -2,7 +2,7 @@ package org.koitharu.kotatsu.core.network
import okhttp3.Interceptor
import okhttp3.Response
import org.jsoup.helper.HttpConnection.CONTENT_ENCODING
import org.koitharu.kotatsu.core.network.CommonHeaders.CONTENT_ENCODING
class GZipInterceptor : Interceptor {

View File

@@ -315,6 +315,7 @@ class AppSettings(context: Context) {
const val KEY_DOH = "doh"
const val KEY_EXIT_CONFIRM = "exit_confirm"
const val KEY_INCOGNITO_MODE = "incognito"
const val KEY_SYNC = "sync"
// About
const val KEY_APP_UPDATE = "app_update"
@@ -325,4 +326,4 @@ class AppSettings(context: Context) {
private const val NETWORK_ALWAYS = 1
private const val NETWORK_NON_METERED = 2
}
}
}

View File

@@ -6,13 +6,13 @@ import kotlinx.coroutines.flow.Flow
@Dao
abstract class FavouriteCategoriesDao {
@Query("SELECT * FROM favourite_categories WHERE category_id = :id")
@Query("SELECT * FROM favourite_categories WHERE category_id = :id AND deleted_at = 0")
abstract suspend fun find(id: Int): FavouriteCategoryEntity
@Query("SELECT * FROM favourite_categories ORDER BY sort_key")
@Query("SELECT * FROM favourite_categories WHERE deleted_at = 0 ORDER BY sort_key")
abstract suspend fun findAll(): List<FavouriteCategoryEntity>
@Query("SELECT * FROM favourite_categories ORDER BY sort_key")
@Query("SELECT * FROM favourite_categories WHERE deleted_at = 0 ORDER BY sort_key")
abstract fun observeAll(): Flow<List<FavouriteCategoryEntity>>
@MapInfo(valueColumn = "cover")
@@ -26,7 +26,7 @@ abstract class FavouriteCategoriesDao {
)
abstract fun observeAllWithDetails(): Flow<Map<FavouriteCategoryEntity, List<String>>>
@Query("SELECT * FROM favourite_categories WHERE category_id = :id")
@Query("SELECT * FROM favourite_categories WHERE category_id = :id AND deleted_at = 0")
abstract fun observe(id: Long): Flow<FavouriteCategoryEntity?>
@Insert(onConflict = OnConflictStrategy.ABORT)
@@ -35,11 +35,8 @@ abstract class FavouriteCategoriesDao {
@Update
abstract suspend fun update(category: FavouriteCategoryEntity): Int
@Query("DELETE FROM favourite_categories WHERE category_id = :id")
abstract suspend fun delete(id: Long)
@Query("UPDATE favourite_categories SET title = :title WHERE category_id = :id")
abstract suspend fun updateTitle(id: Long, title: String)
@Query("UPDATE favourite_categories SET deleted_at = :now WHERE category_id = :id")
abstract suspend fun delete(id: Long, now: Long = System.currentTimeMillis())
@Query("UPDATE favourite_categories SET title = :title, `order` = :order, `track` = :tracker WHERE category_id = :id")
abstract suspend fun update(id: Long, title: String, order: String, tracker: Boolean)
@@ -56,7 +53,10 @@ abstract class FavouriteCategoriesDao {
@Query("UPDATE favourite_categories SET sort_key = :sortKey WHERE category_id = :id")
abstract suspend fun updateSortKey(id: Long, sortKey: Int)
@Query("SELECT MAX(sort_key) FROM favourite_categories")
@Query("DELETE FROM favourite_categories WHERE deleted_at != 0 AND deleted_at < :maxDeletionTime")
abstract suspend fun gc(maxDeletionTime: Long)
@Query("SELECT MAX(sort_key) FROM favourite_categories WHERE deleted_at = 0")
protected abstract suspend fun getMaxSortKey(): Int?
suspend fun getNextSortKey(): Int {
@@ -69,4 +69,4 @@ abstract class FavouriteCategoriesDao {
insert(entity)
}
}
}
}

View File

@@ -15,6 +15,7 @@ data class FavouriteCategoryEntity(
@ColumnInfo(name = "order") val order: String,
@ColumnInfo(name = "track") val track: Boolean,
@ColumnInfo(name = "show_in_lib") val isVisibleInLibrary: Boolean,
@ColumnInfo(name = "deleted_at") val deletedAt: Long,
) {
override fun equals(other: Any?): Boolean {
@@ -44,4 +45,4 @@ data class FavouriteCategoryEntity(
result = 31 * result + isVisibleInLibrary.hashCode()
return result
}
}
}

View File

@@ -29,4 +29,5 @@ data class FavouriteEntity(
@ColumnInfo(name = "category_id", index = true) val categoryId: Long,
@ColumnInfo(name = "sort_key") val sortKey: Int,
@ColumnInfo(name = "created_at") val createdAt: Long,
)
@ColumnInfo(name = "deleted_at") val deletedAt: Long,
)

View File

@@ -4,6 +4,7 @@ import androidx.room.*
import androidx.sqlite.db.SimpleSQLiteQuery
import androidx.sqlite.db.SupportSQLiteQuery
import kotlinx.coroutines.flow.Flow
import org.intellij.lang.annotations.Language
import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.parsers.model.SortOrder
@@ -11,53 +12,67 @@ import org.koitharu.kotatsu.parsers.model.SortOrder
abstract class FavouritesDao {
@Transaction
@Query("SELECT * FROM favourites GROUP BY manga_id ORDER BY created_at DESC")
@Query("SELECT * FROM favourites WHERE deleted_at = 0 GROUP BY manga_id ORDER BY created_at DESC")
abstract suspend fun findAll(): List<FavouriteManga>
fun observeAll(order: SortOrder): Flow<List<FavouriteManga>> {
val orderBy = getOrderBy(order)
val query = SimpleSQLiteQuery(
"SELECT * FROM favourites LEFT JOIN manga ON favourites.manga_id = manga.manga_id GROUP BY favourites.manga_id ORDER BY $orderBy",
@Language("RoomSql") val query = SimpleSQLiteQuery(
"SELECT * FROM favourites LEFT JOIN manga ON favourites.manga_id = manga.manga_id " +
"WHERE favourites.deleted_at = 0 GROUP BY favourites.manga_id ORDER BY $orderBy",
)
return observeAllRaw(query)
}
@Transaction
@Query("SELECT * FROM favourites GROUP BY manga_id ORDER BY created_at DESC LIMIT :limit OFFSET :offset")
@Query(
"SELECT * FROM favourites WHERE deleted_at = 0 " +
"GROUP BY manga_id ORDER BY created_at DESC LIMIT :limit OFFSET :offset"
)
abstract suspend fun findAll(offset: Int, limit: Int): List<FavouriteManga>
@Transaction
@Query("SELECT * FROM favourites WHERE category_id = :categoryId GROUP BY manga_id ORDER BY created_at DESC")
@Query(
"SELECT * FROM favourites WHERE category_id = :categoryId AND deleted_at = 0 " +
"GROUP BY manga_id ORDER BY created_at DESC"
)
abstract suspend fun findAll(categoryId: Long): List<FavouriteManga>
fun observeAll(categoryId: Long, order: SortOrder): Flow<List<FavouriteManga>> {
val orderBy = getOrderBy(order)
val query = SimpleSQLiteQuery(
"SELECT * FROM favourites LEFT JOIN manga ON favourites.manga_id = manga.manga_id WHERE category_id = ? GROUP BY favourites.manga_id ORDER BY $orderBy",
@Language("RoomSql") val query = SimpleSQLiteQuery(
"SELECT * FROM favourites LEFT JOIN manga ON favourites.manga_id = manga.manga_id " +
"WHERE category_id = ? AND deleted_at = 0 GROUP BY favourites.manga_id ORDER BY $orderBy",
arrayOf<Any>(categoryId),
)
return observeAllRaw(query)
}
@Transaction
@Query("SELECT * FROM favourites WHERE category_id = :categoryId GROUP BY manga_id ORDER BY created_at DESC LIMIT :limit OFFSET :offset")
@Query(
"SELECT * FROM favourites WHERE category_id = :categoryId AND deleted_at = 0 " +
"GROUP BY manga_id ORDER BY created_at DESC LIMIT :limit OFFSET :offset"
)
abstract suspend fun findAll(categoryId: Long, offset: Int, limit: Int): List<FavouriteManga>
@Query("SELECT * FROM manga WHERE manga_id IN (SELECT manga_id FROM favourites WHERE category_id = :categoryId)")
@Query(
"SELECT * FROM manga WHERE manga_id IN " +
"(SELECT manga_id FROM favourites WHERE category_id = :categoryId AND deleted_at = 0)"
)
abstract suspend fun findAllManga(categoryId: Int): List<MangaEntity>
@Query("SELECT * FROM manga WHERE manga_id IN (SELECT manga_id FROM favourites)")
@Query("SELECT * FROM manga WHERE manga_id IN (SELECT manga_id FROM favourites WHERE deleted_at = 0)")
abstract suspend fun findAllManga(): List<MangaEntity>
@Transaction
@Query("SELECT * FROM favourites WHERE manga_id = :id GROUP BY manga_id")
@Query("SELECT * FROM favourites WHERE manga_id = :id AND deleted_at = 0 GROUP BY manga_id")
abstract suspend fun find(id: Long): FavouriteManga?
@Transaction
@Query("SELECT * FROM favourites WHERE manga_id = :id GROUP BY manga_id")
@Query("SELECT * FROM favourites WHERE manga_id = :id AND deleted_at = 0 GROUP BY manga_id")
abstract fun observe(id: Long): Flow<FavouriteManga?>
@Query("SELECT DISTINCT category_id FROM favourites WHERE manga_id = :id")
@Query("SELECT DISTINCT category_id FROM favourites WHERE manga_id = :id AND deleted_at = 0")
abstract fun observeIds(id: Long): Flow<List<Long>>
@Insert(onConflict = OnConflictStrategy.IGNORE)
@@ -66,11 +81,18 @@ abstract class FavouritesDao {
@Update
abstract suspend fun update(favourite: FavouriteEntity): Int
@Query("DELETE FROM favourites WHERE manga_id = :mangaId")
abstract suspend fun delete(mangaId: Long)
@Query("UPDATE favourites SET deleted_at = :now WHERE manga_id = :mangaId")
abstract suspend fun delete(mangaId: Long, now: Long = System.currentTimeMillis())
@Query("DELETE FROM favourites WHERE manga_id = :mangaId AND category_id = :categoryId")
abstract suspend fun delete(categoryId: Long, mangaId: Long)
@Query("UPDATE favourites SET deleted_at = :now WHERE manga_id = :mangaId AND category_id = :categoryId")
abstract suspend fun delete(categoryId: Long, mangaId: Long, now: Long = System.currentTimeMillis())
suspend fun recover(mangaId: Long) = delete(mangaId, 0L)
suspend fun recover(categoryId: Long, mangaId: Long) = delete(categoryId, mangaId, 0L)
@Query("DELETE FROM favourites WHERE deleted_at != 0 AND deleted_at < :maxDeletionTime")
abstract suspend fun gc(maxDeletionTime: Long)
@Transaction
open suspend fun upsert(entity: FavouriteEntity) {
@@ -83,7 +105,7 @@ abstract class FavouritesDao {
@RawQuery(observedEntities = [FavouriteEntity::class])
protected abstract fun observeAllRaw(query: SupportSQLiteQuery): Flow<List<FavouriteManga>>
private fun getOrderBy(sortOrder: SortOrder) = when(sortOrder) {
private fun getOrderBy(sortOrder: SortOrder) = when (sortOrder) {
SortOrder.RATING -> "rating DESC"
SortOrder.NEWEST,
SortOrder.UPDATED -> "created_at DESC"

View File

@@ -2,12 +2,12 @@ package org.koitharu.kotatsu.favourites.domain
import androidx.room.withTransaction
import kotlinx.coroutines.flow.*
import org.koitharu.kotatsu.base.domain.ReversibleHandle
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.*
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
import org.koitharu.kotatsu.favourites.data.FavouriteEntity
import org.koitharu.kotatsu.favourites.data.FavouriteManga
import org.koitharu.kotatsu.favourites.data.toFavouriteCategory
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.SortOrder
@@ -62,12 +62,6 @@ class FavouritesRepository(
.map { it?.toFavouriteCategory() }
}
fun observeCategories(mangaId: Long): Flow<List<FavouriteCategory>> {
return db.favouritesDao.observe(mangaId).map { entity ->
entity?.categories?.map { it.toFavouriteCategory() }.orEmpty()
}.distinctUntilChanged()
}
fun observeCategoriesIds(mangaId: Long): Flow<Set<Long>> {
return db.favouritesDao.observeIds(mangaId).map { it.toSet() }
}
@@ -84,6 +78,7 @@ class FavouritesRepository(
categoryId = 0,
order = sortOrder.name,
track = isTrackerEnabled,
deletedAt = 0L,
isVisibleInLibrary = true,
)
val id = db.favouriteCategoriesDao.insert(entity)
@@ -100,27 +95,6 @@ class FavouritesRepository(
db.favouriteCategoriesDao.updateLibVisibility(id, isVisibleInLibrary)
}
suspend fun addCategory(title: String): FavouriteCategory {
val entity = FavouriteCategoryEntity(
title = title,
createdAt = System.currentTimeMillis(),
sortKey = db.favouriteCategoriesDao.getNextSortKey(),
categoryId = 0,
order = SortOrder.NEWEST.name,
track = true,
isVisibleInLibrary = true,
)
val id = db.favouriteCategoriesDao.insert(entity)
val category = entity.toFavouriteCategory(id)
channels.createChannel(category)
return category
}
suspend fun renameCategory(id: Long, title: String) {
db.favouriteCategoriesDao.updateTitle(id, title)
channels.renameChannel(id, title)
}
suspend fun removeCategory(id: Long) {
db.favouriteCategoriesDao.delete(id)
channels.deleteChannel(id)
@@ -138,10 +112,6 @@ class FavouritesRepository(
db.favouriteCategoriesDao.updateOrder(id, order.name)
}
suspend fun setCategoryTracking(id: Long, isEnabled: Boolean) {
db.favouriteCategoriesDao.updateTracking(id, isEnabled)
}
suspend fun reorderCategories(orderedIds: List<Long>) {
val dao = db.favouriteCategoriesDao
db.withTransaction {
@@ -162,26 +132,29 @@ class FavouritesRepository(
categoryId = categoryId,
createdAt = System.currentTimeMillis(),
sortKey = 0,
deletedAt = 0L,
)
db.favouritesDao.insert(entity)
}
}
}
suspend fun removeFromFavourites(ids: Collection<Long>) {
suspend fun removeFromFavourites(ids: Collection<Long>): ReversibleHandle {
db.withTransaction {
for (id in ids) {
db.favouritesDao.delete(id)
}
}
return ReversibleHandle { recoverToFavourites(ids) }
}
suspend fun removeFromCategory(categoryId: Long, ids: Collection<Long>) {
suspend fun removeFromCategory(categoryId: Long, ids: Collection<Long>): ReversibleHandle {
db.withTransaction {
for (id in ids) {
db.favouritesDao.delete(categoryId, id)
}
}
return ReversibleHandle { recoverToCategory(categoryId, ids) }
}
private fun observeOrder(categoryId: Long): Flow<SortOrder> {
@@ -190,4 +163,20 @@ class FavouritesRepository(
.map { x -> SortOrder(x.order, SortOrder.NEWEST) }
.distinctUntilChanged()
}
}
private suspend fun recoverToFavourites(ids: Collection<Long>) {
db.withTransaction {
for (id in ids) {
db.favouritesDao.recover(id)
}
}
}
private suspend fun recoverToCategory(categoryId: Long, ids: Collection<Long>) {
db.withTransaction {
for (id in ids) {
db.favouritesDao.recover(categoryId, id)
}
}
}
}

View File

@@ -1,13 +1,17 @@
package org.koitharu.kotatsu.favourites.ui.list
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.view.View
import androidx.appcompat.view.ActionMode
import androidx.appcompat.widget.PopupMenu
import com.google.android.material.snackbar.Snackbar
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.ReversibleHandle
import org.koitharu.kotatsu.base.domain.reverseAsync
import org.koitharu.kotatsu.core.ui.titleRes
import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesActivity
import org.koitharu.kotatsu.list.ui.MangaListFragment
@@ -25,6 +29,12 @@ class FavouritesListFragment : MangaListFragment(), PopupMenu.OnMenuItemClickLis
override val isSwipeRefreshEnabled = false
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewModel.sortOrder.observe(viewLifecycleOwner) { activity?.invalidateOptionsMenu() }
viewModel.onItemsRemoved.observe(viewLifecycleOwner, ::onItemsRemoved)
}
override fun onScrolledToEnd() = Unit
override fun onFilterClick(view: View?) {
@@ -65,6 +75,15 @@ class FavouritesListFragment : MangaListFragment(), PopupMenu.OnMenuItemClickLis
}
}
private fun onItemsRemoved(reversibleHandle: ReversibleHandle) {
val message = viewModel.categoryName?.let {
getString(R.string.removed_from_s, it)
} ?: getString(R.string.removed_from_favourites)
Snackbar.make(binding.recyclerView, message, Snackbar.LENGTH_LONG)
.setAction(R.string.undo) { reversibleHandle.reverseAsync() }
.show()
}
companion object {
const val NO_ID = 0L
@@ -74,4 +93,4 @@ class FavouritesListFragment : MangaListFragment(), PopupMenu.OnMenuItemClickLis
putLong(ARG_CATEGORY_ID, categoryId)
}
}
}
}

View File

@@ -1,10 +1,15 @@
package org.koitharu.kotatsu.favourites.ui.list
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.plus
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.ReversibleHandle
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment.Companion.NO_ID
@@ -12,9 +17,13 @@ import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.history.domain.PROGRESS_NONE
import org.koitharu.kotatsu.list.domain.ListExtraProvider
import org.koitharu.kotatsu.list.ui.MangaListViewModel
import org.koitharu.kotatsu.list.ui.model.*
import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.list.ui.model.toErrorState
import org.koitharu.kotatsu.list.ui.model.toUi
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
class FavouritesListViewModel(
@@ -25,12 +34,15 @@ class FavouritesListViewModel(
private val settings: AppSettings,
) : MangaListViewModel(settings), ListExtraProvider {
private val sortOrder: StateFlow<SortOrder?> = if (categoryId == NO_ID) {
MutableStateFlow(null)
var categoryName: String? = null
private set
val sortOrder: LiveData<SortOrder?> = if (categoryId == NO_ID) {
MutableLiveData(null)
} else {
repository.observeCategory(categoryId)
.map { it?.order }
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, null)
}
override val content = combine(
@@ -39,9 +51,8 @@ class FavouritesListViewModel(
} else {
repository.observeAll(categoryId)
},
sortOrder,
createListModeFlow()
) { list, order, mode ->
) { list, mode ->
when {
list.isEmpty() -> listOf(
EmptyState(
@@ -55,17 +66,26 @@ class FavouritesListViewModel(
actionStringRes = 0,
)
)
else -> buildList<ListModel>(list.size + 1) {
if (order != null) {
add(ListHeader2(emptyList(), order, false))
}
list.toUi(this, mode, this@FavouritesListViewModel)
}
else -> list.toUi(mode, this)
}
}.catch {
emit(listOf(it.toErrorState(canRetry = false)))
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
val onItemsRemoved = SingleLiveEvent<ReversibleHandle>()
init {
if (categoryId != NO_ID) {
launchJob {
categoryName = withContext(Dispatchers.Default) {
runCatching {
repository.getCategory(categoryId).title
}.getOrNull()
}
}
}
}
override fun onRefresh() = Unit
override fun onRetry() = Unit
@@ -74,12 +94,13 @@ class FavouritesListViewModel(
if (ids.isEmpty()) {
return
}
launchJob {
if (categoryId == NO_ID) {
launchJob(Dispatchers.Default) {
val handle = if (categoryId == NO_ID) {
repository.removeFromFavourites(ids)
} else {
repository.removeFromCategory(categoryId, ids)
}
onItemsRemoved.postCall(handle)
}
}
@@ -103,4 +124,4 @@ class FavouritesListViewModel(
PROGRESS_NONE
}
}
}
}

View File

@@ -8,48 +8,46 @@ import org.koitharu.kotatsu.core.db.entity.TagEntity
@Dao
abstract class HistoryDao {
/**
* @hide
*/
@Transaction
@Query("SELECT * FROM history ORDER BY updated_at DESC LIMIT :limit OFFSET :offset")
@Query("SELECT * FROM history WHERE deleted_at = 0 ORDER BY updated_at DESC LIMIT :limit OFFSET :offset")
abstract suspend fun findAll(offset: Int, limit: Int): List<HistoryWithManga>
@Transaction
@Query("SELECT * FROM history WHERE manga_id IN (:ids)")
@Query("SELECT * FROM history WHERE deleted_at = 0 AND manga_id IN (:ids)")
abstract suspend fun findAll(ids: Collection<Long>): List<HistoryEntity?>
@Transaction
@Query("SELECT * FROM history ORDER BY updated_at DESC")
@Query("SELECT * FROM history WHERE deleted_at = 0 ORDER BY updated_at DESC")
abstract fun observeAll(): Flow<List<HistoryWithManga>>
@Query("SELECT * FROM manga WHERE manga_id IN (SELECT manga_id FROM history)")
@Query("SELECT * FROM manga WHERE manga_id IN (SELECT manga_id FROM history WHERE deleted_at = 0)")
abstract suspend fun findAllManga(): List<MangaEntity>
@Query(
"""SELECT tags.* FROM tags
LEFT JOIN manga_tags ON tags.tag_id = manga_tags.tag_id
INNER JOIN history ON history.manga_id = manga_tags.manga_id
WHERE history.deleted_at = 0
GROUP BY manga_tags.tag_id
ORDER BY COUNT(manga_tags.manga_id) DESC
LIMIT :limit"""
)
abstract suspend fun findPopularTags(limit: Int): List<TagEntity>
@Query("SELECT * FROM history WHERE manga_id = :id")
@Query("SELECT * FROM history WHERE manga_id = :id AND deleted_at = 0")
abstract suspend fun find(id: Long): HistoryEntity?
@Query("SELECT * FROM history WHERE manga_id = :id")
@Query("SELECT * FROM history WHERE manga_id = :id AND deleted_at = 0")
abstract fun observe(id: Long): Flow<HistoryEntity?>
@Query("SELECT COUNT(*) FROM history")
@Query("SELECT COUNT(*) FROM history WHERE deleted_at = 0")
abstract fun observeCount(): Flow<Int>
@Query("SELECT percent FROM history WHERE manga_id = :id")
abstract suspend fun findProgress(id: Long): Float?
@Query("UPDATE history SET deleted_at = :now WHERE deleted_at = 0")
abstract suspend fun clear(now: Long = System.currentTimeMillis())
@Query("DELETE FROM history")
abstract suspend fun clear()
@Query("SELECT percent FROM history WHERE manga_id = :id AND deleted_at = 0")
abstract suspend fun findProgress(id: Long): Float?
@Insert(onConflict = OnConflictStrategy.IGNORE)
abstract suspend fun insert(entity: HistoryEntity): Long
@@ -64,8 +62,13 @@ abstract class HistoryDao {
updatedAt: Long,
): Int
@Query("DELETE FROM history WHERE manga_id = :mangaId")
abstract suspend fun delete(mangaId: Long)
@Query("UPDATE history SET deleted_at = :now WHERE manga_id = :mangaId")
abstract suspend fun delete(mangaId: Long, now: Long = System.currentTimeMillis())
suspend fun recover(mangaId: Long) = delete(mangaId, 0L)
@Query("DELETE FROM history WHERE deleted_at != 0 AND deleted_at < :maxDeletionTime")
abstract suspend fun gc(maxDeletionTime: Long)
@Query("DELETE FROM history WHERE created_at >= :minDate")
abstract suspend fun deleteAfter(minDate: Long)
@@ -95,4 +98,4 @@ abstract class HistoryDao {
}
}
}
}
}

View File

@@ -27,4 +27,5 @@ data class HistoryEntity(
@ColumnInfo(name = "page") val page: Int,
@ColumnInfo(name = "scroll") val scroll: Float,
@ColumnInfo(name = "percent") val percent: Float,
)
@ColumnInfo(name = "deleted_at") val deletedAt: Long,
)

View File

@@ -81,6 +81,7 @@ class HistoryRepository(
page = page,
scroll = scroll.toFloat(), // we migrate to int, but decide to not update database
percent = percent,
deletedAt = 0L,
)
)
trackingRepository.syncWithHistory(manga, chapterId)
@@ -107,28 +108,18 @@ class HistoryRepository(
db.historyDao.delete(manga.id)
}
suspend fun delete(ids: Collection<Long>) {
suspend fun deleteAfter(minDate: Long) {
db.historyDao.delete(minDate)
}
suspend fun delete(ids: Collection<Long>): ReversibleHandle {
db.withTransaction {
for (id in ids) {
db.historyDao.delete(id)
}
}
}
suspend fun deleteAfter(minDate: Long) {
db.historyDao.delete(minDate)
}
suspend fun deleteReversible(ids: Collection<Long>): ReversibleHandle {
val entities = db.withTransaction {
val entities = db.historyDao.findAll(ids.toList()).filterNotNull()
for (id in ids) {
db.historyDao.delete(id)
}
entities
}
return ReversibleHandle {
db.historyDao.upsert(entities)
recover(ids)
}
}
@@ -145,4 +136,12 @@ class HistoryRepository(
suspend fun getPopularTags(limit: Int): List<MangaTag> {
return db.historyDao.findPopularTags(limit).map { x -> x.toMangaTag() }
}
}
private suspend fun recover(ids: Collection<Long>) {
db.withTransaction {
for (id in ids) {
db.historyDao.recover(id)
}
}
}
}

View File

@@ -77,7 +77,7 @@ class HistoryListViewModel(
return
}
launchJob(Dispatchers.Default) {
val handle = repository.deleteReversible(ids)
val handle = repository.delete(ids)
onItemsRemoved.postCall(handle)
}
}
@@ -94,9 +94,6 @@ class HistoryListViewModel(
val result = ArrayList<ListModel>(if (grouped) (list.size * 1.4).toInt() else list.size + 1)
val showPercent = settings.isReadingIndicatorsEnabled
var prevDate: DateTimeAgo? = null
if (!grouped) {
result += ListHeader(null, R.string.history, null)
}
for ((manga, history) in list) {
if (grouped) {
val date = timeAgo(history.updatedAt)
@@ -128,4 +125,4 @@ class HistoryListViewModel(
else -> DateTimeAgo.LongAgo
}
}
}
}

View File

@@ -84,7 +84,7 @@ class LibraryViewModel(
return
}
launchJob(Dispatchers.Default) {
val handle = historyRepository.deleteReversible(ids)
val handle = historyRepository.delete(ids)
onActionDone.postCall(ReversibleAction(R.string.removed_from_history, handle))
}
}
@@ -158,4 +158,4 @@ class LibraryViewModel(
else -> DateTimeAgo.LongAgo
}
}
}
}

View File

@@ -21,6 +21,7 @@ import com.google.android.material.navigation.NavigationBarView
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield
import org.koin.android.ext.android.get
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koitharu.kotatsu.R
@@ -44,6 +45,7 @@ import org.koitharu.kotatsu.settings.newsources.NewSourcesDialogFragment
import org.koitharu.kotatsu.settings.onboard.OnboardDialogFragment
import org.koitharu.kotatsu.settings.tools.ToolsFragment
import org.koitharu.kotatsu.suggestions.ui.SuggestionsWorker
import org.koitharu.kotatsu.sync.domain.SyncController
import org.koitharu.kotatsu.tracker.ui.FeedFragment
import org.koitharu.kotatsu.tracker.work.TrackWorker
import org.koitharu.kotatsu.utils.VoiceInputContract
@@ -341,6 +343,8 @@ class MainActivity :
!settings.isSourcesSelected -> OnboardDialogFragment.showWelcome(supportFragmentManager)
settings.newSources.isNotEmpty() -> NewSourcesDialogFragment.show(supportFragmentManager)
}
yield()
get<SyncController>().requestFullSyncAndGc(get())
}
}

View File

@@ -1,11 +1,19 @@
package org.koitharu.kotatsu.settings
import android.accounts.Account
import android.accounts.AccountManager
import android.content.ActivityNotFoundException
import android.content.Intent
import android.content.SharedPreferences
import android.os.Bundle
import android.preference.PreferenceActivity.EXTRA_SHOW_FRAGMENT_ARGUMENTS
import android.view.View
import androidx.preference.ListPreference
import androidx.preference.Preference
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.android.ext.android.inject
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
@@ -58,6 +66,11 @@ class ContentSettingsFragment :
settings.subscribe(this)
}
override fun onResume() {
super.onResume()
bindSyncSummary()
}
override fun onDestroyView() {
settings.unsubscribe(this)
super.onDestroyView()
@@ -90,6 +103,21 @@ class ContentSettingsFragment :
.show()
true
}
AppSettings.KEY_SYNC -> {
val am = AccountManager.get(requireContext())
val accountType = getString(R.string.account_type_sync)
val account = am.getAccountsByType(accountType).firstOrNull()
if (account == null) {
am.addAccount(accountType, accountType, null, null, requireActivity(), null, null)
} else {
try {
startActivity(getSyncSettingsIntent(account))
} catch (_: ActivityNotFoundException) {
Snackbar.make(listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show()
}
}
true
}
else -> super.onPreferenceTreeClick(preference)
}
}
@@ -111,4 +139,28 @@ class ContentSettingsFragment :
summary = getString(R.string.enabled_d_of_d, total - settings.hiddenSources.size, total)
}
}
private fun bindSyncSummary() {
viewLifecycleScope.launch {
val account = withContext(Dispatchers.Default) {
val type = getString(R.string.account_type_sync)
AccountManager.get(requireContext()).getAccountsByType(type).firstOrNull()
}
findPreference<Preference>(AppSettings.KEY_SYNC)?.run {
summary = account?.name ?: getString(R.string.sync_title)
}
}
}
/**
* Some magic
*/
private fun getSyncSettingsIntent(account: Account): Intent {
val args = Bundle(1)
args.putParcelable("account", account)
val intent = Intent("android.settings.ACCOUNT_SYNC_SETTINGS")
@Suppress("DEPRECATION")
intent.putExtra(EXTRA_SHOW_FRAGMENT_ARGUMENTS, args)
return intent
}
}

View File

@@ -0,0 +1,20 @@
package org.koitharu.kotatsu.sync
import androidx.room.InvalidationTracker
import org.koin.android.ext.koin.androidContext
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.bind
import org.koin.dsl.module
import org.koitharu.kotatsu.sync.data.SyncAuthApi
import org.koitharu.kotatsu.sync.domain.SyncController
import org.koitharu.kotatsu.sync.ui.SyncAuthViewModel
val syncModule
get() = module {
single { SyncController(androidContext()) } bind InvalidationTracker.Observer::class
factory { SyncAuthApi(androidContext(), get()) }
viewModel { SyncAuthViewModel(get()) }
}

View File

@@ -0,0 +1,30 @@
package org.koitharu.kotatsu.sync.data
import android.content.Context
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.parsers.util.parseJson
import org.koitharu.kotatsu.utils.ext.toRequestBody
class SyncAuthApi(
context: Context,
private val okHttpClient: OkHttpClient,
) {
private val baseUrl = context.getString(R.string.url_sync_server)
suspend fun authenticate(email: String, password: String): String {
val body = JSONObject(
mapOf("email" to email, "password" to password)
).toRequestBody()
val request = Request.Builder()
.url("$baseUrl/auth")
.post(body)
.build()
val response = okHttpClient.newCall(request).await().parseJson()
return response.getString("token")
}
}

View File

@@ -0,0 +1,38 @@
package org.koitharu.kotatsu.sync.data
import android.accounts.Account
import android.accounts.AccountManager
import android.content.Context
import kotlinx.coroutines.runBlocking
import okhttp3.Authenticator
import okhttp3.Request
import okhttp3.Response
import okhttp3.Route
import org.koitharu.kotatsu.R
class SyncAuthenticator(
context: Context,
private val account: Account,
private val authApi: SyncAuthApi,
) : Authenticator {
private val accountManager = AccountManager.get(context)
private val tokenType = context.getString(R.string.account_type_sync)
override fun authenticate(route: Route?, response: Response): Request? {
val newToken = tryRefreshToken() ?: return null
accountManager.setAuthToken(account, tokenType, newToken)
return response.request.newBuilder()
.header("Authorization", "Bearer $newToken")
.build()
}
private fun tryRefreshToken() = runCatching {
runBlocking {
authApi.authenticate(
account.name,
accountManager.getPassword(account),
)
}
}.getOrNull()
}

View File

@@ -0,0 +1,30 @@
package org.koitharu.kotatsu.sync.data
import android.accounts.Account
import android.accounts.AccountManager
import android.content.Context
import okhttp3.Interceptor
import okhttp3.Response
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.db.DATABASE_VERSION
class SyncInterceptor(
context: Context,
private val account: Account,
) : Interceptor {
private val accountManager = AccountManager.get(context)
private val tokenType = context.getString(R.string.account_type_sync)
override fun intercept(chain: Interceptor.Chain): Response {
val token = accountManager.peekAuthToken(account, tokenType)
val requestBuilder = chain.request().newBuilder()
if (token != null) {
requestBuilder.header("Authorization", "Bearer $token")
}
requestBuilder.header("X-App-Version", BuildConfig.VERSION_CODE.toString())
requestBuilder.header("X-Db-Version", DATABASE_VERSION.toString())
return chain.proceed(requestBuilder.build())
}
}

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,139 @@
package org.koitharu.kotatsu.sync.domain
import android.accounts.Account
import android.accounts.AccountManager
import android.content.ContentResolver
import android.content.Context
import android.os.Bundle
import android.util.ArrayMap
import androidx.room.InvalidationTracker
import androidx.room.withTransaction
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.TABLE_FAVOURITES
import org.koitharu.kotatsu.core.db.TABLE_FAVOURITE_CATEGORIES
import org.koitharu.kotatsu.core.db.TABLE_HISTORY
import org.koitharu.kotatsu.utils.ext.processLifecycleScope
import java.util.concurrent.TimeUnit
class SyncController(
context: Context,
) : InvalidationTracker.Observer(arrayOf(TABLE_HISTORY, TABLE_FAVOURITES, TABLE_FAVOURITE_CATEGORIES)) {
private val am = AccountManager.get(context)
private val accountType = context.getString(R.string.account_type_sync)
private val minSyncInterval = if (BuildConfig.DEBUG) {
TimeUnit.SECONDS.toMillis(5)
} else {
TimeUnit.MINUTES.toMillis(4)
}
private val mutex = Mutex()
private val jobs = ArrayMap<String, Job>(2)
private val defaultGcPeriod: Long // gc period if sync disabled
get() = TimeUnit.HOURS.toMillis(2)
override fun onInvalidated(tables: MutableSet<String>) {
requestSync(
favourites = TABLE_FAVOURITES in tables || TABLE_FAVOURITE_CATEGORIES in tables,
history = TABLE_HISTORY in tables,
)
}
fun getLastSync(account: Account, authority: String): Long {
val key = "last_sync_" + authority.substringAfterLast('.')
val rawValue = am.getUserData(account, key) ?: return 0L
return rawValue.toLongOrNull() ?: 0L
}
fun setLastSync(account: Account, authority: String, time: Long) {
val key = "last_sync_" + authority.substringAfterLast('.')
am.setUserData(account, key, time.toString())
}
suspend fun requestFullSync() = withContext(Dispatchers.Default) {
requestSyncImpl(favourites = true, history = true, db = null)
}
suspend fun requestFullSyncAndGc(database: MangaDatabase) = withContext(Dispatchers.Default) {
requestSyncImpl(favourites = true, history = true, db = database)
}
private fun requestSync(favourites: Boolean, history: Boolean) = processLifecycleScope.launch(Dispatchers.Default) {
requestSyncImpl(favourites = favourites, history = history, db = null)
}
private suspend fun requestSyncImpl(favourites: Boolean, history: Boolean, db: MangaDatabase?) = mutex.withLock {
if (!favourites && !history) {
return
}
val account = peekAccount()
if (account == null || !ContentResolver.getMasterSyncAutomatically()) {
db?.gc(favourites, history)
return
}
var gcHistory = false
var gcFavourites = false
if (favourites) {
if (ContentResolver.getSyncAutomatically(account, AUTHORITY_FAVOURITES)) {
scheduleSync(account, AUTHORITY_FAVOURITES)
} else {
gcFavourites = true
}
}
if (history) {
if (ContentResolver.getSyncAutomatically(account, AUTHORITY_HISTORY)) {
scheduleSync(account, AUTHORITY_HISTORY)
} else {
gcHistory = true
}
}
if (db != null && (gcHistory || gcFavourites)) {
db.gc(gcFavourites, gcHistory)
}
}
private fun scheduleSync(account: Account, authority: String) {
if (ContentResolver.isSyncActive(account, authority) || ContentResolver.isSyncPending(account, authority)) {
return
}
val job = jobs[authority]
if (job?.isActive == true) {
// already scheduled
return
}
val lastSyncTime = getLastSync(account, authority)
val timeLeft = System.currentTimeMillis() - lastSyncTime + minSyncInterval
if (timeLeft <= 0) {
jobs.remove(authority)
ContentResolver.requestSync(account, authority, Bundle.EMPTY)
} else {
jobs[authority] = processLifecycleScope.launch(Dispatchers.Default) {
try {
delay(timeLeft)
} finally {
// run even if scope cancelled
ContentResolver.requestSync(account, authority, Bundle.EMPTY)
}
}
}
}
private fun peekAccount(): Account? {
return am.getAccountsByType(accountType).firstOrNull()
}
private suspend fun MangaDatabase.gc(favourites: Boolean, history: Boolean) = withTransaction {
val deletedAt = System.currentTimeMillis() - defaultGcPeriod
if (history) {
historyDao.gc(deletedAt)
}
if (favourites) {
favouritesDao.gc(deletedAt)
favouriteCategoriesDao.gc(deletedAt)
}
}
}

View File

@@ -0,0 +1,272 @@
package org.koitharu.kotatsu.sync.domain
import android.accounts.Account
import android.content.*
import android.database.Cursor
import android.net.Uri
import androidx.annotation.WorkerThread
import androidx.core.content.contentValuesOf
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.*
import org.koitharu.kotatsu.parsers.util.json.mapJSONTo
import org.koitharu.kotatsu.sync.data.SyncAuthApi
import org.koitharu.kotatsu.sync.data.SyncAuthenticator
import org.koitharu.kotatsu.sync.data.SyncInterceptor
import org.koitharu.kotatsu.utils.GZipInterceptor
import org.koitharu.kotatsu.utils.ext.parseJsonOrNull
import org.koitharu.kotatsu.utils.ext.toContentValues
import org.koitharu.kotatsu.utils.ext.toJson
import org.koitharu.kotatsu.utils.ext.toRequestBody
import java.util.concurrent.TimeUnit
const val AUTHORITY_HISTORY = "org.koitharu.kotatsu.history"
const val AUTHORITY_FAVOURITES = "org.koitharu.kotatsu.favourites"
private const val FIELD_TIMESTAMP = "timestamp"
/**
* Warning! This class may be used in another process
*/
@WorkerThread
class SyncHelper(
context: Context,
account: Account,
private val provider: ContentProviderClient,
) {
private val httpClient = OkHttpClient.Builder()
.authenticator(SyncAuthenticator(context, account, SyncAuthApi(context, OkHttpClient())))
.addInterceptor(SyncInterceptor(context, account))
.addInterceptor(GZipInterceptor())
.build()
private val baseUrl = context.getString(R.string.url_sync_server)
private val defaultGcPeriod: Long // gc period if sync enabled
get() = TimeUnit.DAYS.toMillis(4)
fun syncFavourites(syncResult: SyncResult) {
val data = JSONObject()
data.put(TABLE_FAVOURITE_CATEGORIES, getFavouriteCategories())
data.put(TABLE_FAVOURITES, getFavourites())
data.put(FIELD_TIMESTAMP, System.currentTimeMillis())
val request = Request.Builder()
.url("$baseUrl/resource/$TABLE_FAVOURITES")
.post(data.toRequestBody())
.build()
val response = httpClient.newCall(request).execute().parseJsonOrNull() ?: return
val timestamp = response.getLong(FIELD_TIMESTAMP)
val categoriesResult = upsertFavouriteCategories(response.getJSONArray(TABLE_FAVOURITE_CATEGORIES), timestamp)
syncResult.stats.numDeletes += categoriesResult.first().count?.toLong() ?: 0L
syncResult.stats.numInserts += categoriesResult.drop(1).sumOf { it.count?.toLong() ?: 0L }
val favouritesResult = upsertFavourites(response.getJSONArray(TABLE_FAVOURITES), timestamp)
syncResult.stats.numDeletes += favouritesResult.first().count?.toLong() ?: 0L
syncResult.stats.numInserts += favouritesResult.drop(1).sumOf { it.count?.toLong() ?: 0L }
gcFavourites()
}
fun syncHistory(syncResult: SyncResult) {
val data = JSONObject()
data.put(TABLE_HISTORY, getHistory())
data.put(FIELD_TIMESTAMP, System.currentTimeMillis())
val request = Request.Builder()
.url("$baseUrl/resource/$TABLE_HISTORY")
.post(data.toRequestBody())
.build()
val response = httpClient.newCall(request).execute().parseJsonOrNull() ?: return
val result = upsertHistory(
json = response.getJSONArray(TABLE_HISTORY),
timestamp = response.getLong(FIELD_TIMESTAMP),
)
syncResult.stats.numDeletes += result.first().count?.toLong() ?: 0L
syncResult.stats.numInserts += result.drop(1).sumOf { it.count?.toLong() ?: 0L }
gcHistory()
}
private fun upsertHistory(json: JSONArray, timestamp: Long): Array<ContentProviderResult> {
val uri = uri(AUTHORITY_HISTORY, TABLE_HISTORY)
val operations = ArrayList<ContentProviderOperation>()
operations += ContentProviderOperation.newDelete(uri)
.withSelection("updated_at < ?", arrayOf(timestamp.toString()))
.build()
json.mapJSONTo(operations) { jo ->
operations.addAll(upsertManga(jo.removeJSONObject("manga"), AUTHORITY_HISTORY))
ContentProviderOperation.newInsert(uri)
.withValues(jo.toContentValues())
.build()
}
return provider.applyBatch(operations)
}
private fun upsertFavouriteCategories(json: JSONArray, timestamp: Long): Array<ContentProviderResult> {
val uri = uri(AUTHORITY_FAVOURITES, TABLE_FAVOURITE_CATEGORIES)
val operations = ArrayList<ContentProviderOperation>()
operations += ContentProviderOperation.newDelete(uri)
.withSelection("created_at < ?", arrayOf(timestamp.toString()))
.build()
json.mapJSONTo(operations) { jo ->
ContentProviderOperation.newInsert(uri)
.withValues(jo.toContentValues())
.build()
}
return provider.applyBatch(operations)
}
private fun upsertFavourites(json: JSONArray, timestamp: Long): Array<ContentProviderResult> {
val uri = uri(AUTHORITY_FAVOURITES, TABLE_FAVOURITES)
val operations = ArrayList<ContentProviderOperation>()
operations += ContentProviderOperation.newDelete(uri)
.withSelection("created_at < ?", arrayOf(timestamp.toString()))
.build()
json.mapJSONTo(operations) { jo ->
operations.addAll(upsertManga(jo.removeJSONObject("manga"), AUTHORITY_FAVOURITES))
ContentProviderOperation.newInsert(uri)
.withValues(jo.toContentValues())
.build()
}
return provider.applyBatch(operations)
}
private fun upsertManga(json: JSONObject, authority: String): List<ContentProviderOperation> {
val tags = json.removeJSONArray(TABLE_TAGS)
val result = ArrayList<ContentProviderOperation>(tags.length() * 2 + 1)
for (i in 0 until tags.length()) {
val tag = tags.getJSONObject(i)
result += ContentProviderOperation.newInsert(uri(authority, TABLE_TAGS))
.withValues(tag.toContentValues())
.build()
result += ContentProviderOperation.newInsert(uri(authority, TABLE_MANGA_TAGS))
.withValues(
contentValuesOf(
"manga_id" to json.getLong("manga_id"),
"tag_id" to tag.getLong("tag_id"),
)
).build()
}
result.add(
0,
ContentProviderOperation.newInsert(uri(authority, TABLE_MANGA))
.withValues(json.toContentValues())
.build()
)
return result
}
private fun getHistory(): JSONArray {
return provider.query(AUTHORITY_HISTORY, TABLE_HISTORY).use { cursor ->
val json = JSONArray()
if (cursor.moveToFirst()) {
do {
val jo = cursor.toJson()
jo.put("manga", getManga(AUTHORITY_HISTORY, jo.getLong("manga_id")))
json.put(jo)
} while (cursor.moveToNext())
}
json
}
}
private fun getFavourites(): JSONArray {
return provider.query(AUTHORITY_FAVOURITES, TABLE_FAVOURITES).use { cursor ->
val json = JSONArray()
if (cursor.moveToFirst()) {
do {
val jo = cursor.toJson()
jo.put("manga", getManga(AUTHORITY_FAVOURITES, jo.getLong("manga_id")))
json.put(jo)
} while (cursor.moveToNext())
}
json
}
}
private fun getFavouriteCategories(): JSONArray {
return provider.query(AUTHORITY_FAVOURITES, TABLE_FAVOURITE_CATEGORIES).use { cursor ->
val json = JSONArray()
if (cursor.moveToFirst()) {
do {
json.put(cursor.toJson())
} while (cursor.moveToNext())
}
json
}
}
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 gcFavourites() {
val deletedAt = System.currentTimeMillis() - defaultGcPeriod
val selection = "deleted_at != 0 AND deleted_at < ?"
val args = arrayOf(deletedAt.toString())
provider.delete(uri(AUTHORITY_FAVOURITES, TABLE_FAVOURITES), selection, args)
provider.delete(uri(AUTHORITY_FAVOURITES, TABLE_FAVOURITE_CATEGORIES), selection, args)
}
private fun gcHistory() {
val deletedAt = System.currentTimeMillis() - defaultGcPeriod
val selection = "deleted_at != 0 AND deleted_at < ?"
val args = arrayOf(deletedAt.toString())
provider.delete(uri(AUTHORITY_HISTORY, TABLE_HISTORY), selection, args)
}
private fun ContentProviderClient.query(authority: String, table: String): Cursor {
val uri = uri(authority, table)
return query(uri, null, null, null, null)
?: throw OperationApplicationException("Query failed: $uri")
}
private fun uri(authority: String, table: String) = Uri.parse("content://$authority/$table")
private fun JSONObject.removeJSONObject(name: String) = remove(name) as JSONObject
private fun JSONObject.removeJSONArray(name: String) = remove(name) as JSONArray
}

View File

@@ -0,0 +1,78 @@
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 SyncAccountAuthenticator(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)
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,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,22 @@
package org.koitharu.kotatsu.sync.ui
import kotlinx.coroutines.Dispatchers
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.sync.data.SyncAuthApi
import org.koitharu.kotatsu.sync.domain.SyncAuthResult
import org.koitharu.kotatsu.utils.SingleLiveEvent
class SyncAuthViewModel(
private val api: SyncAuthApi,
) : BaseViewModel() {
val onTokenObtained = SingleLiveEvent<SyncAuthResult>()
fun obtainToken(email: String, password: String) {
launchLoadingJob(Dispatchers.Default) {
val token = api.authenticate(email, password)
val result = SyncAuthResult(email, password, token)
onTokenObtained.postCall(result)
}
}
}

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: SyncAccountAuthenticator
override fun onCreate() {
super.onCreate()
authenticator = SyncAccountAuthenticator(this)
}
override fun onBind(intent: Intent?): IBinder? {
return authenticator.iBinder
}
}

View File

@@ -0,0 +1,111 @@
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.SupportSQLiteDatabase
import androidx.sqlite.db.SupportSQLiteQueryBuilder
import java.util.concurrent.Callable
import org.koin.android.ext.android.inject
import org.koitharu.kotatsu.core.db.*
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
if (db.insert(table, SQLiteDatabase.CONFLICT_IGNORE, values) < 0) {
db.update(table, values)
}
return uri
}
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int {
val table = getTableName(uri) ?: 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 runAtomicTransaction { super.applyBatch(operations) }
}
override fun bulkInsert(uri: Uri, values: Array<out ContentValues>): Int {
return runAtomicTransaction { super.bulkInsert(uri, values) }
}
private fun getTableName(uri: Uri): String? {
return uri.pathSegments.singleOrNull()?.takeIf { it in supportedTables }
}
private fun <R> runAtomicTransaction(callable: Callable<R>): R {
return synchronized(database) {
database.runInTransaction(callable)
}
}
private fun SupportSQLiteDatabase.update(table: String, values: ContentValues) {
val keys = when (table) {
TABLE_TAGS -> listOf("tag_id")
TABLE_MANGA_TAGS -> listOf("tag_id", "manga_id")
TABLE_MANGA -> listOf("manga_id")
TABLE_FAVOURITES -> listOf("manga_id", "category_id")
TABLE_FAVOURITE_CATEGORIES -> listOf("category_id")
TABLE_HISTORY -> listOf("manga_id")
else -> throw IllegalArgumentException("Update for $table is not supported")
}
val whereClause = keys.joinToString(" AND ") { "`$it` = ?" }
val whereArgs = Array<Any>(keys.size) { i -> values.get("`${keys[i]}`") ?: values.get(keys[i]) }
this.update(table, SQLiteDatabase.CONFLICT_IGNORE, values, whereClause, whereArgs)
}
}

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.SyncController
import org.koitharu.kotatsu.sync.domain.SyncHelper
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,
) {
val syncHelper = SyncHelper(context, account, provider)
runCatching {
syncHelper.syncFavourites(syncResult)
SyncController(context).setLastSync(account, authority, System.currentTimeMillis())
}.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,28 @@
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.SyncController
import org.koitharu.kotatsu.sync.domain.SyncHelper
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,
) {
val syncHelper = SyncHelper(context, account, provider)
runCatching {
syncHelper.syncHistory(syncResult)
SyncController(context).setLastSync(account, authority, System.currentTimeMillis())
}.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,5 +1,6 @@
package org.koitharu.kotatsu.utils
import android.util.ArrayMap
import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.isActive
@@ -11,7 +12,7 @@ import kotlin.coroutines.resume
class CompositeMutex<T : Any> : Set<T> {
private val data = HashMap<T, MutableList<CancellableContinuation<Unit>>>()
private val data = ArrayMap<T, MutableList<CancellableContinuation<Unit>>>()
private val mutex = Mutex()
override val size: Int

View File

@@ -0,0 +1,14 @@
package org.koitharu.kotatsu.utils
import okhttp3.Interceptor
import okhttp3.Response
import org.koitharu.kotatsu.core.network.CommonHeaders.CONTENT_ENCODING
class GZipInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val newRequest = chain.request().newBuilder()
newRequest.addHeader(CONTENT_ENCODING, "gzip")
return chain.proceed(newRequest.build())
}
}

View File

@@ -4,9 +4,13 @@ import android.app.ActivityManager
import android.app.ActivityOptions
import android.content.Context
import android.content.Context.ACTIVITY_SERVICE
import android.content.OperationApplicationException
import android.content.SharedPreferences
import android.content.SyncResult
import android.content.pm.ResolveInfo
import android.database.SQLException
import android.graphics.Color
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkRequest
@@ -35,6 +39,9 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import okio.IOException
import org.json.JSONException
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.utils.InternalResourceHelper
import kotlin.coroutines.resume
@@ -111,6 +118,17 @@ fun Lifecycle.postDelayed(runnable: Runnable, delay: Long) {
}
}
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
}
error.printStackTraceDebug()
}
fun Window.setNavigationBarTransparentCompat(context: Context, elevation: Float = 0F) {
navigationBarColor = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q &&
!InternalResourceHelper.getBoolean(context, "config_navBarNeedsScrim", true)
@@ -150,4 +168,4 @@ fun scaleUpActivityOptionsOf(view: View): ActivityOptions = ActivityOptions.make
0,
view.width,
view.height,
)
)

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)) {
JSONObject.NULL, "null", 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

@@ -1,10 +1,20 @@
package org.koitharu.kotatsu.utils.ext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import org.json.JSONObject
import org.koitharu.kotatsu.parsers.util.parseJson
import java.net.HttpURLConnection
private val TYPE_JSON = "application/json".toMediaType()
fun JSONObject.toRequestBody() = toString().toRequestBody(TYPE_JSON)
fun JSONObject.toRequestBody() = toString().toRequestBody(TYPE_JSON)
fun Response.parseJsonOrNull(): JSONObject? {
return if (code == HttpURLConnection.HTTP_NO_CONTENT) {
null
} else {
parseJson()
}
}

View File

@@ -10,6 +10,6 @@ val appWidgetModule
get() = module {
single<InvalidationTracker.Observer> { WidgetUpdater(androidContext()) }
viewModel { ShelfConfigViewModel(get()) }
}
}

View File

@@ -10,7 +10,9 @@ import org.koitharu.kotatsu.core.db.TABLE_HISTORY
import org.koitharu.kotatsu.widget.recent.RecentWidgetProvider
import org.koitharu.kotatsu.widget.shelf.ShelfWidgetProvider
class WidgetUpdater(private val context: Context) : InvalidationTracker.Observer(TABLE_HISTORY, TABLE_FAVOURITES) {
class WidgetUpdater(
private val context: Context
) : InvalidationTracker.Observer(TABLE_HISTORY, TABLE_FAVOURITES) {
override fun onInvalidated(tables: MutableSet<String>) {
if (TABLE_HISTORY in tables) {
@@ -29,4 +31,4 @@ class WidgetUpdater(private val context: Context) : InvalidationTracker.Observer
intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids)
context.sendBroadcast(intent)
}
}
}

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,178 @@
<?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="@string/enter_email_text"
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:endIconMode="password_toggle"
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,12 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="url_github">https://github.com/KotatsuApp/Kotatsu</string>
<string name="url_discord">https://discord.gg/NNJ5RgVBC5</string>
<string name="url_forpda">https://4pda.to/forum/index.php?showtopic=697669</string>
<string name="url_twitter">https://twitter.com/kotatsuapp</string>
<string name="url_reddit">https://reddit.com/user/kotatsuapp</string>
<string name="url_weblate">https://hosted.weblate.org/engage/kotatsu</string>
<string name="email_error_report">kotatsu@waifu.club</string>
<string name="url_github" translatable="false">https://github.com/KotatsuApp/Kotatsu</string>
<string name="url_discord" translatable="false">https://discord.gg/NNJ5RgVBC5</string>
<string name="url_forpda" translatable="false">https://4pda.to/forum/index.php?showtopic=697669</string>
<string name="url_twitter" translatable="false">https://twitter.com/kotatsuapp</string>
<string name="url_reddit" translatable="false">https://reddit.com/user/kotatsuapp</string>
<string name="url_weblate" translatable="false">https://hosted.weblate.org/engage/kotatsu</string>
<string name="email_error_report" translatable="false">kotatsu@waifu.club</string>
<string name="account_type_sync" translatable="false">org.kotatsu.sync</string>
<string name="url_sync_server" translatable="false">http://95.216.215.49:8055</string>
<string-array name="values_theme" translatable="false">
<item>-1</item>
<item>1</item>

View File

@@ -276,6 +276,12 @@
<string name="download_slowdown_summary">Helps avoid blocking your IP address</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="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>
<string name="hide">Hide</string>
<string name="new_sources_text">New manga sources are available</string>
<string name="check_new_chapters_title">Check for new chapters and notify about it</string>
@@ -347,4 +353,7 @@
<string name="storage_usage">Storage usage</string>
<string name="available">Available</string>
<string name="memory_usage_pattern">%s - %s</string>
<string name="enter_email_text">Enter your email to continue</string>
<string name="removed_from_favourites">Removed from favourites</string>
<string name="removed_from_s">Removed from \"%s\"</string>
</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

@@ -39,9 +39,15 @@
android:valueTo="5"
app:defaultValue="2" />
<PreferenceScreen
android:fragment="org.koitharu.kotatsu.settings.backup.BackupSettingsFragment"
android:title="@string/backup_restore"
<Preference
android:key="sync"
android:persistent="false"
android:summary="@string/sync_title"
android:title="@string/sync"
app:allowDividerAbove="true" />
<PreferenceScreen
android:fragment="org.koitharu.kotatsu.settings.backup.BackupSettingsFragment"
android:title="@string/backup_restore" />
</PreferenceScreen>

View File

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<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" />