Merge branch 'feature/sync' into feature/nextgen

pull/189/head
Koitharu 4 years ago
commit 57c1d070d1
No known key found for this signature in database
GPG Key ID: 8E861F8CE6E7CE27

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

@ -10,6 +10,13 @@
<uses-permission android:name="android.permission.VIBRATE" /> <uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" /> <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 <application
android:name="org.koitharu.kotatsu.KotatsuApp" android:name="org.koitharu.kotatsu.KotatsuApp"
@ -122,6 +129,9 @@
<activity <activity
android:name="org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity" android:name="org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity"
android:theme="@style/Theme.Kotatsu.DialogWhenLarge" /> android:theme="@style/Theme.Kotatsu.DialogWhenLarge" />
<activity
android:name="org.koitharu.kotatsu.sync.ui.SyncAuthActivity"
android:label="@string/sync" />
<service <service
android:name="org.koitharu.kotatsu.download.ui.service.DownloadService" android:name="org.koitharu.kotatsu.download.ui.service.DownloadService"
@ -133,6 +143,41 @@
<service <service
android:name="org.koitharu.kotatsu.widget.recent.RecentWidgetService" android:name="org.koitharu.kotatsu.widget.recent.RecentWidgetService"
android:permission="android.permission.BIND_REMOTEVIEWS" /> android:permission="android.permission.BIND_REMOTEVIEWS" />
<service
android:name="org.koitharu.kotatsu.sync.ui.SyncAuthenticatorService"
android:exported="true"
tools:ignore="ExportedService">
<intent-filter>
<action android:name="android.accounts.AccountAuthenticator" />
</intent-filter>
<meta-data
android:name="android.accounts.AccountAuthenticator"
android:resource="@xml/authenticator_sync" />
</service>
<service
android:name="org.koitharu.kotatsu.sync.ui.favourites.FavouritesSyncService"
android:exported="false"
android:label="@string/favourites"
android:process=":sync">
<intent-filter>
<action android:name="android.content.SyncAdapter" />
</intent-filter>
<meta-data
android:name="android.content.SyncAdapter"
android:resource="@xml/sync_favourites" />
</service>
<service
android:name="org.koitharu.kotatsu.sync.ui.history.HistorySyncService"
android:exported="false"
android:label="@string/history"
android:process=":sync">
<intent-filter>
<action android:name="android.content.SyncAdapter" />
</intent-filter>
<meta-data
android:name="android.content.SyncAdapter"
android:resource="@xml/sync_history" />
</service>
<provider <provider
android:name="org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider" android:name="org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider"
@ -147,6 +192,18 @@
android:name="android.support.FILE_PROVIDER_PATHS" android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/filepaths" /> android:resource="@xml/filepaths" />
</provider> </provider>
<provider
android:name="org.koitharu.kotatsu.sync.ui.favourites.FavouritesSyncProvider"
android:authorities="org.koitharu.kotatsu.favourites"
android:exported="false"
android:label="@string/favourites"
android:syncable="true" />
<provider
android:name="org.koitharu.kotatsu.sync.ui.history.HistorySyncProvider"
android:authorities="org.koitharu.kotatsu.history"
android:exported="false"
android:label="@string/history"
android:syncable="true" />
<receiver <receiver
android:name="org.koitharu.kotatsu.widget.shelf.ShelfWidgetProvider" android:name="org.koitharu.kotatsu.widget.shelf.ShelfWidgetProvider"

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

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

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

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

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

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

@ -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")
}
}

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

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

@ -315,6 +315,7 @@ class AppSettings(context: Context) {
const val KEY_DOH = "doh" const val KEY_DOH = "doh"
const val KEY_EXIT_CONFIRM = "exit_confirm" const val KEY_EXIT_CONFIRM = "exit_confirm"
const val KEY_INCOGNITO_MODE = "incognito" const val KEY_INCOGNITO_MODE = "incognito"
const val KEY_SYNC = "sync"
// About // About
const val KEY_APP_UPDATE = "app_update" const val KEY_APP_UPDATE = "app_update"

@ -6,13 +6,13 @@ import kotlinx.coroutines.flow.Flow
@Dao @Dao
abstract class FavouriteCategoriesDao { 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 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> 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>> abstract fun observeAll(): Flow<List<FavouriteCategoryEntity>>
@MapInfo(valueColumn = "cover") @MapInfo(valueColumn = "cover")
@ -26,7 +26,7 @@ abstract class FavouriteCategoriesDao {
) )
abstract fun observeAllWithDetails(): Flow<Map<FavouriteCategoryEntity, List<String>>> 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?> abstract fun observe(id: Long): Flow<FavouriteCategoryEntity?>
@Insert(onConflict = OnConflictStrategy.ABORT) @Insert(onConflict = OnConflictStrategy.ABORT)
@ -35,11 +35,8 @@ abstract class FavouriteCategoriesDao {
@Update @Update
abstract suspend fun update(category: FavouriteCategoryEntity): Int abstract suspend fun update(category: FavouriteCategoryEntity): Int
@Query("DELETE FROM favourite_categories WHERE category_id = :id") @Query("UPDATE favourite_categories SET deleted_at = :now WHERE category_id = :id")
abstract suspend fun delete(id: Long) abstract suspend fun delete(id: Long, now: Long = System.currentTimeMillis())
@Query("UPDATE favourite_categories SET title = :title WHERE category_id = :id")
abstract suspend fun updateTitle(id: Long, title: String)
@Query("UPDATE favourite_categories SET title = :title, `order` = :order, `track` = :tracker WHERE category_id = :id") @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) 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") @Query("UPDATE favourite_categories SET sort_key = :sortKey WHERE category_id = :id")
abstract suspend fun updateSortKey(id: Long, sortKey: Int) 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? protected abstract suspend fun getMaxSortKey(): Int?
suspend fun getNextSortKey(): Int { suspend fun getNextSortKey(): Int {

@ -15,6 +15,7 @@ data class FavouriteCategoryEntity(
@ColumnInfo(name = "order") val order: String, @ColumnInfo(name = "order") val order: String,
@ColumnInfo(name = "track") val track: Boolean, @ColumnInfo(name = "track") val track: Boolean,
@ColumnInfo(name = "show_in_lib") val isVisibleInLibrary: Boolean, @ColumnInfo(name = "show_in_lib") val isVisibleInLibrary: Boolean,
@ColumnInfo(name = "deleted_at") val deletedAt: Long,
) { ) {
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {

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

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

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

@ -1,13 +1,17 @@
package org.koitharu.kotatsu.favourites.ui.list package org.koitharu.kotatsu.favourites.ui.list
import android.os.Bundle
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import androidx.appcompat.view.ActionMode import androidx.appcompat.view.ActionMode
import androidx.appcompat.widget.PopupMenu import androidx.appcompat.widget.PopupMenu
import com.google.android.material.snackbar.Snackbar
import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
import org.koitharu.kotatsu.R 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.core.ui.titleRes
import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesActivity import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesActivity
import org.koitharu.kotatsu.list.ui.MangaListFragment import org.koitharu.kotatsu.list.ui.MangaListFragment
@ -25,6 +29,12 @@ class FavouritesListFragment : MangaListFragment(), PopupMenu.OnMenuItemClickLis
override val isSwipeRefreshEnabled = false 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 onScrolledToEnd() = Unit
override fun onFilterClick(view: View?) { 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 { companion object {
const val NO_ID = 0L const val NO_ID = 0L

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

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

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

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

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

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

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

@ -1,11 +1,19 @@
package org.koitharu.kotatsu.settings 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.content.SharedPreferences
import android.os.Bundle import android.os.Bundle
import android.preference.PreferenceActivity.EXTRA_SHOW_FRAGMENT_ARGUMENTS
import android.view.View import android.view.View
import androidx.preference.ListPreference import androidx.preference.ListPreference
import androidx.preference.Preference import androidx.preference.Preference
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BasePreferenceFragment import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
@ -58,6 +66,11 @@ class ContentSettingsFragment :
settings.subscribe(this) settings.subscribe(this)
} }
override fun onResume() {
super.onResume()
bindSyncSummary()
}
override fun onDestroyView() { override fun onDestroyView() {
settings.unsubscribe(this) settings.unsubscribe(this)
super.onDestroyView() super.onDestroyView()
@ -90,6 +103,21 @@ class ContentSettingsFragment :
.show() .show()
true 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) else -> super.onPreferenceTreeClick(preference)
} }
} }
@ -111,4 +139,28 @@ class ContentSettingsFragment :
summary = getString(R.string.enabled_d_of_d, total - settings.hiddenSources.size, total) 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
}
} }

@ -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()) }
}

@ -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")
}
}

@ -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()
}

@ -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())
}
}

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

@ -0,0 +1,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)
}
}
}

@ -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
}

@ -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
}

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

@ -0,0 +1,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)
}
}
}

@ -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
}
}

@ -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)
}
}

@ -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)
}
}

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

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

@ -0,0 +1,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)
}
}

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

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

@ -1,5 +1,6 @@
package org.koitharu.kotatsu.utils package org.koitharu.kotatsu.utils
import android.util.ArrayMap
import kotlinx.coroutines.CancellableContinuation import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
@ -11,7 +12,7 @@ import kotlin.coroutines.resume
class CompositeMutex<T : Any> : Set<T> { 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() private val mutex = Mutex()
override val size: Int override val size: Int

@ -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())
}
}

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

@ -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`"

@ -1,10 +1,20 @@
package org.koitharu.kotatsu.utils.ext package org.koitharu.kotatsu.utils.ext
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import org.json.JSONObject import org.json.JSONObject
import org.koitharu.kotatsu.parsers.util.parseJson
import java.net.HttpURLConnection
private val TYPE_JSON = "application/json".toMediaType() 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()
}
}

@ -10,7 +10,9 @@ import org.koitharu.kotatsu.core.db.TABLE_HISTORY
import org.koitharu.kotatsu.widget.recent.RecentWidgetProvider import org.koitharu.kotatsu.widget.recent.RecentWidgetProvider
import org.koitharu.kotatsu.widget.shelf.ShelfWidgetProvider 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>) { override fun onInvalidated(tables: MutableSet<String>) {
if (TABLE_HISTORY in tables) { if (TABLE_HISTORY in tables) {

@ -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>

@ -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>

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

@ -276,6 +276,12 @@
<string name="download_slowdown_summary">Helps avoid blocking your IP address</string> <string name="download_slowdown_summary">Helps avoid blocking your IP address</string>
<string name="local_manga_processing">Saved manga processing</string> <string name="local_manga_processing">Saved manga processing</string>
<string name="chapters_will_removed_background">Chapters will be removed in the background. It can take some time</string> <string name="chapters_will_removed_background">Chapters will be removed in the background. It can take some time</string>
<string name="canceled">Canceled</string>
<string name="account_already_exists">Account already exists</string>
<string name="back">Back</string>
<string name="sync">Synchronization</string>
<string name="sync_title">Sync your data</string>
<string name="email_enter_hint">Enter your email to continue</string>
<string name="hide">Hide</string> <string name="hide">Hide</string>
<string name="new_sources_text">New manga sources are available</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> <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="storage_usage">Storage usage</string>
<string name="available">Available</string> <string name="available">Available</string>
<string name="memory_usage_pattern">%s - %s</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> </resources>

@ -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" />

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

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen />

@ -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" />

@ -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" />
Loading…
Cancel
Save