Merge pull request #212 from inorichi/backup

Support backups
This commit is contained in:
inorichi 2016-03-29 20:54:15 +02:00
commit a809b05808
17 changed files with 1361 additions and 7 deletions

View file

@ -0,0 +1,381 @@
package eu.kanade.tachiyomi.data.backup
import com.google.gson.*
import com.google.gson.reflect.TypeToken
import com.google.gson.stream.JsonReader
import eu.kanade.tachiyomi.data.backup.serializer.IdExclusion
import eu.kanade.tachiyomi.data.backup.serializer.IntegerSerializer
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.*
import java.io.*
import java.lang.reflect.Type
import java.util.*
/**
* This class provides the necessary methods to create and restore backups for the data of the
* application. The backup follows a JSON structure, with the following scheme:
*
* {
* "mangas": [
* {
* "manga": {"id": 1, ...},
* "chapters": [{"id": 1, ...}, {...}],
* "sync": [{"id": 1, ...}, {...}],
* "categories": ["cat1", "cat2", ...]
* },
* { ... }
* ],
* "categories": [
* {"id": 1, ...},
* {"id": 2, ...}
* ]
* }
*
* @param db the database helper.
*/
class BackupManager(private val db: DatabaseHelper) {
private val MANGA = "manga"
private val MANGAS = "mangas"
private val CHAPTERS = "chapters"
private val MANGA_SYNC = "sync"
private val CATEGORIES = "categories"
@Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN")
private val gson = GsonBuilder()
.registerTypeAdapter(Integer::class.java, IntegerSerializer())
.setExclusionStrategies(IdExclusion())
.create()
/**
* Backups the data of the application to a file.
*
* @param file the file where the backup will be saved.
* @throws IOException if there's any IO error.
*/
@Throws(IOException::class)
fun backupToFile(file: File) {
val root = backupToJson()
FileWriter(file).use {
gson.toJson(root, it)
}
}
/**
* Creates a JSON object containing the backup of the app's data.
*
* @return the backup as a JSON object.
*/
fun backupToJson(): JsonObject {
val root = JsonObject()
// Backup library mangas and its dependencies
val mangaEntries = JsonArray()
root.add(MANGAS, mangaEntries)
for (manga in db.getFavoriteMangas().executeAsBlocking()) {
mangaEntries.add(backupManga(manga))
}
// Backup categories
val categoryEntries = JsonArray()
root.add(CATEGORIES, categoryEntries)
for (category in db.getCategories().executeAsBlocking()) {
categoryEntries.add(backupCategory(category))
}
return root
}
/**
* Backups a manga and its related data (chapters, categories this manga is in, sync...).
*
* @param manga the manga to backup.
* @return a JSON object containing all the data of the manga.
*/
private fun backupManga(manga: Manga): JsonObject {
// Entry for this manga
val entry = JsonObject()
// Backup manga fields
entry.add(MANGA, gson.toJsonTree(manga))
// Backup all the chapters
val chapters = db.getChapters(manga).executeAsBlocking()
if (!chapters.isEmpty()) {
entry.add(CHAPTERS, gson.toJsonTree(chapters))
}
// Backup manga sync
val mangaSync = db.getMangasSync(manga).executeAsBlocking()
if (!mangaSync.isEmpty()) {
entry.add(MANGA_SYNC, gson.toJsonTree(mangaSync))
}
// Backup categories for this manga
val categoriesForManga = db.getCategoriesForManga(manga).executeAsBlocking()
if (!categoriesForManga.isEmpty()) {
val categoriesNames = ArrayList<String>()
for (category in categoriesForManga) {
categoriesNames.add(category.name)
}
entry.add(CATEGORIES, gson.toJsonTree(categoriesNames))
}
return entry
}
/**
* Backups a category.
*
* @param category the category to backup.
* @return a JSON object containing the data of the category.
*/
private fun backupCategory(category: Category): JsonElement {
return gson.toJsonTree(category)
}
/**
* Restores a backup from a file.
*
* @param file the file containing the backup.
* @throws IOException if there's any IO error.
*/
@Throws(IOException::class)
fun restoreFromFile(file: File) {
JsonReader(FileReader(file)).use {
val root = JsonParser().parse(it).asJsonObject
restoreFromJson(root)
}
}
/**
* Restores a backup from an input stream.
*
* @param stream the stream containing the backup.
* @throws IOException if there's any IO error.
*/
@Throws(IOException::class)
fun restoreFromStream(stream: InputStream) {
JsonReader(InputStreamReader(stream)).use {
val root = JsonParser().parse(it).asJsonObject
restoreFromJson(root)
}
}
/**
* Restores a backup from a JSON object. Everything executes in a single transaction so that
* nothing is modified if there's an error.
*
* @param root the root of the JSON.
*/
fun restoreFromJson(root: JsonObject) {
db.inTransaction {
// Restore categories
root.get(CATEGORIES)?.let {
restoreCategories(it.asJsonArray)
}
// Restore mangas
root.get(MANGAS)?.let {
restoreMangas(it.asJsonArray)
}
}
}
/**
* Restores the categories.
*
* @param jsonCategories the categories of the json.
*/
private fun restoreCategories(jsonCategories: JsonArray) {
// Get categories from file and from db
val dbCategories = db.getCategories().executeAsBlocking()
val backupCategories = getArrayOrEmpty<Category>(jsonCategories,
object : TypeToken<List<Category>>() {}.type)
// Iterate over them
for (category in backupCategories) {
// Used to know if the category is already in the db
var found = false
for (dbCategory in dbCategories) {
// If the category is already in the db, assign the id to the file's category
// and do nothing
if (category.nameLower == dbCategory.nameLower) {
category.id = dbCategory.id
found = true
break
}
}
// If the category isn't in the db, remove the id and insert a new category
// Store the inserted id in the category
if (!found) {
// Let the db assign the id
category.id = null
val result = db.insertCategory(category).executeAsBlocking()
category.id = result.insertedId()?.toInt()
}
}
}
/**
* Restores all the mangas and its related data.
*
* @param jsonMangas the mangas and its related data (chapters, sync, categories) from the json.
*/
private fun restoreMangas(jsonMangas: JsonArray) {
val chapterToken = object : TypeToken<List<Chapter>>() {}.type
val mangaSyncToken = object : TypeToken<List<MangaSync>>() {}.type
val categoriesNamesToken = object : TypeToken<List<String>>() {}.type
for (backupManga in jsonMangas) {
// Map every entry to objects
val element = backupManga.asJsonObject
val manga = gson.fromJson(element.get(MANGA), Manga::class.java)
val chapters = getArrayOrEmpty<Chapter>(element.get(CHAPTERS), chapterToken)
val sync = getArrayOrEmpty<MangaSync>(element.get(MANGA_SYNC), mangaSyncToken)
val categories = getArrayOrEmpty<String>(element.get(CATEGORIES), categoriesNamesToken)
// Restore everything related to this manga
restoreManga(manga)
restoreChaptersForManga(manga, chapters)
restoreSyncForManga(manga, sync)
restoreCategoriesForManga(manga, categories)
}
}
/**
* Restores a manga.
*
* @param manga the manga to restore.
*/
private fun restoreManga(manga: Manga) {
// Try to find existing manga in db
val dbManga = db.getManga(manga.url, manga.source).executeAsBlocking()
if (dbManga == null) {
// Let the db assign the id
manga.id = null
val result = db.insertManga(manga).executeAsBlocking()
manga.id = result.insertedId()
} else {
// If it exists already, we copy only the values related to the source from the db
// (they can be up to date). Local values (flags) are kept from the backup.
manga.id = dbManga.id
manga.copyFrom(dbManga)
manga.favorite = true
db.insertManga(manga).executeAsBlocking()
}
}
/**
* Restores the chapters of a manga.
*
* @param manga the manga whose chapters have to be restored.
* @param chapters the chapters to restore.
*/
private fun restoreChaptersForManga(manga: Manga, chapters: List<Chapter>) {
// Fix foreign keys with the current manga id
for (chapter in chapters) {
chapter.manga_id = manga.id
}
val dbChapters = db.getChapters(manga).executeAsBlocking()
val chaptersToUpdate = ArrayList<Chapter>()
for (backupChapter in chapters) {
// Try to find existing chapter in db
val pos = dbChapters.indexOf(backupChapter)
if (pos != -1) {
// The chapter is already in the db, only update its fields
val dbChapter = dbChapters[pos]
// If one of them was read, the chapter will be marked as read
dbChapter.read = backupChapter.read || dbChapter.read
dbChapter.last_page_read = Math.max(backupChapter.last_page_read, dbChapter.last_page_read)
chaptersToUpdate.add(dbChapter)
} else {
// Insert new chapter. Let the db assign the id
backupChapter.id = null
chaptersToUpdate.add(backupChapter)
}
}
// Update database
if (!chaptersToUpdate.isEmpty()) {
db.insertChapters(chaptersToUpdate).executeAsBlocking()
}
}
/**
* Restores the categories a manga is in.
*
* @param manga the manga whose categories have to be restored.
* @param categories the categories to restore.
*/
private fun restoreCategoriesForManga(manga: Manga, categories: List<String>) {
val dbCategories = db.getCategories().executeAsBlocking()
val mangaCategoriesToUpdate = ArrayList<MangaCategory>()
for (backupCategoryStr in categories) {
for (dbCategory in dbCategories) {
if (backupCategoryStr.toLowerCase() == dbCategory.nameLower) {
mangaCategoriesToUpdate.add(MangaCategory.create(manga, dbCategory))
break
}
}
}
// Update database
if (!mangaCategoriesToUpdate.isEmpty()) {
val mangaAsList = ArrayList<Manga>()
mangaAsList.add(manga)
db.deleteOldMangasCategories(mangaAsList).executeAsBlocking()
db.insertMangasCategories(mangaCategoriesToUpdate).executeAsBlocking()
}
}
/**
* Restores the sync of a manga.
*
* @param manga the manga whose sync have to be restored.
* @param sync the sync to restore.
*/
private fun restoreSyncForManga(manga: Manga, sync: List<MangaSync>) {
// Fix foreign keys with the current manga id
for (mangaSync in sync) {
mangaSync.manga_id = manga.id
}
val dbSyncs = db.getMangasSync(manga).executeAsBlocking()
val syncToUpdate = ArrayList<MangaSync>()
for (backupSync in sync) {
// Try to find existing chapter in db
val pos = dbSyncs.indexOf(backupSync)
if (pos != -1) {
// The sync is already in the db, only update its fields
val dbSync = dbSyncs[pos]
// Mark the max chapter as read and nothing else
dbSync.last_chapter_read = Math.max(backupSync.last_chapter_read, dbSync.last_chapter_read)
syncToUpdate.add(dbSync)
} else {
// Insert new sync. Let the db assign the id
backupSync.id = null
syncToUpdate.add(backupSync)
}
}
// Update database
if (!syncToUpdate.isEmpty()) {
db.insertMangasSync(syncToUpdate).executeAsBlocking()
}
}
/**
* Returns a list of items from a json element, or an empty list if the element is null.
*
* @param element the json to be mapped to a list of items.
* @param type the gson mapping to restore the list.
* @return a list of items.
*/
private fun <T> getArrayOrEmpty(element: JsonElement?, type: Type): List<T> {
return gson.fromJson<List<T>>(element, type) ?: ArrayList<T>()
}
}

View file

@ -0,0 +1,27 @@
package eu.kanade.tachiyomi.data.backup.serializer
import com.google.gson.ExclusionStrategy
import com.google.gson.FieldAttributes
import eu.kanade.tachiyomi.data.database.models.Category
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.models.MangaSync
class IdExclusion : ExclusionStrategy {
private val categoryExclusions = listOf("id")
private val mangaExclusions = listOf("id")
private val chapterExclusions = listOf("id", "manga_id")
private val syncExclusions = listOf("id", "manga_id", "update")
override fun shouldSkipField(f: FieldAttributes) = when (f.declaringClass) {
Manga::class.java -> mangaExclusions.contains(f.name)
Chapter::class.java -> chapterExclusions.contains(f.name)
MangaSync::class.java -> syncExclusions.contains(f.name)
Category::class.java -> categoryExclusions.contains(f.name)
else -> false
}
override fun shouldSkipClass(clazz: Class<*>) = false
}

View file

@ -0,0 +1,17 @@
package eu.kanade.tachiyomi.data.backup.serializer
import com.google.gson.JsonElement
import com.google.gson.JsonPrimitive
import com.google.gson.JsonSerializationContext
import com.google.gson.JsonSerializer
import java.lang.reflect.Type
class IntegerSerializer : JsonSerializer<Int> {
override fun serialize(value: Int?, type: Type, context: JsonSerializationContext): JsonElement? {
if (value != null && value !== 0)
return JsonPrimitive(value)
return null
}
}

View file

@ -256,6 +256,8 @@ open class DatabaseHelper(context: Context) {
fun insertMangaSync(manga: MangaSync) = db.put().`object`(manga).prepare()
fun insertMangasSync(mangas: List<MangaSync>) = db.put().objects(mangas).prepare()
fun deleteMangaSync(manga: MangaSync) = db.delete().`object`(manga).prepare()
// Categories related queries
@ -268,6 +270,13 @@ open class DatabaseHelper(context: Context) {
.build())
.prepare()
fun getCategoriesForManga(manga: Manga) = db.get()
.listOfObjects(Category::class.java)
.withQuery(RawQuery.builder()
.query(getCategoriesForMangaQuery(manga))
.build())
.prepare()
fun insertCategory(category: Category) = db.put().`object`(category).prepare()
fun insertCategories(categories: List<Category>) = db.put().objects(categories).prepare()

View file

@ -1,6 +1,8 @@
package eu.kanade.tachiyomi.data.database
import java.util.*
import eu.kanade.tachiyomi.data.database.models.Manga as MangaModel
import eu.kanade.tachiyomi.data.database.tables.CategoryTable as Category
import eu.kanade.tachiyomi.data.database.tables.ChapterTable as Chapter
import eu.kanade.tachiyomi.data.database.tables.MangaCategoryTable as MangaCategory
import eu.kanade.tachiyomi.data.database.tables.MangaTable as Manga
@ -38,3 +40,15 @@ fun getRecentsQuery(date: Date): String =
"ON ${Manga.TABLE}.${Manga.COLUMN_ID} = ${Chapter.TABLE}.${Chapter.COLUMN_MANGA_ID} " +
"WHERE ${Manga.COLUMN_FAVORITE} = 1 AND ${Chapter.COLUMN_DATE_UPLOAD} > ${date.time} " +
"ORDER BY ${Chapter.COLUMN_DATE_UPLOAD} DESC"
/**
* Query to get the categorias for a manga.
*
* @param manga the manga.
*/
fun getCategoriesForMangaQuery(manga: MangaModel) =
"SELECT ${Category.TABLE}.* FROM ${Category.TABLE} " +
"JOIN ${MangaCategory.TABLE} ON ${Category.TABLE}.${Category.COLUMN_ID} = " +
"${MangaCategory.TABLE}.${MangaCategory.COLUMN_CATEGORY_ID} " +
"WHERE ${MangaCategory.COLUMN_MANGA_ID} = ${manga.id}"

View file

@ -35,4 +35,23 @@ public class Category implements Serializable {
c.id = 0;
return c;
}
public String getNameLower() {
return name.toLowerCase();
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Category category = (Category) o;
return name.equals(category.name);
}
@Override
public int hashCode() {
return name.hashCode();
}
}

View file

@ -59,9 +59,9 @@ public class Manga implements Serializable {
@StorIOSQLiteColumn(name = MangaTable.COLUMN_CHAPTER_FLAGS)
public int chapter_flags;
public int unread;
public transient int unread;
public int category;
public transient int category;
public static final int UNKNOWN = 0;
public static final int ONGOING = 1;

View file

@ -40,6 +40,10 @@ public class MangaSync implements Serializable {
public boolean update;
public static MangaSync create() {
return new MangaSync();
}
public static MangaSync create(MangaSyncService service) {
MangaSync mangasync = new MangaSync();
mangasync.sync_id = service.getId();
@ -52,4 +56,23 @@ public class MangaSync implements Serializable {
status = other.status;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
MangaSync mangaSync = (MangaSync) o;
if (manga_id != mangaSync.manga_id) return false;
if (sync_id != mangaSync.sync_id) return false;
return remote_id == mangaSync.remote_id;
}
@Override
public int hashCode() {
int result = (int) (manga_id ^ (manga_id >>> 32));
result = 31 * result + sync_id;
result = 31 * result + remote_id;
return result;
}
}

View file

@ -10,6 +10,7 @@ import eu.kanade.tachiyomi.data.source.base.Source
import eu.kanade.tachiyomi.data.updater.UpdateDownloader
import eu.kanade.tachiyomi.injection.module.AppModule
import eu.kanade.tachiyomi.injection.module.DataModule
import eu.kanade.tachiyomi.ui.backup.BackupPresenter
import eu.kanade.tachiyomi.ui.catalogue.CataloguePresenter
import eu.kanade.tachiyomi.ui.category.CategoryPresenter
import eu.kanade.tachiyomi.ui.download.DownloadPresenter
@ -38,6 +39,7 @@ interface AppComponent {
fun inject(myAnimeListPresenter: MyAnimeListPresenter)
fun inject(categoryPresenter: CategoryPresenter)
fun inject(recentChaptersPresenter: RecentChaptersPresenter)
fun inject(backupPresenter: BackupPresenter)
fun inject(mangaActivity: MangaActivity)
fun inject(settingsActivity: SettingsActivity)

View file

@ -0,0 +1,133 @@
package eu.kanade.tachiyomi.ui.backup
import android.app.Activity
import android.app.Dialog
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import com.afollestad.materialdialogs.MaterialDialog
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.base.fragment.BaseRxFragment
import eu.kanade.tachiyomi.util.toast
import kotlinx.android.synthetic.main.fragment_backup.*
import nucleus.factory.RequiresPresenter
import java.io.File
import java.text.SimpleDateFormat
import java.util.*
/**
* Fragment to create and restore backups of the application's data.
* Uses R.layout.fragment_backup.
*/
@RequiresPresenter(BackupPresenter::class)
class BackupFragment : BaseRxFragment<BackupPresenter>() {
private var backupDialog: Dialog? = null
private var restoreDialog: Dialog? = null
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedState: Bundle?): View {
return inflater.inflate(R.layout.fragment_backup, container, false)
}
override fun onViewCreated(view: View?, savedState: Bundle?) {
backup_button.setOnClickListener {
val today = SimpleDateFormat("yyyy-MM-dd").format(Date())
val file = File(activity.externalCacheDir, "tachiyomi-$today.json")
presenter.createBackup(file)
backupDialog = MaterialDialog.Builder(activity)
.content(R.string.backup_please_wait)
.progress(true, 0)
.show()
}
restore_button.setOnClickListener {
val intent = Intent(Intent.ACTION_GET_CONTENT)
intent.addCategory(Intent.CATEGORY_OPENABLE)
intent.type = "application/octet-stream"
val chooser = Intent.createChooser(intent, getString(R.string.file_select_cover))
startActivityForResult(chooser, REQUEST_BACKUP_OPEN)
}
}
/**
* Called from the presenter when the backup is completed.
*/
fun onBackupCompleted() {
dismissBackupDialog()
val intent = Intent(Intent.ACTION_SEND)
intent.type = "text/plain"
intent.putExtra(Intent.EXTRA_STREAM, Uri.parse("file://" + presenter.backupFile))
startActivity(Intent.createChooser(intent, ""))
}
/**
* Called from the presenter when the restore is completed.
*/
fun onRestoreCompleted() {
dismissRestoreDialog()
context.toast(R.string.backup_completed)
}
/**
* Called from the presenter when there's an error doing the backup.
* @param error the exception thrown.
*/
fun onBackupError(error: Throwable) {
dismissBackupDialog()
context.toast(error.message)
}
/**
* Called from the presenter when there's an error restoring the backup.
* @param error the exception thrown.
*/
fun onRestoreError(error: Throwable) {
dismissRestoreDialog()
context.toast(error.message)
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (data != null && resultCode == Activity.RESULT_OK && requestCode == REQUEST_BACKUP_OPEN) {
restoreDialog = MaterialDialog.Builder(activity)
.content(R.string.restore_please_wait)
.progress(true, 0)
.show()
val stream = context.contentResolver.openInputStream(data.data)
presenter.restoreBackup(stream)
}
}
/**
* Dismisses the backup dialog.
*/
fun dismissBackupDialog() {
backupDialog?.let {
it.dismiss()
backupDialog = null
}
}
/**
* Dismisses the restore dialog.
*/
fun dismissRestoreDialog() {
restoreDialog?.let {
it.dismiss()
restoreDialog = null
}
}
companion object {
private val REQUEST_BACKUP_OPEN = 102
fun newInstance(): BackupFragment {
return BackupFragment()
}
}
}

View file

@ -0,0 +1,109 @@
package eu.kanade.tachiyomi.ui.backup
import android.os.Bundle
import eu.kanade.tachiyomi.data.backup.BackupManager
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import rx.Observable
import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers
import java.io.File
import java.io.InputStream
import javax.inject.Inject
/**
* Presenter of [BackupFragment].
*/
class BackupPresenter : BasePresenter<BackupFragment>() {
/**
* Database.
*/
@Inject lateinit var db: DatabaseHelper
/**
* Backup manager.
*/
private lateinit var backupManager: BackupManager
/**
* File where the backup is saved.
*/
var backupFile: File? = null
private set
/**
* Stream to restore a backup.
*/
private var restoreStream: InputStream? = null
/**
* Id of the restartable that creates a backup.
*/
private val CREATE_BACKUP = 1
/**
* Id of the restartable that restores a backup.
*/
private val RESTORE_BACKUP = 2
override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState)
backupManager = BackupManager(db)
startableFirst(CREATE_BACKUP,
{ getBackupObservable() },
{ view, next -> view.onBackupCompleted() },
{ view, error -> view.onBackupError(error) })
startableFirst(RESTORE_BACKUP,
{ getRestoreObservable() },
{ view, next -> view.onRestoreCompleted() },
{ view, error -> view.onRestoreError(error) })
}
/**
* Creates a backup and saves it to a file.
*
* @param file the path where the file will be saved.
*/
fun createBackup(file: File) {
if (isUnsubscribed(CREATE_BACKUP)) {
backupFile = file
start(CREATE_BACKUP)
}
}
/**
* Restores a backup from a stream.
*
* @param stream the input stream of the backup file.
*/
fun restoreBackup(stream: InputStream) {
if (isUnsubscribed(RESTORE_BACKUP)) {
restoreStream = stream
start(RESTORE_BACKUP)
}
}
/**
* Returns the observable to save a backup.
*/
private fun getBackupObservable(): Observable<Boolean> {
return Observable.fromCallable {
backupManager.backupToFile(backupFile!!)
true
}.subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread())
}
/**
* Returns the observable to restore a backup.
*/
private fun getRestoreObservable(): Observable<Boolean> {
return Observable.fromCallable {
backupManager.restoreFromStream(restoreStream!!)
true
}.subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread())
}
}

View file

@ -9,6 +9,7 @@ import android.support.v4.widget.DrawerLayout
import android.view.MenuItem
import android.view.View
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.backup.BackupFragment
import eu.kanade.tachiyomi.ui.base.activity.BaseActivity
import eu.kanade.tachiyomi.ui.catalogue.CatalogueFragment
import eu.kanade.tachiyomi.ui.download.DownloadFragment
@ -80,6 +81,10 @@ class MainActivity : BaseActivity() {
item.isChecked = false
startActivity(Intent(this, SettingsActivity::class.java))
}
R.id.nav_drawer_backup -> {
setFragment(BackupFragment.newInstance())
item.isChecked = true
}
}
drawer.closeDrawer(GravityCompat.START)
true

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:pathData="M19.35,10.04C18.67,6.59 15.64,4 12,4 9.11,4 6.6,5.64 5.35,8.04 2.34,8.36 0,10.91 0,14c0,3.31 2.69,6 6,6h13c2.76,0 5,-2.24 5,-5 0,-2.64 -2.05,-4.78 -4.65,-4.96zM14,13v4h-4v-4H7l5,-5 5,5h-3z"/>
</vector>

View file

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center">
<Button
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:id="@+id/backup_button"
android:layout_marginBottom="16dp"
android:text="@string/backup"/>
<Button
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:id="@+id/restore_button"
android:text="@string/restore"/>
</LinearLayout>

View file

@ -21,10 +21,14 @@
android:title="@string/label_download_queue" />
</group>
<group android:id="@+id/group_settings"
android:checkableBehavior="none">
android:checkableBehavior="single">
<item
android:id="@+id/nav_drawer_settings"
android:icon="@drawable/ic_settings_black_24dp"
android:title="@string/label_settings" />
<item
android:id="@+id/nav_drawer_backup"
android:icon="@drawable/ic_backup_black_24dp"
android:title="@string/label_backup" />
</group>
</menu>

View file

@ -11,6 +11,7 @@
<string name="label_catalogues">Catalogues</string>
<string name="label_categories">Categories</string>
<string name="label_selected">Selected: %1$d</string>
<string name="label_backup">Backup</string>
<!-- Actions -->
<string name="action_settings">Settings</string>
@ -243,6 +244,13 @@
<string name="decode_image_error">Image could not be loaded.\nTry changing the image decoder or with one of the options below</string>
<string name="confirm_update_manga_sync">Update last chapter read in enabled services to %1$d?</string>
<!-- Backup fragment -->
<string name="backup">Backup</string>
<string name="restore">Restore</string>
<string name="backup_please_wait">Backup in progress. Please wait…</string>
<string name="backup_completed">Backup successfully restored</string>
<string name="restore_please_wait">Restoring backup. Please wait…</string>
<!-- Downloads activity and service -->
<string name="download_queue_error">An error occurred while downloading chapters. You can try again in the downloads section</string>

View file

@ -0,0 +1,573 @@
package eu.kanade.tachiyomi;
import android.app.Application;
import android.os.Build;
import com.google.gson.Gson;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RuntimeEnvironment;
import org.robolectric.annotation.Config;
import java.util.ArrayList;
import java.util.List;
import eu.kanade.tachiyomi.data.backup.BackupManager;
import eu.kanade.tachiyomi.data.database.DatabaseHelper;
import eu.kanade.tachiyomi.data.database.models.Category;
import eu.kanade.tachiyomi.data.database.models.Chapter;
import eu.kanade.tachiyomi.data.database.models.Manga;
import eu.kanade.tachiyomi.data.database.models.MangaCategory;
import eu.kanade.tachiyomi.data.database.models.MangaSync;
import static org.assertj.core.api.Assertions.assertThat;
@Config(constants = BuildConfig.class, sdk = Build.VERSION_CODES.LOLLIPOP)
@RunWith(CustomRobolectricGradleTestRunner.class)
public class BackupTest {
DatabaseHelper db;
BackupManager backupManager;
Gson gson;
JsonObject root;
@Before
public void setup() {
Application app = RuntimeEnvironment.application;
db = new DatabaseHelper(app);
backupManager = new BackupManager(db);
gson = new Gson();
root = new JsonObject();
}
@Test
public void testRestoreCategory() {
String catName = "cat";
root = createRootJson(null, toJson(createCategories(catName)));
backupManager.restoreFromJson(root);
List<Category> dbCats = db.getCategories().executeAsBlocking();
assertThat(dbCats).hasSize(1);
assertThat(dbCats.get(0).name).isEqualTo(catName);
}
@Test
public void testRestoreEmptyCategory() {
root = createRootJson(null, toJson(new ArrayList<>()));
backupManager.restoreFromJson(root);
List<Category> dbCats = db.getCategories().executeAsBlocking();
assertThat(dbCats).isEmpty();
}
@Test
public void testRestoreExistingCategory() {
String catName = "cat";
db.insertCategory(createCategory(catName)).executeAsBlocking();
root = createRootJson(null, toJson(createCategories(catName)));
backupManager.restoreFromJson(root);
List<Category> dbCats = db.getCategories().executeAsBlocking();
assertThat(dbCats).hasSize(1);
assertThat(dbCats.get(0).name).isEqualTo(catName);
}
@Test
public void testRestoreCategories() {
root = createRootJson(null, toJson(createCategories("cat", "cat2", "cat3")));
backupManager.restoreFromJson(root);
List<Category> dbCats = db.getCategories().executeAsBlocking();
assertThat(dbCats).hasSize(3);
}
@Test
public void testRestoreExistingCategories() {
db.insertCategories(createCategories("cat", "cat2")).executeAsBlocking();
root = createRootJson(null, toJson(createCategories("cat", "cat2", "cat3")));
backupManager.restoreFromJson(root);
List<Category> dbCats = db.getCategories().executeAsBlocking();
assertThat(dbCats).hasSize(3);
}
@Test
public void testRestoreExistingCategoriesAlt() {
db.insertCategories(createCategories("cat", "cat2", "cat3")).executeAsBlocking();
root = createRootJson(null, toJson(createCategories("cat", "cat2")));
backupManager.restoreFromJson(root);
List<Category> dbCats = db.getCategories().executeAsBlocking();
assertThat(dbCats).hasSize(3);
}
@Test
public void testRestoreManga() {
String mangaName = "title";
List<Manga> mangas = createMangas(mangaName);
List<JsonElement> elements = new ArrayList<>();
for (Manga manga : mangas) {
JsonObject entry = new JsonObject();
entry.add("manga", toJson(manga));
elements.add(entry);
}
root = createRootJson(toJson(elements), null);
backupManager.restoreFromJson(root);
List<Manga> dbMangas = db.getMangas().executeAsBlocking();
assertThat(dbMangas).hasSize(1);
assertThat(dbMangas.get(0).title).isEqualTo(mangaName);
}
@Test
public void testRestoreExistingManga() {
String mangaName = "title";
Manga manga = createManga(mangaName);
db.insertManga(manga).executeAsBlocking();
List<JsonElement> elements = new ArrayList<>();
JsonObject entry = new JsonObject();
entry.add("manga", toJson(manga));
elements.add(entry);
root = createRootJson(toJson(elements), null);
backupManager.restoreFromJson(root);
List<Manga> dbMangas = db.getMangas().executeAsBlocking();
assertThat(dbMangas).hasSize(1);
}
@Test
public void testRestoreExistingMangaWithUpdatedFields() {
// Store a manga in db
String mangaName = "title";
String updatedThumbnailUrl = "updated thumbnail url";
Manga manga = createManga(mangaName);
manga.chapter_flags = 1024;
manga.thumbnail_url = updatedThumbnailUrl;
db.insertManga(manga).executeAsBlocking();
// Add an entry for a new manga with different attributes
manga = createManga(mangaName);
manga.chapter_flags = 512;
JsonObject entry = new JsonObject();
entry.add("manga", toJson(manga));
// Append the entry to the backup list
List<JsonElement> elements = new ArrayList<>();
elements.add(entry);
// Restore from json
root = createRootJson(toJson(elements), null);
backupManager.restoreFromJson(root);
List<Manga> dbMangas = db.getMangas().executeAsBlocking();
assertThat(dbMangas).hasSize(1);
assertThat(dbMangas.get(0).thumbnail_url).isEqualTo(updatedThumbnailUrl);
assertThat(dbMangas.get(0).chapter_flags).isEqualTo(512);
}
@Test
public void testRestoreChaptersForManga() {
// Create a manga and 3 chapters
Manga manga = createManga("title");
manga.id = 1L;
List<Chapter> chapters = createChapters(manga, "1", "2", "3");
// Add an entry for the manga
JsonObject entry = new JsonObject();
entry.add("manga", toJson(manga));
entry.add("chapters", toJson(chapters));
// Append the entry to the backup list
List<JsonElement> mangas = new ArrayList<>();
mangas.add(entry);
// Restore from json
root = createRootJson(toJson(mangas), null);
backupManager.restoreFromJson(root);
Manga dbManga = db.getManga(1).executeAsBlocking();
assertThat(dbManga).isNotNull();
List<Chapter> dbChapters = db.getChapters(dbManga).executeAsBlocking();
assertThat(dbChapters).hasSize(3);
}
@Test
public void testRestoreChaptersForExistingManga() {
long mangaId = 3;
// Create a manga and 3 chapters
Manga manga = createManga("title");
manga.id = mangaId;
List<Chapter> chapters = createChapters(manga, "1", "2", "3");
db.insertManga(manga).executeAsBlocking();
// Add an entry for the manga
JsonObject entry = new JsonObject();
entry.add("manga", toJson(manga));
entry.add("chapters", toJson(chapters));
// Append the entry to the backup list
List<JsonElement> mangas = new ArrayList<>();
mangas.add(entry);
// Restore from json
root = createRootJson(toJson(mangas), null);
backupManager.restoreFromJson(root);
Manga dbManga = db.getManga(mangaId).executeAsBlocking();
assertThat(dbManga).isNotNull();
List<Chapter> dbChapters = db.getChapters(dbManga).executeAsBlocking();
assertThat(dbChapters).hasSize(3);
}
@Test
public void testRestoreExistingChaptersForExistingManga() {
long mangaId = 5;
// Store a manga and 3 chapters
Manga manga = createManga("title");
manga.id = mangaId;
List<Chapter> chapters = createChapters(manga, "1", "2", "3");
db.insertManga(manga).executeAsBlocking();
db.insertChapters(chapters).executeAsBlocking();
// The backup contains a existing chapter and a new one, so it should have 4 chapters
chapters = createChapters(manga, "3", "4");
// Add an entry for the manga
JsonObject entry = new JsonObject();
entry.add("manga", toJson(manga));
entry.add("chapters", toJson(chapters));
// Append the entry to the backup list
List<JsonElement> mangas = new ArrayList<>();
mangas.add(entry);
// Restore from json
root = createRootJson(toJson(mangas), null);
backupManager.restoreFromJson(root);
Manga dbManga = db.getManga(mangaId).executeAsBlocking();
assertThat(dbManga).isNotNull();
List<Chapter> dbChapters = db.getChapters(dbManga).executeAsBlocking();
assertThat(dbChapters).hasSize(4);
}
@Test
public void testRestoreCategoriesForManga() {
// Create a manga
Manga manga = createManga("title");
// Create categories
List<Category> categories = createCategories("cat1", "cat2", "cat3");
// Add an entry for the manga
JsonObject entry = new JsonObject();
entry.add("manga", toJson(manga));
entry.add("categories", toJson(createStringCategories("cat1")));
// Append the entry to the backup list
List<JsonElement> mangas = new ArrayList<>();
mangas.add(entry);
// Restore from json
root = createRootJson(toJson(mangas), toJson(categories));
backupManager.restoreFromJson(root);
Manga dbManga = db.getManga(1).executeAsBlocking();
assertThat(dbManga).isNotNull();
assertThat(db.getCategoriesForManga(dbManga).executeAsBlocking())
.hasSize(1)
.contains(Category.create("cat1"))
.doesNotContain(Category.create("cat2"));
}
@Test
public void testRestoreCategoriesForExistingManga() {
// Store a manga
Manga manga = createManga("title");
db.insertManga(manga).executeAsBlocking();
// Create categories
List<Category> categories = createCategories("cat1", "cat2", "cat3");
// Add an entry for the manga
JsonObject entry = new JsonObject();
entry.add("manga", toJson(manga));
entry.add("categories", toJson(createStringCategories("cat1")));
// Append the entry to the backup list
List<JsonElement> mangas = new ArrayList<>();
mangas.add(entry);
// Restore from json
root = createRootJson(toJson(mangas), toJson(categories));
backupManager.restoreFromJson(root);
Manga dbManga = db.getManga(1).executeAsBlocking();
assertThat(dbManga).isNotNull();
assertThat(db.getCategoriesForManga(dbManga).executeAsBlocking())
.hasSize(1)
.contains(Category.create("cat1"))
.doesNotContain(Category.create("cat2"));
}
@Test
public void testRestoreMultipleCategoriesForManga() {
// Create a manga
Manga manga = createManga("title");
// Create categories
List<Category> categories = createCategories("cat1", "cat2", "cat3");
// Add an entry for the manga
JsonObject entry = new JsonObject();
entry.add("manga", toJson(manga));
entry.add("categories", toJson(createStringCategories("cat1", "cat3")));
// Append the entry to the backup list
List<JsonElement> mangas = new ArrayList<>();
mangas.add(entry);
// Restore from json
root = createRootJson(toJson(mangas), toJson(categories));
backupManager.restoreFromJson(root);
Manga dbManga = db.getManga(1).executeAsBlocking();
assertThat(dbManga).isNotNull();
assertThat(db.getCategoriesForManga(dbManga).executeAsBlocking())
.hasSize(2)
.contains(Category.create("cat1"), Category.create("cat3"))
.doesNotContain(Category.create("cat2"));
}
@Test
public void testRestoreMultipleCategoriesForExistingMangaAndCategory() {
// Store a manga and a category
Manga manga = createManga("title");
manga.id = 1L;
db.insertManga(manga).executeAsBlocking();
Category cat = createCategory("cat1");
cat.id = 1;
db.insertCategory(cat).executeAsBlocking();
db.insertMangaCategory(MangaCategory.create(manga, cat)).executeAsBlocking();
// Create categories
List<Category> categories = createCategories("cat1", "cat2", "cat3");
// Add an entry for the manga
JsonObject entry = new JsonObject();
entry.add("manga", toJson(manga));
entry.add("categories", toJson(createStringCategories("cat1", "cat2")));
// Append the entry to the backup list
List<JsonElement> mangas = new ArrayList<>();
mangas.add(entry);
// Restore from json
root = createRootJson(toJson(mangas), toJson(categories));
backupManager.restoreFromJson(root);
Manga dbManga = db.getManga(1).executeAsBlocking();
assertThat(dbManga).isNotNull();
assertThat(db.getCategoriesForManga(dbManga).executeAsBlocking())
.hasSize(2)
.contains(Category.create("cat1"), Category.create("cat2"))
.doesNotContain(Category.create("cat3"));
}
@Test
public void testRestoreSyncForManga() {
// Create a manga and mangaSync
Manga manga = createManga("title");
manga.id = 1L;
List<MangaSync> mangaSync = createMangaSync(manga, 1, 2, 3);
// Add an entry for the manga
JsonObject entry = new JsonObject();
entry.add("manga", toJson(manga));
entry.add("sync", toJson(mangaSync));
// Append the entry to the backup list
List<JsonElement> mangas = new ArrayList<>();
mangas.add(entry);
// Restore from json
root = createRootJson(toJson(mangas), null);
backupManager.restoreFromJson(root);
Manga dbManga = db.getManga(1).executeAsBlocking();
assertThat(dbManga).isNotNull();
List<MangaSync> dbSync = db.getMangasSync(dbManga).executeAsBlocking();
assertThat(dbSync).hasSize(3);
}
@Test
public void testRestoreSyncForExistingManga() {
long mangaId = 3;
// Create a manga and 3 sync
Manga manga = createManga("title");
manga.id = mangaId;
List<MangaSync> mangaSync = createMangaSync(manga, 1, 2, 3);
db.insertManga(manga).executeAsBlocking();
// Add an entry for the manga
JsonObject entry = new JsonObject();
entry.add("manga", toJson(manga));
entry.add("sync", toJson(mangaSync));
// Append the entry to the backup list
List<JsonElement> mangas = new ArrayList<>();
mangas.add(entry);
// Restore from json
root = createRootJson(toJson(mangas), null);
backupManager.restoreFromJson(root);
Manga dbManga = db.getManga(mangaId).executeAsBlocking();
assertThat(dbManga).isNotNull();
List<MangaSync> dbSync = db.getMangasSync(dbManga).executeAsBlocking();
assertThat(dbSync).hasSize(3);
}
@Test
public void testRestoreExistingSyncForExistingManga() {
long mangaId = 5;
// Store a manga and 3 sync
Manga manga = createManga("title");
manga.id = mangaId;
List<MangaSync> mangaSync = createMangaSync(manga, 1, 2, 3);
db.insertManga(manga).executeAsBlocking();
db.insertMangasSync(mangaSync).executeAsBlocking();
// The backup contains a existing sync and a new one, so it should have 4 sync
mangaSync = createMangaSync(manga, 3, 4);
// Add an entry for the manga
JsonObject entry = new JsonObject();
entry.add("manga", toJson(manga));
entry.add("sync", toJson(mangaSync));
// Append the entry to the backup list
List<JsonElement> mangas = new ArrayList<>();
mangas.add(entry);
// Restore from json
root = createRootJson(toJson(mangas), null);
backupManager.restoreFromJson(root);
Manga dbManga = db.getManga(mangaId).executeAsBlocking();
assertThat(dbManga).isNotNull();
List<MangaSync> dbSync = db.getMangasSync(dbManga).executeAsBlocking();
assertThat(dbSync).hasSize(4);
}
private JsonObject createRootJson(JsonElement mangas, JsonElement categories) {
JsonObject root = new JsonObject();
if (mangas != null)
root.add("mangas", mangas);
if (categories != null)
root.add("categories", categories);
return root;
}
private Category createCategory(String name) {
Category c = new Category();
c.name = name;
return c;
}
private List<Category> createCategories(String... names) {
List<Category> cats = new ArrayList<>();
for (String name : names) {
cats.add(createCategory(name));
}
return cats;
}
private List<String> createStringCategories(String... names) {
List<String> cats = new ArrayList<>();
for (String name : names) {
cats.add(name);
}
return cats;
}
private Manga createManga(String title) {
Manga m = new Manga();
m.title = title;
m.author = "";
m.artist = "";
m.thumbnail_url = "";
m.genre = "a list of genres";
m.description = "long description";
m.url = "url to manga";
m.favorite = true;
m.source = 1;
return m;
}
private List<Manga> createMangas(String... titles) {
List<Manga> mangas = new ArrayList<>();
for (String title : titles) {
mangas.add(createManga(title));
}
return mangas;
}
private Chapter createChapter(Manga manga, String url) {
Chapter c = Chapter.create();
c.url = url;
c.name = url;
c.manga_id = manga.id;
return c;
}
private List<Chapter> createChapters(Manga manga, String... urls) {
List<Chapter> chapters = new ArrayList<>();
for (String url : urls) {
chapters.add(createChapter(manga, url));
}
return chapters;
}
private MangaSync createMangaSync(Manga manga, int syncId) {
MangaSync m = MangaSync.create();
m.manga_id = manga.id;
m.sync_id = syncId;
m.title = "title";
return m;
}
private List<MangaSync> createMangaSync(Manga manga, Integer... syncIds) {
List<MangaSync> ms = new ArrayList<>();
for (int title : syncIds) {
ms.add(createMangaSync(manga, title));
}
return ms;
}
private JsonElement toJson(Object element) {
return gson.toJsonTree(element);
}
}