diff --git a/app/src/androidTest/java/org/koitharu/kotatsu/core/db/MangaDatabaseTest.kt b/app/src/androidTest/java/org/koitharu/kotatsu/core/db/MangaDatabaseTest.kt index 6ff4fe4b2..daf61989e 100644 --- a/app/src/androidTest/java/org/koitharu/kotatsu/core/db/MangaDatabaseTest.kt +++ b/app/src/androidTest/java/org/koitharu/kotatsu/core/db/MangaDatabaseTest.kt @@ -68,6 +68,7 @@ class MangaDatabaseTest { Migration10To11(), Migration11To12(), Migration12To13(), + Migration13To14(), ) ======= >>>>>>> devel diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index bad45ea16..880e595c3 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -10,6 +10,13 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + - \ No newline at end of file + diff --git a/app/src/main/java/org/koitharu/kotatsu/KotatsuApp.kt b/app/src/main/java/org/koitharu/kotatsu/KotatsuApp.kt index 7c2d53226..957dc47ea 100644 --- a/app/src/main/java/org/koitharu/kotatsu/KotatsuApp.kt +++ b/app/src/main/java/org/koitharu/kotatsu/KotatsuApp.kt @@ -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() } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/koitharu/kotatsu/core/backup/JsonDeserializer.kt b/app/src/main/java/org/koitharu/kotatsu/core/backup/JsonDeserializer.kt index 9416afcb9..42aa846b1 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/backup/JsonDeserializer.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/backup/JsonDeserializer.kt @@ -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, ) -} \ No newline at end of file +} diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/DatabaseModule.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/DatabaseModule.kt index 215d02259..150dba8dd 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/db/DatabaseModule.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/DatabaseModule.kt @@ -6,4 +6,4 @@ import org.koin.dsl.module val databaseModule get() = module { single { MangaDatabase(androidContext()) } - } \ No newline at end of file + } diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/DatabasePrePopulateCallback.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/DatabasePrePopulateCallback.kt index 435882014..e13b0d26a 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/db/DatabasePrePopulateCallback.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/DatabasePrePopulateCallback.kt @@ -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, + ) ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/MangaDatabase.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/MangaDatabase.kt index 5552e10d5..a9649e942 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/db/MangaDatabase.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/MangaDatabase.kt @@ -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 Migration10To11(), Migration11To12(), Migration12To13(), + Migration13To14(), ) fun MangaDatabase(context: Context): MangaDatabase = Room diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/Tables.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/Tables.kt index 28920a626..7fd8c8786 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/db/Tables.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/Tables.kt @@ -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" diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaEntity.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaEntity.kt index 36e534c71..e2c474399 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaEntity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaEntity.kt @@ -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, -) \ No newline at end of file +) diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaTagsEntity.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaTagsEntity.kt index e7a59c5d0..bc343f784 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaTagsEntity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/MangaTagsEntity.kt @@ -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, ) ] ) diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/entity/TagEntity.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/TagEntity.kt index 7f147c992..6c7907da6 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/db/entity/TagEntity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/entity/TagEntity.kt @@ -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, -) \ No newline at end of file +) diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration11To12.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration11To12.kt index 9d13d2420..ae82764b2 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration11To12.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration11To12.kt @@ -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") } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration13To14.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration13To14.kt new file mode 100644 index 000000000..204e20843 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/migrations/Migration13To14.kt @@ -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") + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/core/network/CommonHeaders.kt b/app/src/main/java/org/koitharu/kotatsu/core/network/CommonHeaders.kt index f377404c8..9b27ccfc2 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/network/CommonHeaders.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/network/CommonHeaders.kt @@ -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 diff --git a/app/src/main/java/org/koitharu/kotatsu/core/network/GZipInterceptor.kt b/app/src/main/java/org/koitharu/kotatsu/core/network/GZipInterceptor.kt index d923b0cec..1e53a4e7e 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/network/GZipInterceptor.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/network/GZipInterceptor.kt @@ -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 { diff --git a/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt index 826573312..cbb330d6e 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt @@ -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 } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteCategoriesDao.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteCategoriesDao.kt index e3227561a..b4b94737f 100644 --- a/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteCategoriesDao.kt +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteCategoriesDao.kt @@ -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 - @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> @MapInfo(valueColumn = "cover") @@ -26,7 +26,7 @@ abstract class FavouriteCategoriesDao { ) abstract fun observeAllWithDetails(): Flow>> - @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 @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) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteCategoryEntity.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteCategoryEntity.kt index 062454a82..befd29a72 100644 --- a/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteCategoryEntity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteCategoryEntity.kt @@ -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 } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteEntity.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteEntity.kt index 209707a3b..87f8afa55 100644 --- a/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteEntity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouriteEntity.kt @@ -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, -) \ No newline at end of file + @ColumnInfo(name = "deleted_at") val deletedAt: Long, +) diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt index 89fcc92fb..ac7dd88a0 100644 --- a/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt @@ -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 fun observeAll(order: SortOrder): Flow> { 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 @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 fun observeAll(categoryId: Long, order: SortOrder): Flow> { 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(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 - @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 - @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 @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 - @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> @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> - 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" diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt index 3f09d261e..2f48167fc 100644 --- a/app/src/main/java/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt @@ -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> { - return db.favouritesDao.observe(mangaId).map { entity -> - entity?.categories?.map { it.toFavouriteCategory() }.orEmpty() - }.distinctUntilChanged() - } - fun observeCategoriesIds(mangaId: Long): Flow> { 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) { 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) { + suspend fun removeFromFavourites(ids: Collection): ReversibleHandle { db.withTransaction { for (id in ids) { db.favouritesDao.delete(id) } } + return ReversibleHandle { recoverToFavourites(ids) } } - suspend fun removeFromCategory(categoryId: Long, ids: Collection) { + suspend fun removeFromCategory(categoryId: Long, ids: Collection): ReversibleHandle { db.withTransaction { for (id in ids) { db.favouritesDao.delete(categoryId, id) } } + return ReversibleHandle { recoverToCategory(categoryId, ids) } } private fun observeOrder(categoryId: Long): Flow { @@ -190,4 +163,20 @@ class FavouritesRepository( .map { x -> SortOrder(x.order, SortOrder.NEWEST) } .distinctUntilChanged() } -} \ No newline at end of file + + private suspend fun recoverToFavourites(ids: Collection) { + db.withTransaction { + for (id in ids) { + db.favouritesDao.recover(id) + } + } + } + + private suspend fun recoverToCategory(categoryId: Long, ids: Collection) { + db.withTransaction { + for (id in ids) { + db.favouritesDao.recover(categoryId, id) + } + } + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListFragment.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListFragment.kt index 1fef50c93..cbb569de8 100644 --- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListFragment.kt @@ -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) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListViewModel.kt index 9558207ec..c444cfa09 100644 --- a/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/favourites/ui/list/FavouritesListViewModel.kt @@ -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 = if (categoryId == NO_ID) { - MutableStateFlow(null) + var categoryName: String? = null + private set + + val sortOrder: LiveData = 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(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() + + 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 } } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryDao.kt b/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryDao.kt index 77d7e431c..10a912494 100644 --- a/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryDao.kt +++ b/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryDao.kt @@ -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 @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): List @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> - @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 @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 - @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 - @Query("SELECT COUNT(*) FROM history") + @Query("SELECT COUNT(*) FROM history WHERE deleted_at = 0") abstract fun observeCount(): Flow - @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 { } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryEntity.kt b/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryEntity.kt index 38e2daa7b..c499e8c37 100644 --- a/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryEntity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryEntity.kt @@ -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, -) \ No newline at end of file + @ColumnInfo(name = "deleted_at") val deletedAt: Long, +) diff --git a/app/src/main/java/org/koitharu/kotatsu/history/domain/HistoryRepository.kt b/app/src/main/java/org/koitharu/kotatsu/history/domain/HistoryRepository.kt index 510cf16d0..52a34fd91 100644 --- a/app/src/main/java/org/koitharu/kotatsu/history/domain/HistoryRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/history/domain/HistoryRepository.kt @@ -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) { + suspend fun deleteAfter(minDate: Long) { + db.historyDao.delete(minDate) + } + + suspend fun delete(ids: Collection): 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): 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 { return db.historyDao.findPopularTags(limit).map { x -> x.toMangaTag() } } -} \ No newline at end of file + + private suspend fun recover(ids: Collection) { + db.withTransaction { + for (id in ids) { + db.historyDao.recover(id) + } + } + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt index 7cb6d266e..468d4ae91 100644 --- a/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt @@ -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(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 } } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/koitharu/kotatsu/library/ui/LibraryViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/library/ui/LibraryViewModel.kt index d3024c8bc..5762d16b7 100644 --- a/app/src/main/java/org/koitharu/kotatsu/library/ui/LibraryViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/library/ui/LibraryViewModel.kt @@ -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 } } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt b/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt index 721ebc28e..fb463cea5 100644 --- a/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt +++ b/app/src/main/java/org/koitharu/kotatsu/main/ui/MainActivity.kt @@ -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().requestFullSyncAndGc(get()) } } diff --git a/app/src/main/java/org/koitharu/kotatsu/settings/ContentSettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/settings/ContentSettingsFragment.kt index 90e6e6c8e..19506de9e 100644 --- a/app/src/main/java/org/koitharu/kotatsu/settings/ContentSettingsFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/settings/ContentSettingsFragment.kt @@ -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(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 + } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/sync/SyncModule.kt b/app/src/main/java/org/koitharu/kotatsu/sync/SyncModule.kt new file mode 100644 index 000000000..ba0d9f2a9 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/sync/SyncModule.kt @@ -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()) } + } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/sync/data/SyncAuthApi.kt b/app/src/main/java/org/koitharu/kotatsu/sync/data/SyncAuthApi.kt new file mode 100644 index 000000000..744670526 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/sync/data/SyncAuthApi.kt @@ -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") + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/sync/data/SyncAuthenticator.kt b/app/src/main/java/org/koitharu/kotatsu/sync/data/SyncAuthenticator.kt new file mode 100644 index 000000000..409feba44 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/sync/data/SyncAuthenticator.kt @@ -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() +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/sync/data/SyncInterceptor.kt b/app/src/main/java/org/koitharu/kotatsu/sync/data/SyncInterceptor.kt new file mode 100644 index 000000000..0a9cc0db4 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/sync/data/SyncInterceptor.kt @@ -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()) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/sync/domain/SyncAuthResult.kt b/app/src/main/java/org/koitharu/kotatsu/sync/domain/SyncAuthResult.kt new file mode 100644 index 000000000..e16e1241c --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/sync/domain/SyncAuthResult.kt @@ -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 + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/sync/domain/SyncController.kt b/app/src/main/java/org/koitharu/kotatsu/sync/domain/SyncController.kt new file mode 100644 index 000000000..2924b8480 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/sync/domain/SyncController.kt @@ -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(2) + private val defaultGcPeriod: Long // gc period if sync disabled + get() = TimeUnit.HOURS.toMillis(2) + + override fun onInvalidated(tables: MutableSet) { + 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) + } + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/sync/domain/SyncHelper.kt b/app/src/main/java/org/koitharu/kotatsu/sync/domain/SyncHelper.kt new file mode 100644 index 000000000..71d4925d8 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/sync/domain/SyncHelper.kt @@ -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 { + val uri = uri(AUTHORITY_HISTORY, TABLE_HISTORY) + val operations = ArrayList() + 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 { + val uri = uri(AUTHORITY_FAVOURITES, TABLE_FAVOURITE_CATEGORIES) + val operations = ArrayList() + 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 { + val uri = uri(AUTHORITY_FAVOURITES, TABLE_FAVOURITES) + val operations = ArrayList() + 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 { + val tags = json.removeJSONArray(TABLE_TAGS) + val result = ArrayList(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 +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/sync/ui/SyncAccountAuthenticator.kt b/app/src/main/java/org/koitharu/kotatsu/sync/ui/SyncAccountAuthenticator.kt new file mode 100644 index 000000000..d1e4eb05c --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/sync/ui/SyncAccountAuthenticator.kt @@ -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?, + 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?, + ): Bundle? = null +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/sync/ui/SyncAuthActivity.kt b/app/src/main/java/org/koitharu/kotatsu/sync/ui/SyncAuthActivity.kt new file mode 100644 index 000000000..8d8efbf37 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/sync/ui/SyncAuthActivity.kt @@ -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(), View.OnClickListener { + + private var accountAuthenticatorResponse: AccountAuthenticatorResponse? = null + private var resultBundle: Bundle? = null + + private val viewModel by viewModel() + + 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 + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/sync/ui/SyncAuthViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/sync/ui/SyncAuthViewModel.kt new file mode 100644 index 000000000..da6dfa32c --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/sync/ui/SyncAuthViewModel.kt @@ -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() + + fun obtainToken(email: String, password: String) { + launchLoadingJob(Dispatchers.Default) { + val token = api.authenticate(email, password) + val result = SyncAuthResult(email, password, token) + onTokenObtained.postCall(result) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/sync/ui/SyncAuthenticatorService.kt b/app/src/main/java/org/koitharu/kotatsu/sync/ui/SyncAuthenticatorService.kt new file mode 100644 index 000000000..1262a9e9c --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/sync/ui/SyncAuthenticatorService.kt @@ -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 + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/sync/ui/SyncProvider.kt b/app/src/main/java/org/koitharu/kotatsu/sync/ui/SyncProvider.kt new file mode 100644 index 000000000..604e6de9d --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/sync/ui/SyncProvider.kt @@ -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() + 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?, + selection: String?, + selectionArgs: Array?, + 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?): 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?): 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): Array { + return runAtomicTransaction { super.applyBatch(operations) } + } + + override fun bulkInsert(uri: Uri, values: Array): Int { + return runAtomicTransaction { super.bulkInsert(uri, values) } + } + + private fun getTableName(uri: Uri): String? { + return uri.pathSegments.singleOrNull()?.takeIf { it in supportedTables } + } + + private fun runAtomicTransaction(callable: Callable): 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(keys.size) { i -> values.get("`${keys[i]}`") ?: values.get(keys[i]) } + this.update(table, SQLiteDatabase.CONFLICT_IGNORE, values, whereClause, whereArgs) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/sync/ui/favourites/FavouritesSyncAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/sync/ui/favourites/FavouritesSyncAdapter.kt new file mode 100644 index 000000000..fa6995793 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/sync/ui/favourites/FavouritesSyncAdapter.kt @@ -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) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/sync/ui/favourites/FavouritesSyncProvider.kt b/app/src/main/java/org/koitharu/kotatsu/sync/ui/favourites/FavouritesSyncProvider.kt new file mode 100644 index 000000000..d09666ee6 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/sync/ui/favourites/FavouritesSyncProvider.kt @@ -0,0 +1,5 @@ +package org.koitharu.kotatsu.sync.ui.favourites + +import org.koitharu.kotatsu.sync.ui.SyncProvider + +class FavouritesSyncProvider : SyncProvider() \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/sync/ui/favourites/FavouritesSyncService.kt b/app/src/main/java/org/koitharu/kotatsu/sync/ui/favourites/FavouritesSyncService.kt new file mode 100644 index 000000000..397b4e144 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/sync/ui/favourites/FavouritesSyncService.kt @@ -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 + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/sync/ui/history/HistorySyncAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/sync/ui/history/HistorySyncAdapter.kt new file mode 100644 index 000000000..43c8978c9 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/sync/ui/history/HistorySyncAdapter.kt @@ -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) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/sync/ui/history/HistorySyncProvider.kt b/app/src/main/java/org/koitharu/kotatsu/sync/ui/history/HistorySyncProvider.kt new file mode 100644 index 000000000..f4bf2cdd3 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/sync/ui/history/HistorySyncProvider.kt @@ -0,0 +1,5 @@ +package org.koitharu.kotatsu.sync.ui.history + +import org.koitharu.kotatsu.sync.ui.SyncProvider + +class HistorySyncProvider : SyncProvider() \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/sync/ui/history/HistorySyncService.kt b/app/src/main/java/org/koitharu/kotatsu/sync/ui/history/HistorySyncService.kt new file mode 100644 index 000000000..4fdc8f00e --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/sync/ui/history/HistorySyncService.kt @@ -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 + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/CompositeMutex.kt b/app/src/main/java/org/koitharu/kotatsu/utils/CompositeMutex.kt index 6433ca44e..7f9e2278d 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/CompositeMutex.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/CompositeMutex.kt @@ -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 : Set { - private val data = HashMap>>() + private val data = ArrayMap>>() private val mutex = Mutex() override val size: Int diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/GZipInterceptor.kt b/app/src/main/java/org/koitharu/kotatsu/utils/GZipInterceptor.kt new file mode 100644 index 000000000..5da93ae8a --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/GZipInterceptor.kt @@ -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()) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/AndroidExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/AndroidExt.kt index 4f763c903..8818d607f 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/AndroidExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/AndroidExt.kt @@ -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, -) \ No newline at end of file +) diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/CursorExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/CursorExt.kt new file mode 100644 index 000000000..eeab153b0 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/CursorExt.kt @@ -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`" \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/HttpExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/HttpExt.kt index 058ca4ea6..a78792727 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/HttpExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/HttpExt.kt @@ -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) \ No newline at end of file +fun JSONObject.toRequestBody() = toString().toRequestBody(TYPE_JSON) + +fun Response.parseJsonOrNull(): JSONObject? { + return if (code == HttpURLConnection.HTTP_NO_CONTENT) { + null + } else { + parseJson() + } +} diff --git a/app/src/main/java/org/koitharu/kotatsu/widget/AppWidgetModule.kt b/app/src/main/java/org/koitharu/kotatsu/widget/AppWidgetModule.kt index fda8aba02..2e83ca23b 100644 --- a/app/src/main/java/org/koitharu/kotatsu/widget/AppWidgetModule.kt +++ b/app/src/main/java/org/koitharu/kotatsu/widget/AppWidgetModule.kt @@ -10,6 +10,6 @@ val appWidgetModule get() = module { single { WidgetUpdater(androidContext()) } - + viewModel { ShelfConfigViewModel(get()) } - } \ No newline at end of file + } diff --git a/app/src/main/java/org/koitharu/kotatsu/widget/WidgetUpdater.kt b/app/src/main/java/org/koitharu/kotatsu/widget/WidgetUpdater.kt index 185d4d5b2..8af7f10f2 100644 --- a/app/src/main/java/org/koitharu/kotatsu/widget/WidgetUpdater.kt +++ b/app/src/main/java/org/koitharu/kotatsu/widget/WidgetUpdater.kt @@ -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) { 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) } -} \ No newline at end of file +} diff --git a/app/src/main/res/drawable/ic_sync.xml b/app/src/main/res/drawable/ic_sync.xml new file mode 100644 index 000000000..ad631f02e --- /dev/null +++ b/app/src/main/res/drawable/ic_sync.xml @@ -0,0 +1,12 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_sync_auth.xml b/app/src/main/res/layout/activity_sync_auth.xml new file mode 100644 index 000000000..86ff29e22 --- /dev/null +++ b/app/src/main/res/layout/activity_sync_auth.xml @@ -0,0 +1,178 @@ + + + + + + + + + + + + + + + + + +