Rewrote Backup (#650)

* Rewrote Backup

* Save automatic backups with datetime

* Minor improvements

* Remove suggested directories for backup and hardcoded strings. Rename JSON -> Backup

* Bugfix

* Fix tests

* Run restore inside a transaction, use external cache dir for log and other minor changes
This commit is contained in:
Bram van de Kerkhof
2017-04-04 17:42:17 +02:00
committed by inorichi
parent 3094d084d6
commit 0642889b64
39 changed files with 2166 additions and 1149 deletions
@@ -0,0 +1,166 @@
package eu.kanade.tachiyomi.data.backup
import android.app.IntentService
import android.content.Context
import android.content.Intent
import android.net.Uri
import com.github.salomonbrys.kotson.set
import com.google.gson.JsonArray
import com.google.gson.JsonObject
import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.data.backup.models.Backup
import eu.kanade.tachiyomi.data.backup.models.Backup.CATEGORIES
import eu.kanade.tachiyomi.data.backup.models.Backup.MANGAS
import eu.kanade.tachiyomi.data.backup.models.Backup.VERSION
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.ui.setting.SettingsBackupFragment
import eu.kanade.tachiyomi.util.sendLocalBroadcast
import timber.log.Timber
import eu.kanade.tachiyomi.BuildConfig.APPLICATION_ID as ID
/**
* [IntentService] used to backup [Manga] information to [JsonArray]
*/
class BackupCreateService : IntentService(NAME) {
companion object {
// Name of class
private const val NAME = "BackupCreateService"
// Uri as string
private const val EXTRA_URI = "$ID.$NAME.EXTRA_URI"
// Backup called from job
private const val EXTRA_IS_JOB = "$ID.$NAME.EXTRA_IS_JOB"
// Options for backup
private const val EXTRA_FLAGS = "$ID.$NAME.EXTRA_FLAGS"
// Filter options
internal const val BACKUP_CATEGORY = 0x1
internal const val BACKUP_CATEGORY_MASK = 0x1
internal const val BACKUP_CHAPTER = 0x2
internal const val BACKUP_CHAPTER_MASK = 0x2
internal const val BACKUP_HISTORY = 0x4
internal const val BACKUP_HISTORY_MASK = 0x4
internal const val BACKUP_TRACK = 0x8
internal const val BACKUP_TRACK_MASK = 0x8
internal const val BACKUP_ALL = 0xF
/**
* Make a backup from library
*
* @param context context of application
* @param path path of Uri
* @param flags determines what to backup
* @param isJob backup called from job
*/
fun makeBackup(context: Context, path: String, flags: Int, isJob: Boolean = false) {
val intent = Intent(context, BackupCreateService::class.java).apply {
putExtra(EXTRA_URI, path)
putExtra(EXTRA_IS_JOB, isJob)
putExtra(EXTRA_FLAGS, flags)
}
context.startService(intent)
}
}
private val backupManager by lazy { BackupManager(this) }
override fun onHandleIntent(intent: Intent?) {
if (intent == null) return
// Get values
val uri = intent.getStringExtra(EXTRA_URI)
val isJob = intent.getBooleanExtra(EXTRA_IS_JOB, false)
val flags = intent.getIntExtra(EXTRA_FLAGS, 0)
// Create backup
createBackupFromApp(Uri.parse(uri), flags, isJob)
}
/**
* Create backup Json file from database
*
* @param uri path of Uri
* @param isJob backup called from job
*/
fun createBackupFromApp(uri: Uri, flags: Int, isJob: Boolean) {
// Create root object
val root = JsonObject()
// Create information object
val information = JsonObject()
// Create manga array
val mangaEntries = JsonArray()
// Create category array
val categoryEntries = JsonArray()
// Add value's to root
root[VERSION] = Backup.CURRENT_VERSION
root[MANGAS] = mangaEntries
root[CATEGORIES] = categoryEntries
backupManager.databaseHelper.inTransaction {
// Get manga from database
val mangas = backupManager.getFavoriteManga()
// Backup library manga and its dependencies
mangas.forEach { manga ->
mangaEntries.add(backupManager.backupMangaObject(manga, flags))
}
// Backup categories
if ((flags and BACKUP_CATEGORY_MASK) == BACKUP_CATEGORY) {
backupManager.backupCategories(categoryEntries)
}
}
try {
// When BackupCreatorJob
if (isJob) {
// Get dir of file
val dir = UniFile.fromUri(this, uri)
// Delete older backups
val numberOfBackups = backupManager.numberOfBackups()
val backupRegex = Regex("""tachiyomi_\d+-\d+-\d+_\d+-\d+.json""")
dir.listFiles { _, filename -> backupRegex.matches(filename) }
.orEmpty()
.sortedByDescending { it.name }
.drop(numberOfBackups - 1)
.forEach { it.delete() }
// Create new file to place backup
val newFile = dir.createFile(Backup.getDefaultFilename())
?: throw Exception("Couldn't create backup file")
newFile.openOutputStream().bufferedWriter().use {
backupManager.parser.toJson(root, it)
}
} else {
val file = UniFile.fromUri(this, uri)
?: throw Exception("Couldn't create backup file")
file.openOutputStream().bufferedWriter().use {
backupManager.parser.toJson(root, it)
}
// Show completed dialog
val intent = Intent(SettingsBackupFragment.INTENT_FILTER).apply {
putExtra(SettingsBackupFragment.ACTION, SettingsBackupFragment.ACTION_BACKUP_COMPLETED_DIALOG)
putExtra(SettingsBackupFragment.EXTRA_URI, file.uri.toString())
}
sendLocalBroadcast(intent)
}
} catch (e: Exception) {
Timber.e(e)
if (!isJob) {
// Show error dialog
val intent = Intent(SettingsBackupFragment.INTENT_FILTER).apply {
putExtra(SettingsBackupFragment.ACTION, SettingsBackupFragment.ACTION_ERROR_BACKUP_DIALOG)
putExtra(SettingsBackupFragment.EXTRA_ERROR_MESSAGE, e.message)
}
sendLocalBroadcast(intent)
}
}
}
}
@@ -0,0 +1,41 @@
package eu.kanade.tachiyomi.data.backup
import com.evernote.android.job.Job
import com.evernote.android.job.JobManager
import com.evernote.android.job.JobRequest
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class BackupCreatorJob : Job() {
override fun onRunJob(params: Params): Result {
val preferences = Injekt.get<PreferencesHelper>()
val path = preferences.backupsDirectory().getOrDefault()
val flags = BackupCreateService.BACKUP_ALL
BackupCreateService.makeBackup(context,path,flags,true)
return Result.SUCCESS
}
companion object {
const val TAG = "BackupCreator"
fun setupTask(prefInterval: Int? = null) {
val preferences = Injekt.get<PreferencesHelper>()
val interval = prefInterval ?: preferences.backupInterval().getOrDefault()
if (interval > 0) {
JobRequest.Builder(TAG)
.setPeriodic(interval * 60 * 60 * 1000L, 10 * 60 * 1000)
.setPersisted(true)
.setUpdateCurrent(true)
.build()
.schedule()
}
}
fun cancelTask() {
JobManager.instance().cancelAllForTag(TAG)
}
}
}
@@ -1,203 +1,213 @@
package eu.kanade.tachiyomi.data.backup
import com.github.salomonbrys.kotson.fromJson
import android.content.Context
import com.github.salomonbrys.kotson.*
import com.google.gson.*
import com.google.gson.stream.JsonReader
import eu.kanade.tachiyomi.data.backup.serializer.BooleanSerializer
import eu.kanade.tachiyomi.data.backup.serializer.IdExclusion
import eu.kanade.tachiyomi.data.backup.serializer.IntegerSerializer
import eu.kanade.tachiyomi.data.backup.serializer.LongSerializer
import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_CATEGORY
import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_CATEGORY_MASK
import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_CHAPTER
import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_CHAPTER_MASK
import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_HISTORY
import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_HISTORY_MASK
import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_TRACK
import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_TRACK_MASK
import eu.kanade.tachiyomi.data.backup.models.Backup.CATEGORIES
import eu.kanade.tachiyomi.data.backup.models.Backup.CHAPTERS
import eu.kanade.tachiyomi.data.backup.models.Backup.CURRENT_VERSION
import eu.kanade.tachiyomi.data.backup.models.Backup.HISTORY
import eu.kanade.tachiyomi.data.backup.models.Backup.MANGA
import eu.kanade.tachiyomi.data.backup.models.Backup.TRACK
import eu.kanade.tachiyomi.data.backup.models.DHistory
import eu.kanade.tachiyomi.data.backup.serializer.*
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.*
import java.io.*
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.util.syncChaptersWithSource
import rx.Observable
import uy.kohesive.injekt.injectLazy
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 TRACK = "sync"
private val CATEGORIES = "categories"
@Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN")
private val gson = GsonBuilder()
.registerTypeAdapter(java.lang.Integer::class.java, IntegerSerializer())
.registerTypeAdapter(java.lang.Boolean::class.java, BooleanSerializer())
.registerTypeAdapter(java.lang.Long::class.java, LongSerializer())
.setExclusionStrategies(IdExclusion())
.create()
class BackupManager(val context: Context, version: Int = CURRENT_VERSION) {
/**
* 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.
* Database.
*/
@Throws(IOException::class)
fun backupToFile(file: File) {
val root = backupToJson()
internal val databaseHelper: DatabaseHelper by injectLazy()
FileWriter(file).use {
gson.toJson(root, it)
/**
* Source manager.
*/
internal val sourceManager: SourceManager by injectLazy()
/**
* Version of parser
*/
var version: Int = version
private set
/**
* Json Parser
*/
var parser: Gson = initParser()
/**
* Preferences
*/
private val preferences: PreferencesHelper by injectLazy()
/**
* Set version of parser
*
* @param version version of parser
*/
internal fun setVersion(version: Int) {
this.version = version
parser = initParser()
}
private fun initParser(): Gson {
return when (version) {
1 -> GsonBuilder().create()
2 -> GsonBuilder()
.registerTypeAdapter<MangaImpl>(MangaTypeAdapter.build())
.registerTypeHierarchyAdapter<ChapterImpl>(ChapterTypeAdapter.build())
.registerTypeAdapter<CategoryImpl>(CategoryTypeAdapter.build())
.registerTypeAdapter<DHistory>(HistoryTypeAdapter.build())
.registerTypeHierarchyAdapter<TrackImpl>(TrackTypeAdapter.build())
.create()
else -> throw Exception("Json version unknown")
}
}
/**
* Creates a JSON object containing the backup of the app's data.
* Backup the categories of library
*
* @return the backup as a JSON object.
* @param root root of categories json
*/
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
internal fun backupCategories(root: JsonArray) {
val categories = databaseHelper.getCategories().executeAsBlocking()
categories.forEach { root.add(parser.toJsonTree(it)) }
}
/**
* Backups a manga and its related data (chapters, categories this manga is in, sync...).
* Convert a manga to Json
*
* @param manga the manga to backup.
* @return a JSON object containing all the data of the manga.
* @param manga manga that gets converted
* @return [JsonElement] containing manga information
*/
private fun backupManga(manga: Manga): JsonObject {
internal fun backupMangaObject(manga: Manga, options: Int): JsonElement {
// Entry for this manga
val entry = JsonObject()
// Backup manga fields
entry.add(MANGA, gson.toJsonTree(manga))
entry[MANGA] = parser.toJsonTree(manga)
// Backup all the chapters
val chapters = db.getChapters(manga).executeAsBlocking()
if (!chapters.isEmpty()) {
entry.add(CHAPTERS, gson.toJsonTree(chapters))
}
// Backup tracks
val tracks = db.getTracks(manga).executeAsBlocking()
if (!tracks.isEmpty()) {
entry.add(TRACK, gson.toJsonTree(tracks))
}
// 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)
// Check if user wants chapter information in backup
if (options and BACKUP_CHAPTER_MASK == BACKUP_CHAPTER) {
// Backup all the chapters
val chapters = databaseHelper.getChapters(manga).executeAsBlocking()
if (!chapters.isEmpty()) {
val chaptersJson = parser.toJsonTree(chapters)
if (chaptersJson.asJsonArray.size() > 0) {
entry[CHAPTERS] = chaptersJson
}
}
}
// Check if user wants category information in backup
if (options and BACKUP_CATEGORY_MASK == BACKUP_CATEGORY) {
// Backup categories for this manga
val categoriesForManga = databaseHelper.getCategoriesForManga(manga).executeAsBlocking()
if (!categoriesForManga.isEmpty()) {
val categoriesNames = categoriesForManga.map { it.name }
entry[CATEGORIES] = parser.toJsonTree(categoriesNames)
}
}
// Check if user wants track information in backup
if (options and BACKUP_TRACK_MASK == BACKUP_TRACK) {
val tracks = databaseHelper.getTracks(manga).executeAsBlocking()
if (!tracks.isEmpty()) {
entry[TRACK] = parser.toJsonTree(tracks)
}
}
// Check if user wants history information in backup
if (options and BACKUP_HISTORY_MASK == BACKUP_HISTORY) {
val historyForManga = databaseHelper.getHistoryByMangaId(manga.id!!).executeAsBlocking()
if (!historyForManga.isEmpty()) {
val historyData = historyForManga.mapNotNull { history ->
val url = databaseHelper.getChapter(history.chapter_id).executeAsBlocking()?.url
url?.let { DHistory(url, history.last_read) }
}
val historyJson = parser.toJsonTree(historyData)
if (historyJson.asJsonArray.size() > 0) {
entry[HISTORY] = historyJson
}
}
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)
fun restoreMangaNoFetch(manga: Manga, dbManga: Manga) {
manga.id = dbManga.id
manga.copyFrom(dbManga)
manga.favorite = true
insertManga(manga)
}
/**
* Restores a backup from a file.
* [Observable] that fetches manga information
*
* @param file the file containing the backup.
* @throws IOException if there's any IO error.
* @param source source of manga
* @param manga manga that needs updating
* @return [Observable] that contains manga
*/
@Throws(IOException::class)
fun restoreFromFile(file: File) {
JsonReader(FileReader(file)).use {
val root = JsonParser().parse(it).asJsonObject
restoreFromJson(root)
}
fun restoreMangaFetchObservable(source: Source, manga: Manga): Observable<Manga> {
return source.fetchMangaDetails(manga)
.map { networkManga ->
manga.copyFrom(networkManga)
manga.favorite = true
manga.initialized = true
manga.id = insertManga(manga)
manga
}
}
/**
* Restores a backup from an input stream.
* [Observable] that fetches chapter information
*
* @param stream the stream containing the backup.
* @throws IOException if there's any IO error.
* @param source source of manga
* @param manga manga that needs updating
* @return [Observable] that contains manga
*/
@Throws(IOException::class)
fun restoreFromStream(stream: InputStream) {
JsonReader(InputStreamReader(stream)).use {
val root = JsonParser().parse(it).asJsonObject
restoreFromJson(root)
}
fun restoreChapterFetchObservable(source: Source, manga: Manga, chapters: List<Chapter>): Observable<Pair<List<Chapter>, List<Chapter>>> {
return source.fetchChapterList(manga)
.map { syncChaptersWithSource(databaseHelper, it, manga, source) }
.doOnNext {
if (it.first.isNotEmpty()) {
chapters.forEach { it.manga_id = manga.id }
insertChapters(chapters)
}
}
}
/**
* Restores a backup from a JSON object. Everything executes in a single transaction so that
* nothing is modified if there's an error.
* Restore the categories from Json
*
* @param root the root of the JSON.
* @param jsonCategories array containing categories
*/
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) {
internal fun restoreCategories(jsonCategories: JsonArray) {
// Get categories from file and from db
val dbCategories = db.getCategories().executeAsBlocking()
val backupCategories = gson.fromJson<List<CategoryImpl>>(jsonCategories)
val dbCategories = databaseHelper.getCategories().executeAsBlocking()
val backupCategories = parser.fromJson<List<CategoryImpl>>(jsonCategories)
// Iterate over them
for (category in backupCategories) {
backupCategories.forEach { category ->
// Used to know if the category is already in the db
var found = false
for (dbCategory in dbCategories) {
@@ -214,102 +224,20 @@ class BackupManager(private val db: DatabaseHelper) {
if (!found) {
// Let the db assign the id
category.id = null
val result = db.insertCategory(category).executeAsBlocking()
val result = databaseHelper.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) {
for (backupManga in jsonMangas) {
// Map every entry to objects
val element = backupManga.asJsonObject
val manga = gson.fromJson(element.get(MANGA), MangaImpl::class.java)
val chapters = gson.fromJson<List<ChapterImpl>>(element.get(CHAPTERS) ?: JsonArray())
val tracks = gson.fromJson<List<TrackImpl>>(element.get(TRACK) ?: JsonArray())
val categories = gson.fromJson<List<String>>(element.get(CATEGORIES) ?: JsonArray())
// Restore everything related to this manga
restoreManga(manga)
restoreChaptersForManga(manga, chapters)
restoreSyncForManga(manga, tracks)
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()
internal fun restoreCategoriesForManga(manga: Manga, categories: List<String>) {
val dbCategories = databaseHelper.getCategories().executeAsBlocking()
val mangaCategoriesToUpdate = ArrayList<MangaCategory>()
for (backupCategoryStr in categories) {
for (dbCategory in dbCategories) {
@@ -324,45 +252,151 @@ class BackupManager(private val db: DatabaseHelper) {
if (!mangaCategoriesToUpdate.isEmpty()) {
val mangaAsList = ArrayList<Manga>()
mangaAsList.add(manga)
db.deleteOldMangasCategories(mangaAsList).executeAsBlocking()
db.insertMangasCategories(mangaCategoriesToUpdate).executeAsBlocking()
databaseHelper.deleteOldMangasCategories(mangaAsList).executeAsBlocking()
databaseHelper.insertMangasCategories(mangaCategoriesToUpdate).executeAsBlocking()
}
}
/**
* Restore history from Json
*
* @param history list containing history to be restored
*/
internal fun restoreHistoryForManga(history: List<DHistory>) {
// List containing history to be updated
val historyToBeUpdated = ArrayList<History>()
for ((url, lastRead) in history) {
val dbHistory = databaseHelper.getHistoryByChapterUrl(url).executeAsBlocking()
// Check if history already in database and update
if (dbHistory != null) {
dbHistory.apply {
last_read = Math.max(lastRead, dbHistory.last_read)
}
historyToBeUpdated.add(dbHistory)
} else {
// If not in database create
databaseHelper.getChapter(url).executeAsBlocking()?.let {
val historyToAdd = History.create(it).apply {
last_read = lastRead
}
historyToBeUpdated.add(historyToAdd)
}
}
}
databaseHelper.updateHistoryLastRead(historyToBeUpdated).executeAsBlocking()
}
/**
* Restores the sync of a manga.
*
* @param manga the manga whose sync have to be restored.
* @param tracks the track list to restore.
*/
private fun restoreSyncForManga(manga: Manga, tracks: List<Track>) {
internal fun restoreTrackForManga(manga: Manga, tracks: List<Track>) {
// Fix foreign keys with the current manga id
for (track in tracks) {
track.manga_id = manga.id!!
}
tracks.map { it.manga_id = manga.id!! }
val dbTracks = db.getTracks(manga).executeAsBlocking()
// Get tracks from database
val dbTracks = databaseHelper.getTracks(manga).executeAsBlocking()
val trackToUpdate = ArrayList<Track>()
for (backupTrack in tracks) {
// Try to find existing chapter in db
val pos = dbTracks.indexOf(backupTrack)
if (pos != -1) {
// The sync is already in the db, only update its fields
val dbSync = dbTracks[pos]
// Mark the max chapter as read and nothing else
dbSync.last_chapter_read = Math.max(backupTrack.last_chapter_read, dbSync.last_chapter_read)
trackToUpdate.add(dbSync)
} else {
for (track in tracks) {
var isInDatabase = false
for (dbTrack in dbTracks) {
if (track.sync_id == dbTrack.sync_id) {
// The sync is already in the db, only update its fields
if (track.remote_id != dbTrack.remote_id) {
dbTrack.remote_id = track.remote_id
}
dbTrack.last_chapter_read = Math.max(dbTrack.last_chapter_read, track.last_chapter_read)
isInDatabase = true
trackToUpdate.add(dbTrack)
break
}
}
if (!isInDatabase) {
// Insert new sync. Let the db assign the id
backupTrack.id = null
trackToUpdate.add(backupTrack)
track.id = null
trackToUpdate.add(track)
}
}
// Update database
if (!trackToUpdate.isEmpty()) {
db.insertTracks(trackToUpdate).executeAsBlocking()
databaseHelper.insertTracks(trackToUpdate).executeAsBlocking()
}
}
/**
* Restore the chapters for manga if chapters already in database
*
* @param manga manga of chapters
* @param chapters list containing chapters that get restored
* @return boolean answering if chapter fetch is not needed
*/
internal fun restoreChaptersForManga(manga: Manga, chapters: List<Chapter>): Boolean {
val dbChapters = databaseHelper.getChapters(manga).executeAsBlocking()
// Return if fetch is needed
if (dbChapters.isEmpty() || dbChapters.size < chapters.size)
return false
for (chapter in chapters) {
val pos = dbChapters.indexOf(chapter)
if (pos != -1) {
val dbChapter = dbChapters[pos]
chapter.id = dbChapter.id
chapter.copyFrom(dbChapter)
break
}
}
// Filter the chapters that couldn't be found.
chapters.filter { it.id != null }
chapters.map { it.manga_id = manga.id }
insertChapters(chapters)
return true
}
/**
* Returns manga
*
* @return [Manga], null if not found
*/
internal fun getMangaFromDatabase(manga: Manga): Manga? {
return databaseHelper.getManga(manga.url, manga.source).executeAsBlocking()
}
/**
* Returns list containing manga from library
*
* @return [Manga] from library
*/
internal fun getFavoriteManga(): List<Manga> {
return databaseHelper.getFavoriteMangas().executeAsBlocking()
}
/**
* Inserts manga and returns id
*
* @return id of [Manga], null if not found
*/
internal fun insertManga(manga: Manga): Long? {
return databaseHelper.insertManga(manga).executeAsBlocking().insertedId()
}
/**
* Inserts list of chapters
*/
internal fun insertChapters(chapters: List<Chapter>) {
databaseHelper.updateChaptersBackup(chapters).executeAsBlocking()
}
/**
* Return number of backups.
*
* @return number of backups selected by user
*/
fun numberOfBackups(): Int {
return preferences.numberOfBackups().getOrDefault()
}
}
@@ -0,0 +1,413 @@
package eu.kanade.tachiyomi.data.backup
import android.app.Service
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.IBinder
import android.os.PowerManager
import com.github.salomonbrys.kotson.fromJson
import com.google.gson.JsonArray
import com.google.gson.JsonParser
import com.google.gson.stream.JsonReader
import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.backup.models.Backup.CATEGORIES
import eu.kanade.tachiyomi.data.backup.models.Backup.CHAPTERS
import eu.kanade.tachiyomi.data.backup.models.Backup.HISTORY
import eu.kanade.tachiyomi.data.backup.models.Backup.MANGA
import eu.kanade.tachiyomi.data.backup.models.Backup.MANGAS
import eu.kanade.tachiyomi.data.backup.models.Backup.TRACK
import eu.kanade.tachiyomi.data.backup.models.Backup.VERSION
import eu.kanade.tachiyomi.data.backup.models.DHistory
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.*
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.ui.setting.SettingsBackupFragment
import eu.kanade.tachiyomi.util.AndroidComponentUtil
import eu.kanade.tachiyomi.util.chop
import eu.kanade.tachiyomi.util.sendLocalBroadcast
import rx.Observable
import rx.Subscription
import rx.schedulers.Schedulers
import timber.log.Timber
import uy.kohesive.injekt.injectLazy
import java.io.File
import java.text.SimpleDateFormat
import java.util.*
import eu.kanade.tachiyomi.BuildConfig.APPLICATION_ID as ID
/**
* Restores backup from json file
*/
class BackupRestoreService : Service() {
companion object {
// Name of service
private const val NAME = "BackupRestoreService"
// Uri as string
private const val EXTRA_URI = "$ID.$NAME.EXTRA_URI"
/**
* Returns the status of the service.
*
* @param context the application context.
* @return true if the service is running, false otherwise.
*/
fun isRunning(context: Context): Boolean {
return AndroidComponentUtil.isServiceRunning(context, BackupRestoreService::class.java)
}
/**
* Starts a service to restore a backup from Json
*
* @param context context of application
* @param uri path of Uri
*/
fun start(context: Context, uri: String) {
if (!isRunning(context)) {
val intent = Intent(context, BackupRestoreService::class.java).apply {
putExtra(EXTRA_URI, uri)
}
context.startService(intent)
}
}
/**
* Stops the service.
*
* @param context the application context.
*/
fun stop(context: Context) {
context.stopService(Intent(context, BackupRestoreService::class.java))
}
}
/**
* Wake lock that will be held until the service is destroyed.
*/
private lateinit var wakeLock: PowerManager.WakeLock
/**
* Subscription where the update is done.
*/
private var subscription: Subscription? = null
/**
* The progress of a backup restore
*/
private var restoreProgress = 0
/**
* Amount of manga in Json file (needed for restore)
*/
private var restoreAmount = 0
/**
* List containing errors
*/
private val errors = mutableListOf<Pair<Date, String>>()
/**
* Backup manager
*/
private lateinit var backupManager: BackupManager
/**
* Database
*/
private val db: DatabaseHelper by injectLazy()
/**
* Method called when the service is created. It injects dependencies and acquire the wake lock.
*/
override fun onCreate() {
super.onCreate()
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK, "BackupRestoreService:WakeLock")
wakeLock.acquire()
}
/**
* Method called when the service is destroyed. It destroys the running subscription and
* releases the wake lock.
*/
override fun onDestroy() {
subscription?.unsubscribe()
if (wakeLock.isHeld) {
wakeLock.release()
}
super.onDestroy()
}
/**
* This method needs to be implemented, but it's not used/needed.
*/
override fun onBind(intent: Intent): IBinder? {
return null
}
/**
* Method called when the service receives an intent.
*
* @param intent the start intent from.
* @param flags the flags of the command.
* @param startId the start id of this command.
* @return the start value of the command.
*/
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent == null) return Service.START_NOT_STICKY
// Unsubscribe from any previous subscription if needed.
subscription?.unsubscribe()
val startTime = System.currentTimeMillis()
subscription = Observable.defer {
// Get URI
val uri = Uri.parse(intent.getStringExtra(EXTRA_URI))
// Get file from Uri
val file = UniFile.fromUri(this, uri)
// Clear errors
errors.clear()
// Reset progress
restoreProgress = 0
db.lowLevel().beginTransaction()
getRestoreObservable(file)
}
.subscribeOn(Schedulers.io())
.subscribe({
}, { error ->
db.lowLevel().endTransaction()
Timber.e(error)
writeErrorLog()
val errorIntent = Intent(SettingsBackupFragment.INTENT_FILTER).apply {
putExtra(SettingsBackupFragment.ACTION, SettingsBackupFragment.ACTION_ERROR_RESTORE_DIALOG)
putExtra(SettingsBackupFragment.EXTRA_ERROR_MESSAGE, error.message)
}
sendLocalBroadcast(errorIntent)
stopSelf(startId)
}, {
db.lowLevel().setTransactionSuccessful()
db.lowLevel().endTransaction()
val endTime = System.currentTimeMillis()
val time = endTime - startTime
val file = writeErrorLog()
val completeIntent = Intent(SettingsBackupFragment.INTENT_FILTER).apply {
putExtra(SettingsBackupFragment.EXTRA_TIME, time)
putExtra(SettingsBackupFragment.EXTRA_ERRORS, errors.size)
putExtra(SettingsBackupFragment.EXTRA_ERROR_FILE_PATH, file.parent)
putExtra(SettingsBackupFragment.EXTRA_ERROR_FILE, file.name)
putExtra(SettingsBackupFragment.ACTION, SettingsBackupFragment.ACTION_RESTORE_COMPLETED_DIALOG)
}
sendLocalBroadcast(completeIntent)
stopSelf(startId)
})
return Service.START_NOT_STICKY
}
/**
* Returns an [Observable] containing restore process.
*
* @param file restore file
* @return [Observable<Manga>]
*/
private fun getRestoreObservable(file: UniFile): Observable<Manga> {
val reader = JsonReader(file.openInputStream().bufferedReader())
val json = JsonParser().parse(reader).asJsonObject
// Get parser version
val version = json.get(VERSION)?.asInt ?: 1
// Initialize manager
backupManager = BackupManager(this, version)
val mangasJson = json.get(MANGAS).asJsonArray
restoreAmount = mangasJson.size() + 1 // +1 for categories
// Restore categories
json.get(CATEGORIES)?.let {
backupManager.restoreCategories(it.asJsonArray)
restoreProgress += 1
showRestoreProgress(restoreProgress, restoreAmount, "Categories added", errors.size)
}
return Observable.from(mangasJson)
.concatMap {
val obj = it.asJsonObject
val manga = backupManager.parser.fromJson<MangaImpl>(obj.get(MANGA))
val chapters = backupManager.parser.fromJson<List<ChapterImpl>>(obj.get(CHAPTERS) ?: JsonArray())
val categories = backupManager.parser.fromJson<List<String>>(obj.get(CATEGORIES) ?: JsonArray())
val history = backupManager.parser.fromJson<List<DHistory>>(obj.get(HISTORY) ?: JsonArray())
val tracks = backupManager.parser.fromJson<List<TrackImpl>>(obj.get(TRACK) ?: JsonArray())
val observable = getMangaRestoreObservable(manga, chapters, categories, history, tracks)
if (observable != null) {
observable
} else {
errors.add(Date() to "${manga.title} - ${getString(R.string.source_not_found)}")
restoreProgress += 1
val content = getString(R.string.dialog_restoring_source_not_found, manga.title.chop(15))
showRestoreProgress(restoreProgress, restoreAmount, manga.title, errors.size, content)
Observable.just(manga)
}
}
}
/**
* Write errors to error log
*/
private fun writeErrorLog(): File {
try {
if (errors.isNotEmpty()) {
val destFile = File(externalCacheDir, "tachiyomi_restore.log")
val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.getDefault())
destFile.bufferedWriter().use { out ->
errors.forEach { (date, message) ->
out.write("[${sdf.format(date)}] $message\n")
}
}
return destFile
}
} catch (e: Exception) {
// Empty
}
return File("")
}
/**
* Returns a manga restore observable
*
* @param manga manga data from json
* @param chapters chapters data from json
* @param categories categories data from json
* @param history history data from json
* @param tracks tracking data from json
* @return [Observable] containing manga restore information
*/
private fun getMangaRestoreObservable(manga: Manga, chapters: List<Chapter>,
categories: List<String>, history: List<DHistory>,
tracks: List<Track>): Observable<Manga>? {
// Get source
val source = backupManager.sourceManager.get(manga.source) ?: return null
val dbManga = backupManager.getMangaFromDatabase(manga)
if (dbManga == null) {
// Manga not in database
return mangaFetchObservable(source, manga, chapters, categories, history, tracks)
} else { // Manga in database
// Copy information from manga already in database
backupManager.restoreMangaNoFetch(manga, dbManga)
// Fetch rest of manga information
return mangaNoFetchObservable(source, manga, chapters, categories, history, tracks)
}
}
/**
* [Observable] that fetches manga information
*
* @param manga manga that needs updating
* @param chapters chapters of manga that needs updating
* @param categories categories that need updating
*/
private fun mangaFetchObservable(source: Source, manga: Manga, chapters: List<Chapter>,
categories: List<String>, history: List<DHistory>,
tracks: List<Track>): Observable<Manga> {
return backupManager.restoreMangaFetchObservable(source, manga)
.onErrorReturn {
errors.add(Date() to "${manga.title} - ${it.message}")
manga
}
.filter { it.id != null }
.flatMap { manga ->
chapterFetchObservable(source, manga, chapters)
// Convert to the manga that contains new chapters.
.map { manga }
}
.doOnNext {
// Restore categories
backupManager.restoreCategoriesForManga(it, categories)
// Restore history
backupManager.restoreHistoryForManga(history)
// Restore tracking
backupManager.restoreTrackForManga(it, tracks)
}
.doOnCompleted {
restoreProgress += 1
showRestoreProgress(restoreProgress, restoreAmount, manga.title, errors.size)
}
}
private fun mangaNoFetchObservable(source: Source, backupManga: Manga, chapters: List<Chapter>,
categories: List<String>, history: List<DHistory>,
tracks: List<Track>): Observable<Manga> {
return Observable.just(backupManga)
.flatMap { manga ->
if (!backupManager.restoreChaptersForManga(manga, chapters)) {
chapterFetchObservable(source, manga, chapters)
.map { manga }
} else {
Observable.just(manga)
}
}
.doOnNext {
// Restore categories
backupManager.restoreCategoriesForManga(it, categories)
// Restore history
backupManager.restoreHistoryForManga(history)
// Restore tracking
backupManager.restoreTrackForManga(it, tracks)
}
.doOnCompleted {
restoreProgress += 1
showRestoreProgress(restoreProgress, restoreAmount, backupManga.title, errors.size)
}
}
/**
* [Observable] that fetches chapter information
*
* @param source source of manga
* @param manga manga that needs updating
* @return [Observable] that contains manga
*/
private fun chapterFetchObservable(source: Source, manga: Manga, chapters: List<Chapter>): Observable<Pair<List<Chapter>, List<Chapter>>> {
return backupManager.restoreChapterFetchObservable(source, manga, chapters)
// If there's any error, return empty update and continue.
.onErrorReturn {
errors.add(Date() to "${manga.title} - ${it.message}")
Pair(emptyList<Chapter>(), emptyList<Chapter>())
}
}
/**
* Called to update dialog in [SettingsBackupFragment]
*
* @param progress restore progress
* @param amount total restoreAmount of manga
* @param title title of restored manga
*/
private fun showRestoreProgress(progress: Int, amount: Int, title: String, errors: Int,
content: String = getString(R.string.dialog_restoring_backup, title.chop(15))) {
val intent = Intent(SettingsBackupFragment.INTENT_FILTER).apply {
putExtra(SettingsBackupFragment.EXTRA_PROGRESS, progress)
putExtra(SettingsBackupFragment.EXTRA_AMOUNT, amount)
putExtra(SettingsBackupFragment.EXTRA_CONTENT, content)
putExtra(SettingsBackupFragment.EXTRA_ERRORS, errors)
putExtra(SettingsBackupFragment.ACTION, SettingsBackupFragment.ACTION_SET_PROGRESS_DIALOG)
}
sendLocalBroadcast(intent)
}
}
@@ -0,0 +1,23 @@
package eu.kanade.tachiyomi.data.backup.models
import java.text.SimpleDateFormat
import java.util.*
/**
* Json values
*/
object Backup {
const val CURRENT_VERSION = 2
const val MANGA = "manga"
const val MANGAS = "mangas"
const val TRACK = "track"
const val CHAPTERS = "chapters"
const val CATEGORIES = "categories"
const val HISTORY = "history"
const val VERSION = "version"
fun getDefaultFilename(): String {
val date = SimpleDateFormat("yyyy-MM-dd_HH-mm", Locale.getDefault()).format(Date())
return "tachiyomi_$date.json"
}
}
@@ -0,0 +1,3 @@
package eu.kanade.tachiyomi.data.backup.models
data class DHistory(val url: String,val lastRead: Long)
@@ -1,16 +0,0 @@
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 BooleanSerializer : JsonSerializer<Boolean> {
override fun serialize(value: Boolean?, type: Type, context: JsonSerializationContext): JsonElement? {
if (value != null && value != false)
return JsonPrimitive(value)
return null
}
}
@@ -0,0 +1,31 @@
package eu.kanade.tachiyomi.data.backup.serializer
import com.github.salomonbrys.kotson.typeAdapter
import com.google.gson.TypeAdapter
import eu.kanade.tachiyomi.data.database.models.CategoryImpl
/**
* JSON Serializer used to write / read [CategoryImpl] to / from json
*/
object CategoryTypeAdapter {
fun build(): TypeAdapter<CategoryImpl> {
return typeAdapter {
write {
beginArray()
value(it.name)
value(it.order)
endArray()
}
read {
beginArray()
val category = CategoryImpl()
category.name = nextString()
category.order = nextInt()
endArray()
category
}
}
}
}
@@ -0,0 +1,61 @@
package eu.kanade.tachiyomi.data.backup.serializer
import com.github.salomonbrys.kotson.typeAdapter
import com.google.gson.TypeAdapter
import com.google.gson.stream.JsonToken
import eu.kanade.tachiyomi.data.database.models.ChapterImpl
/**
* JSON Serializer used to write / read [ChapterImpl] to / from json
*/
object ChapterTypeAdapter {
private const val URL = "u"
private const val READ = "r"
private const val BOOKMARK = "b"
private const val LAST_READ = "l"
fun build(): TypeAdapter<ChapterImpl> {
return typeAdapter {
write {
if (it.read || it.bookmark || it.last_page_read != 0) {
beginObject()
name(URL)
value(it.url)
if (it.read) {
name(READ)
value(1)
}
if (it.bookmark) {
name(BOOKMARK)
value(1)
}
if (it.last_page_read != 0) {
name(LAST_READ)
value(it.last_page_read)
}
endObject()
}
}
read {
val chapter = ChapterImpl()
beginObject()
while (hasNext()) {
if (peek() == JsonToken.NAME) {
val name = nextName()
when (name) {
URL -> chapter.url = nextString()
READ -> chapter.read = nextInt() == 1
BOOKMARK -> chapter.bookmark = nextInt() == 1
LAST_READ -> chapter.last_page_read = nextInt()
}
}
}
endObject()
chapter
}
}
}
}
@@ -0,0 +1,32 @@
package eu.kanade.tachiyomi.data.backup.serializer
import com.github.salomonbrys.kotson.typeAdapter
import com.google.gson.TypeAdapter
import eu.kanade.tachiyomi.data.backup.models.DHistory
/**
* JSON Serializer used to write / read [DHistory] to / from json
*/
object HistoryTypeAdapter {
fun build(): TypeAdapter<DHistory> {
return typeAdapter {
write {
if (it.lastRead != 0L) {
beginArray()
value(it.url)
value(it.lastRead)
endArray()
}
}
read {
beginArray()
val url = nextString()
val lastRead = nextLong()
endArray()
DHistory(url, lastRead)
}
}
}
}
@@ -1,27 +0,0 @@
package eu.kanade.tachiyomi.data.backup.serializer
import com.google.gson.ExclusionStrategy
import com.google.gson.FieldAttributes
import eu.kanade.tachiyomi.data.database.models.CategoryImpl
import eu.kanade.tachiyomi.data.database.models.ChapterImpl
import eu.kanade.tachiyomi.data.database.models.MangaImpl
import eu.kanade.tachiyomi.data.database.models.TrackImpl
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) {
MangaImpl::class.java -> mangaExclusions.contains(f.name)
ChapterImpl::class.java -> chapterExclusions.contains(f.name)
TrackImpl::class.java -> syncExclusions.contains(f.name)
CategoryImpl::class.java -> categoryExclusions.contains(f.name)
else -> false
}
override fun shouldSkipClass(clazz: Class<*>) = false
}
@@ -1,17 +0,0 @@
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
}
}
@@ -1,16 +0,0 @@
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 LongSerializer : JsonSerializer<Long> {
override fun serialize(value: Long?, type: Type, context: JsonSerializationContext): JsonElement? {
if (value != null && value !== 0L)
return JsonPrimitive(value)
return null
}
}
@@ -0,0 +1,37 @@
package eu.kanade.tachiyomi.data.backup.serializer
import com.github.salomonbrys.kotson.typeAdapter
import com.google.gson.TypeAdapter
import eu.kanade.tachiyomi.data.database.models.MangaImpl
/**
* JSON Serializer used to write / read [MangaImpl] to / from json
*/
object MangaTypeAdapter {
fun build(): TypeAdapter<MangaImpl> {
return typeAdapter {
write {
beginArray()
value(it.url)
value(it.title)
value(it.source)
value(it.viewer)
value(it.chapter_flags)
endArray()
}
read {
beginArray()
val manga = MangaImpl()
manga.url = nextString()
manga.title = nextString()
manga.source = nextLong()
manga.viewer = nextInt()
manga.chapter_flags = nextInt()
endArray()
manga
}
}
}
}
@@ -0,0 +1,53 @@
package eu.kanade.tachiyomi.data.backup.serializer
import com.github.salomonbrys.kotson.typeAdapter
import com.google.gson.TypeAdapter
import com.google.gson.stream.JsonToken
import eu.kanade.tachiyomi.data.database.models.TrackImpl
/**
* JSON Serializer used to write / read [TrackImpl] to / from json
*/
object TrackTypeAdapter {
private const val SYNC = "s"
private const val REMOTE = "r"
private const val TITLE = "t"
private const val LAST_READ = "l"
fun build(): TypeAdapter<TrackImpl> {
return typeAdapter {
write {
beginObject()
name(TITLE)
value(it.title)
name(SYNC)
value(it.sync_id)
name(REMOTE)
value(it.remote_id)
name(LAST_READ)
value(it.last_chapter_read)
endObject()
}
read {
val track = TrackImpl()
beginObject()
while (hasNext()) {
if (peek() == JsonToken.NAME) {
val name = nextName()
when (name) {
TITLE -> track.title = nextString()
SYNC -> track.sync_id = nextInt()
REMOTE -> track.remote_id = nextInt()
LAST_READ -> track.last_chapter_read = nextInt()
}
}
}
endObject()
track
}
}
}
}