diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b00b93f45d..ee4e97ee64 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -4,13 +4,14 @@ import java.time.ZoneOffset import java.time.format.DateTimeFormatter plugins { - id("com.android.application") - id("com.google.android.gms.oss-licenses-plugin") - kotlin("android") - kotlin("android.extensions") - kotlin("kapt") - id("com.google.gms.google-services") apply false - id("org.jmailen.kotlinter") + id(Plugins.androidApplication) + kotlin(Plugins.kotlinAndroid) + kotlin(Plugins.kotlinExtensions) + kotlin(Plugins.kapt) + id(Plugins.kotlinSerialization) + id(Plugins.aboutLibraries) + id(Plugins.firebaseCrashlytics) + id(Plugins.googleServices) apply false } fun getBuildTime() = DateTimeFormatter.ISO_DATE_TIME.format(LocalDateTime.now(ZoneOffset.UTC)) @@ -45,7 +46,7 @@ android { buildConfigField("Boolean", "INCLUDE_UPDATER", "false") ndk { - abiFilters("armeabi-v7a", "arm64-v8a", "x86") + abiFilters += setOf("armeabi-v7a", "arm64-v8a", "x86") } } buildTypes { @@ -90,23 +91,26 @@ dependencies { implementation("com.github.tachiyomiorg:subsampling-scale-image-view:6caf219") implementation("com.github.inorichi:junrar-android:634c1f5") + // Source models and interfaces from Tachiyomi 1.x + implementation("tachiyomi.sourceapi:source-api:1.1") + // Android X libraries - implementation("androidx.appcompat:appcompat:1.1.0") + implementation("androidx.appcompat:appcompat:1.2.0") implementation("androidx.cardview:cardview:1.0.0") - implementation("com.google.android.material:material:1.1.0") + implementation("com.google.android.material:material:1.3.0") implementation("androidx.recyclerview:recyclerview:1.1.0") implementation("androidx.preference:preference:1.1.1") implementation("androidx.annotation:annotation:1.1.0") - implementation("androidx.browser:browser:1.2.0") - implementation("androidx.biometric:biometric:1.0.1") + implementation("androidx.browser:browser:1.3.0") + implementation("androidx.biometric:biometric:1.1.0") implementation("androidx.palette:palette:1.0.0") - implementation ("androidx.core:core-ktx:$1.3.1") + implementation("androidx.core:core-ktx:1.5.0-beta03") implementation("androidx.constraintlayout:constraintlayout:1.1.3") implementation("androidx.multidex:multidex:2.0.1") - implementation("com.google.firebase:firebase-core:17.4.4") + implementation("com.google.firebase:firebase-core:18.0.2") val lifecycleVersion = "2.2.0" implementation("androidx.lifecycle:lifecycle-extensions:$lifecycleVersion") @@ -121,13 +125,13 @@ dependencies { implementation("com.github.pwittchen:reactivenetwork:0.13.0") // Coroutines - implementation("com.github.tfcporciuncula:flow-preferences:1.1.1") + implementation("com.github.tfcporciuncula:flow-preferences:1.3.4") // Network client implementation("com.squareup.okhttp3:okhttp:${Versions.OKHTTP}") implementation("com.squareup.okhttp3:logging-interceptor:${Versions.OKHTTP}") implementation("com.squareup.okhttp3:okhttp-dnsoverhttps:${Versions.OKHTTP}") - implementation("com.squareup.okio:okio:2.7.0") + implementation("com.squareup.okio:okio:2.10.0") // Chucker val chuckerVersion = "3.2.0" @@ -153,6 +157,8 @@ dependencies { implementation("com.squareup.retrofit2:converter-gson:${Versions.RETROFIT}") // JSON + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:${Versions.KOTLINSERIALIZATION}") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-protobuf:${Versions.KOTLINSERIALIZATION}") implementation("com.google.code.gson:gson:2.8.6") implementation("com.github.salomonbrys.kotson:kotson:2.5.0") @@ -167,7 +173,6 @@ dependencies { implementation("org.jsoup:jsoup:1.13.1") // Job scheduling - implementation("android.arch.work:work-runtime:${Versions.WORKMANAGER}") implementation("android.arch.work:work-runtime-ktx:${Versions.WORKMANAGER}") implementation("com.google.android.gms:play-services-gcm:17.0.0") @@ -255,12 +260,8 @@ dependencies { implementation("org.conscrypt:conscrypt-android:2.4.0") } - tasks.preBuild { - dependsOn(tasks.lintKotlin) -} -tasks.lintKotlin { - dependsOn(tasks.formatKotlin) + dependsOn(tasks.ktlintFormat) } if (gradle.startParameter.taskRequests.toString().contains("Standard")) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/Migrations.kt b/app/src/main/java/eu/kanade/tachiyomi/Migrations.kt index 84f0262273..47b82af9e6 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/Migrations.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/Migrations.kt @@ -78,7 +78,7 @@ object Migrations { UpdaterJob.setupTask() } LibraryUpdateJob.setupTask() - BackupCreatorJob.setupTask() + BackupCreatorJob.setupTask(context) ExtensionUpdateJob.setupTask() } if (oldVersion < 66) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/AbstractBackupManager.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/AbstractBackupManager.kt new file mode 100644 index 0000000000..723fb259be --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/AbstractBackupManager.kt @@ -0,0 +1,96 @@ +package eu.kanade.tachiyomi.data.backup + +import android.content.Context +import android.net.Uri +import eu.kanade.tachiyomi.data.database.DatabaseHelper +import eu.kanade.tachiyomi.data.database.models.Chapter +import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.data.database.models.toMangaInfo +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.data.track.TrackManager +import eu.kanade.tachiyomi.source.Source +import eu.kanade.tachiyomi.source.SourceManager +import eu.kanade.tachiyomi.source.model.toSChapter +import eu.kanade.tachiyomi.util.chapter.syncChaptersWithSource +import uy.kohesive.injekt.injectLazy + +abstract class AbstractBackupManager(protected val context: Context) { + + internal val databaseHelper: DatabaseHelper by injectLazy() + internal val sourceManager: SourceManager by injectLazy() + internal val trackManager: TrackManager by injectLazy() + protected val preferences: PreferencesHelper by injectLazy() + + abstract fun createBackup(uri: Uri, flags: Int, isJob: Boolean): String? + + /** + * Returns manga + * + * @return [Manga], null if not found + */ + internal fun getMangaFromDatabase(manga: Manga): Manga? = + databaseHelper.getManga(manga.url, manga.source).executeAsBlocking() + + /** + * Fetches chapter information. + * + * @param source source of manga + * @param manga manga that needs updating + * @param chapters list of chapters in the backup + * @return Updated manga chapters. + */ + internal suspend fun restoreChapters(source: Source, manga: Manga, chapters: List): Pair, List> { + val fetchedChapters = source.getChapterList(manga.toMangaInfo()) + .map { it.toSChapter() } + val syncedChapters = syncChaptersWithSource(databaseHelper, fetchedChapters, manga, source) + if (syncedChapters.first.isNotEmpty()) { + chapters.forEach { it.manga_id = manga.id } + updateChapters(chapters) + } + return syncedChapters + } + + /** + * Returns list containing manga from library + * + * @return [Manga] from library + */ + protected fun getFavoriteManga(): List = + databaseHelper.getFavoriteMangas().executeAsBlocking() + + /** + * Inserts manga and returns id + * + * @return id of [Manga], null if not found + */ + internal fun insertManga(manga: Manga): Long? = + databaseHelper.insertManga(manga).executeAsBlocking().insertedId() + + /** + * Inserts list of chapters + */ + protected fun insertChapters(chapters: List) { + databaseHelper.insertChapters(chapters).executeAsBlocking() + } + + /** + * Updates a list of chapters + */ + protected fun updateChapters(chapters: List) { + databaseHelper.updateChaptersBackup(chapters).executeAsBlocking() + } + + /** + * Updates a list of chapters with known database ids + */ + protected fun updateKnownChapters(chapters: List) { + databaseHelper.updateKnownChaptersBackup(chapters).executeAsBlocking() + } + + /** + * Return number of backups. + * + * @return number of backups selected by user + */ + protected fun numberOfBackups(): Int = preferences.numberOfBackups().get() +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/AbstractBackupRestore.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/AbstractBackupRestore.kt new file mode 100644 index 0000000000..0706cc3f16 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/AbstractBackupRestore.kt @@ -0,0 +1,138 @@ +package eu.kanade.tachiyomi.data.backup + +import android.content.Context +import android.net.Uri +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.database.DatabaseHelper +import eu.kanade.tachiyomi.data.database.models.Chapter +import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.data.database.models.Track +import eu.kanade.tachiyomi.data.track.TrackManager +import eu.kanade.tachiyomi.source.Source +import eu.kanade.tachiyomi.util.chapter.NoChaptersException +import eu.kanade.tachiyomi.util.system.createFileInCacheDir +import kotlinx.coroutines.Job +import uy.kohesive.injekt.injectLazy +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +abstract class AbstractBackupRestore(protected val context: Context, protected val notifier: BackupNotifier) { + + protected val db: DatabaseHelper by injectLazy() + protected val trackManager: TrackManager by injectLazy() + + var job: Job? = null + + protected lateinit var backupManager: T + + protected var restoreAmount = 0 + protected var restoreProgress = 0 + + /** + * Mapping of source ID to source name from backup data + */ + protected var sourceMapping: Map = emptyMap() + + protected val errors = mutableListOf>() + + abstract suspend fun performRestore(uri: Uri): Boolean + + suspend fun restoreBackup(uri: Uri): Boolean { + val startTime = System.currentTimeMillis() + restoreProgress = 0 + errors.clear() + + if (!performRestore(uri)) { + return false + } + + val endTime = System.currentTimeMillis() + val time = endTime - startTime + + val logFile = writeErrorLog() + + notifier.showRestoreComplete(time, errors.size, logFile.parent, logFile.name) + return true + } + + /** + * Fetches chapter information. + * + * @param source source of manga + * @param manga manga that needs updating + * @return Updated manga chapters. + */ + internal suspend fun updateChapters(source: Source, manga: Manga, chapters: List): Pair, List> { + return try { + backupManager.restoreChapters(source, manga, chapters) + } catch (e: Exception) { + // If there's any error, return empty update and continue. + val errorMessage = if (e is NoChaptersException) { + context.getString(R.string.no_chapters_error) + } else { + e.message + } + errors.add(Date() to "${manga.title} - $errorMessage") + Pair(emptyList(), emptyList()) + } + } + + /** + * Refreshes tracking information. + * + * @param manga manga that needs updating. + * @param tracks list containing tracks from restore file. + */ + internal suspend fun updateTracking(manga: Manga, tracks: List) { + tracks.forEach { track -> + val service = trackManager.getService(track.sync_id) + if (service != null && service.isLogged) { + try { + val updatedTrack = service.refresh(track) + db.insertTrack(updatedTrack).executeAsBlocking() + } catch (e: Exception) { + errors.add(Date() to "${manga.title} - ${e.message}") + } + } else { + val serviceName = service?.nameRes()?.let { context.getString(it) } + errors.add(Date() to "${manga.title} - ${context.getString(R.string.not_logged_into_, serviceName)}") + } + } + } + + /** + * Called to update dialog in [BackupConst] + * + * @param progress restore progress + * @param amount total restoreAmount of manga + * @param title title of restored manga + */ + internal fun showRestoreProgress( + progress: Int, + amount: Int, + title: String + ) { + notifier.showRestoreProgress(title, progress, amount) + } + + internal fun writeErrorLog(): File { + try { + if (errors.isNotEmpty()) { + val file = context.createFileInCacheDir("tachiyomi_restore.txt") + val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.getDefault()) + + file.bufferedWriter().use { out -> + errors.forEach { (date, message) -> + out.write("[${sdf.format(date)}] $message\n") + } + } + return file + } + } catch (e: Exception) { + // Empty + } + return File("") + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/AbstractBackupRestoreValidator.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/AbstractBackupRestoreValidator.kt new file mode 100644 index 0000000000..2dc959691c --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/AbstractBackupRestoreValidator.kt @@ -0,0 +1,16 @@ +package eu.kanade.tachiyomi.data.backup + +import android.content.Context +import android.net.Uri +import eu.kanade.tachiyomi.data.track.TrackManager +import eu.kanade.tachiyomi.source.SourceManager +import uy.kohesive.injekt.injectLazy + +abstract class AbstractBackupRestoreValidator { + protected val sourceManager: SourceManager by injectLazy() + protected val trackManager: TrackManager by injectLazy() + + abstract fun validate(context: Context, uri: Uri): Results + + data class Results(val missingSources: List, val missingTrackers: List) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupConst.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupConst.kt index 6b7ef5aad6..1b396bc11b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupConst.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupConst.kt @@ -1,14 +1,15 @@ package eu.kanade.tachiyomi.data.backup + import eu.kanade.tachiyomi.BuildConfig.APPLICATION_ID as ID object BackupConst { private const val NAME = "BackupRestoreServices" + const val EXTRA_URI = "$ID.$NAME.EXTRA_URI" const val EXTRA_FLAGS = "$ID.$NAME.EXTRA_FLAGS" - const val INTENT_FILTER = "SettingsBackupFragment" - const val ACTION_BACKUP_COMPLETED_DIALOG = "$ID.$INTENT_FILTER.ACTION_BACKUP_COMPLETED_DIALOG" - const val ACTION_ERROR_BACKUP_DIALOG = "$ID.$INTENT_FILTER.ACTION_ERROR_BACKUP_DIALOG" - const val ACTION = "$ID.$INTENT_FILTER.ACTION" - const val EXTRA_ERROR_MESSAGE = "$ID.$INTENT_FILTER.EXTRA_ERROR_MESSAGE" - const val EXTRA_URI = "$ID.$INTENT_FILTER.EXTRA_URI" + const val EXTRA_MODE = "$ID.$NAME.EXTRA_MODE" + const val EXTRA_TYPE = "$ID.$NAME.EXTRA_TYPE" + + const val BACKUP_TYPE_LEGACY = 0 + const val BACKUP_TYPE_FULL = 1 } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreateService.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreateService.kt index 997f559fed..8d87f40b3c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreateService.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreateService.kt @@ -4,12 +4,15 @@ import android.app.Service import android.content.Context import android.content.Intent import android.net.Uri -import android.os.Build import android.os.IBinder import android.os.PowerManager +import androidx.core.content.ContextCompat import androidx.core.net.toUri import com.hippo.unifile.UniFile +import eu.kanade.tachiyomi.data.backup.full.FullBackupManager +import eu.kanade.tachiyomi.data.backup.legacy.LegacyBackupManager import eu.kanade.tachiyomi.data.notification.Notifications +import eu.kanade.tachiyomi.util.system.acquireWakeLock import eu.kanade.tachiyomi.util.system.isServiceRunning /** @@ -45,17 +48,14 @@ class BackupCreateService : Service() { * @param uri path of Uri * @param flags determines what to backup */ - fun start(context: Context, uri: Uri, flags: Int) { + fun start(context: Context, uri: Uri, flags: Int, type: Int) { if (!isRunning(context)) { val intent = Intent(context, BackupCreateService::class.java).apply { putExtra(BackupConst.EXTRA_URI, uri) putExtra(BackupConst.EXTRA_FLAGS, flags) + putExtra(BackupConst.EXTRA_TYPE, type) } - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { - context.startService(intent) - } else { - context.startForegroundService(intent) - } + ContextCompat.startForegroundService(context, intent) } } } @@ -65,20 +65,15 @@ class BackupCreateService : Service() { */ private lateinit var wakeLock: PowerManager.WakeLock - private lateinit var backupManager: BackupManager private lateinit var notifier: BackupNotifier override fun onCreate() { super.onCreate() + notifier = BackupNotifier(this) + wakeLock = acquireWakeLock(javaClass.name) startForeground(Notifications.ID_BACKUP_PROGRESS, notifier.showBackupProgress().build()) - - wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).newWakeLock( - PowerManager.PARTIAL_WAKE_LOCK, - "${javaClass.name}:WakeLock" - ) - wakeLock.acquire() } override fun stopService(name: Intent?): Boolean { @@ -108,11 +103,15 @@ class BackupCreateService : Service() { try { val uri = intent.getParcelableExtra(BackupConst.EXTRA_URI) val backupFlags = intent.getIntExtra(BackupConst.EXTRA_FLAGS, 0) - backupManager = BackupManager(this) + val backupType = intent.getIntExtra(BackupConst.EXTRA_TYPE, BackupConst.BACKUP_TYPE_LEGACY) + val backupManager = when (backupType) { + BackupConst.BACKUP_TYPE_FULL -> FullBackupManager(this) + else -> LegacyBackupManager(this) + } val backupFileUri = backupManager.createBackup(uri, backupFlags, false)?.toUri() val unifile = UniFile.fromUri(this, backupFileUri) - notifier.showBackupComplete(unifile) + notifier.showBackupComplete(unifile, backupType == BackupConst.BACKUP_TYPE_LEGACY) } catch (e: Exception) { notifier.showBackupError(e.message) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreatorJob.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreatorJob.kt index 73d6509adf..81df6a7601 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreatorJob.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupCreatorJob.kt @@ -7,8 +7,9 @@ import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import androidx.work.Worker import androidx.work.WorkerParameters +import eu.kanade.tachiyomi.data.backup.full.FullBackupManager +import eu.kanade.tachiyomi.data.backup.legacy.LegacyBackupManager 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 import java.util.concurrent.TimeUnit @@ -18,11 +19,13 @@ class BackupCreatorJob(private val context: Context, workerParams: WorkerParamet override fun doWork(): Result { val preferences = Injekt.get() - val backupManager = BackupManager(context) - val uri = preferences.backupsDirectory().getOrDefault().toUri() + val uri = preferences.backupsDirectory().get().toUri() val flags = BackupCreateService.BACKUP_ALL return try { - backupManager.createBackup(uri, flags, true) + FullBackupManager(context).createBackup(uri, flags, true) + if (preferences.createLegacyBackup().get()) { + LegacyBackupManager(context).createBackup(uri, flags, true) + } Result.success() } catch (e: Exception) { Result.failure() @@ -32,9 +35,9 @@ class BackupCreatorJob(private val context: Context, workerParams: WorkerParamet companion object { private const val TAG = "BackupCreator" - fun setupTask(prefInterval: Int? = null) { + fun setupTask(context: Context, prefInterval: Int? = null) { val preferences = Injekt.get() - val interval = prefInterval ?: preferences.backupInterval().getOrDefault() + val interval = prefInterval ?: preferences.backupInterval().get() if (interval > 0) { val request = PeriodicWorkRequestBuilder( interval.toLong(), diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupNotifier.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupNotifier.kt index e1c46a93f5..ac74588c30 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupNotifier.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupNotifier.kt @@ -8,11 +8,14 @@ import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.notification.NotificationReceiver import eu.kanade.tachiyomi.data.notification.Notifications import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.util.storage.getUriCompat import eu.kanade.tachiyomi.util.system.notificationBuilder import eu.kanade.tachiyomi.util.system.notificationManager import uy.kohesive.injekt.injectLazy +import java.io.File +import java.util.concurrent.TimeUnit -internal class BackupNotifier(private val context: Context) { +class BackupNotifier(private val context: Context) { private val preferences: PreferencesHelper by injectLazy() @@ -57,28 +60,93 @@ internal class BackupNotifier(private val context: Context) { } } - fun showBackupComplete(unifile: UniFile) { + fun showBackupComplete(unifile: UniFile, isLegacyFormat: Boolean) { context.notificationManager.cancel(Notifications.ID_BACKUP_PROGRESS) with(completeNotificationBuilder) { setContentTitle(context.getString(R.string.backup_created)) - - if (unifile.filePath != null) { - setContentText(unifile.filePath) - } + setContentText(unifile.filePath ?: unifile.name) // Clear old actions if they exist - if (mActions.isNotEmpty()) { - mActions.clear() - } + clearActions() addAction( R.drawable.ic_share_24dp, context.getString(R.string.share), - NotificationReceiver.shareBackupPendingBroadcast(context, unifile.uri, Notifications.ID_BACKUP_COMPLETE) + NotificationReceiver.shareBackupPendingBroadcast(context, unifile.uri, isLegacyFormat, Notifications.ID_BACKUP_COMPLETE) ) show(Notifications.ID_BACKUP_COMPLETE) } } + + fun showRestoreProgress(content: String = "", progress: Int = 0, maxAmount: Int = 100): NotificationCompat.Builder { + val builder = with(progressNotificationBuilder) { + setContentTitle(context.getString(R.string.restoring_backup)) + + // if (!preferences.hideNotificationContent()) { + setContentText(content) + // } + + setProgress(maxAmount, progress, false) + setOnlyAlertOnce(true) + + // Clear old actions if they exist + clearActions() + + addAction( + R.drawable.ic_close_24dp, + context.getString(R.string.stop), + NotificationReceiver.cancelRestorePendingBroadcast(context, Notifications.ID_RESTORE_PROGRESS) + ) + } + + builder.show(Notifications.ID_RESTORE_PROGRESS) + + return builder + } + + fun showRestoreError(error: String?) { + context.notificationManager.cancel(Notifications.ID_RESTORE_PROGRESS) + + with(completeNotificationBuilder) { + setContentTitle(context.getString(R.string.restore_error)) + setContentText(error) + + show(Notifications.ID_RESTORE_COMPLETE) + } + } + + fun showRestoreComplete(time: Long, errorCount: Int, path: String?, file: String?) { + context.notificationManager.cancel(Notifications.ID_RESTORE_PROGRESS) + + val timeString = context.getString( + R.string.restore_duration, + TimeUnit.MILLISECONDS.toMinutes(time), + TimeUnit.MILLISECONDS.toSeconds(time) - TimeUnit.MINUTES.toSeconds( + TimeUnit.MILLISECONDS.toMinutes(time) + ) + ) + + with(completeNotificationBuilder) { + setContentTitle(context.getString(R.string.restore_completed)) + setContentText(context.resources.getQuantityString(R.plurals.restore_completed_message, errorCount, timeString, errorCount)) + + // Clear old actions if they exist + clearActions() + + if (errorCount > 0 && !path.isNullOrEmpty() && !file.isNullOrEmpty()) { + val destFile = File(path, file) + val uri = destFile.getUriCompat(context) + + addAction( + R.drawable.ic_eye_24dp, + context.getString(R.string.open_log), + NotificationReceiver.openErrorLogPendingActivity(context, uri) + ) + } + + show(Notifications.ID_RESTORE_COMPLETE) + } + } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupRestoreService.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupRestoreService.kt index 9e03185418..dcae87dbcf 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupRestoreService.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupRestoreService.kt @@ -1,504 +1,31 @@ package eu.kanade.tachiyomi.data.backup -import android.app.PendingIntent import android.app.Service import android.content.Context import android.content.Intent import android.net.Uri -import android.os.Build import android.os.IBinder import android.os.PowerManager -import androidx.core.app.NotificationCompat import androidx.core.content.ContextCompat -import com.github.salomonbrys.kotson.fromJson -import com.google.gson.JsonArray -import com.google.gson.JsonObject -import com.google.gson.JsonParser -import com.google.gson.stream.JsonReader 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.EXTENSIONS -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.ChapterImpl -import eu.kanade.tachiyomi.data.database.models.Manga -import eu.kanade.tachiyomi.data.database.models.MangaImpl -import eu.kanade.tachiyomi.data.database.models.Track -import eu.kanade.tachiyomi.data.database.models.TrackImpl -import eu.kanade.tachiyomi.data.notification.NotificationReceiver +import eu.kanade.tachiyomi.data.backup.full.FullBackupRestore +import eu.kanade.tachiyomi.data.backup.legacy.LegacyBackupRestore import eu.kanade.tachiyomi.data.notification.Notifications -import eu.kanade.tachiyomi.data.track.TrackManager -import eu.kanade.tachiyomi.source.SourceNotFoundException -import eu.kanade.tachiyomi.util.lang.chop -import eu.kanade.tachiyomi.util.storage.getUriCompat +import eu.kanade.tachiyomi.util.system.acquireWakeLock import eu.kanade.tachiyomi.util.system.isServiceRunning -import eu.kanade.tachiyomi.util.system.notificationManager import kotlinx.coroutines.CoroutineExceptionHandler -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.Job +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import timber.log.Timber -import uy.kohesive.injekt.injectLazy -import java.io.File -import java.text.SimpleDateFormat -import java.util.Locale -import java.util.concurrent.TimeUnit /** - * Restores backup from json file + * Restores backup. */ class BackupRestoreService : Service() { - /** - * 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 job: Job? = null - - /** - * The progress of a backup restore - */ - private var restoreProgress = 0 - - private var totalAmount = 0 - - /** - * List containing errors - */ - private val errors = mutableListOf() - - /** - * count of cancelled - */ - private var cancelled = 0 - - /** - * List containing distinct errors - */ - private val trackingErrors = mutableListOf() - - /** - * List containing missing sources - */ - private val sourcesMissing = mutableListOf() - - var extensionsMap: Map = emptyMap() - - /** - * List containing missing sources - */ - private var lincensedManga = 0 - - /** - * Backup manager - */ - private lateinit var backupManager: BackupManager - - /** - * Database - */ - private val db: DatabaseHelper by injectLazy() - - /** - * Tracking manager - */ - internal val trackManager: TrackManager by injectLazy() - - /** - * Method called when the service is created. It injects dependencies and acquire the wake lock. - */ - override fun onCreate() { - super.onCreate() - startForeground(Notifications.ID_RESTORE_PROGRESS, progressNotification.build()) - wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).newWakeLock( - PowerManager.PARTIAL_WAKE_LOCK, - "BackupRestoreService:WakeLock" - ) - wakeLock.acquire(TimeUnit.HOURS.toMillis(3)) - } - - /** - * Method called when the service is destroyed. It destroys the running subscription and - * releases the wake lock. - */ - override fun onDestroy() { - job?.cancel() - 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? = 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 { - val uri = intent?.getParcelableExtra(BackupConst.EXTRA_URI) ?: return START_NOT_STICKY - - // Unsubscribe from any previous subscription if needed. - job?.cancel() - val handler = CoroutineExceptionHandler { _, exception -> - Timber.e(exception) - showErrorNotification(exception.message!!) - stopSelf(startId) - } - job = GlobalScope.launch(handler) { - restoreBackup(uri) - } - job?.invokeOnCompletion { stopSelf(startId) } - - return START_NOT_STICKY - } - - override fun stopService(name: Intent?): Boolean { - job?.cancel() - if (wakeLock.isHeld) { - wakeLock.release() - } - return super.stopService(name) - } - - /** - * Restore a backup json file - */ - private suspend fun restoreBackup(uri: Uri) { - val reader = JsonReader(contentResolver.openInputStream(uri)!!.bufferedReader()) - val json = JsonParser.parseReader(reader).asJsonObject - - // Get parser version - val version = json.get(VERSION)?.asInt ?: 1 - - // Initialize manager - backupManager = BackupManager(this, version) - - val mangasJson = json.get(MANGAS).asJsonArray - - // +1 for categories - totalAmount = mangasJson.size() + 1 - trackingErrors.clear() - sourcesMissing.clear() - lincensedManga = 0 - errors.clear() - cancelled = 0 - // Restore categories - restoreCategories(json, backupManager) - extensionsMap = getExtensionsList(json, backupManager) - - mangasJson.forEach { - restoreManga(it.asJsonObject, backupManager) - } - - notificationManager.cancel(Notifications.ID_RESTORE_PROGRESS) - - cancelled = errors.count { it.contains("cancelled", true) } - val tmpErrors = errors.filter { !it.contains("cancelled", true) } - errors.clear() - errors.addAll(tmpErrors) - - val logFile = writeErrorLog() - showResultNotification(logFile.parent, logFile.name) - } - - /** - * Restore extension names if they were backed up - */ - private fun getExtensionsList(json: JsonObject, backupManager: BackupManager): Map { - json.get(EXTENSIONS)?.let { element -> - return backupManager.getExtensionsMap(element.asJsonArray) - } - return emptyMap() - } - - /** - * Restore categories if they were backed up - */ - private fun restoreCategories(json: JsonObject, backupManager: BackupManager) { - val element = json.get(CATEGORIES) - if (element != null) { - backupManager.restoreCategories(element.asJsonArray) - restoreProgress += 1 - showProgressNotification(restoreProgress, totalAmount, "Categories added") - } else { - totalAmount -= 1 - } - } - - /** - * Restore manga from json this should be refactored more at some point to prevent the manga object from being mutable - */ - private suspend fun restoreManga(obj: JsonObject, backupManager: BackupManager) { - val manga = backupManager.parser.fromJson(obj.get(MANGA)) - val chapters = backupManager.parser.fromJson>(obj.get(CHAPTERS) ?: JsonArray()) - val categories = backupManager.parser.fromJson>(obj.get(CATEGORIES) ?: JsonArray()) - val history = backupManager.parser.fromJson>(obj.get(HISTORY) ?: JsonArray()) - val tracks = backupManager.parser.fromJson>(obj.get(TRACK) ?: JsonArray()) - val source = backupManager.sourceManager.getOrStub(manga.source) - - try { - if (job?.isCancelled == false) { - showProgressNotification(restoreProgress, totalAmount, manga.title) - restoreProgress += 1 - } else { - throw java.lang.Exception("Job was cancelled") - } - val dbManga = backupManager.getMangaFromDatabase(manga) - val dbMangaExists = dbManga != null - - if (dbMangaExists) { - // Manga in database copy information from manga already in database - backupManager.restoreMangaNoFetch(manga, dbManga!!) - } else { - // manga gets details from network - backupManager.restoreMangaFetch(source, manga) - } - - // Restore categories - backupManager.restoreCategoriesForManga(manga, categories) - - if (!dbMangaExists || !backupManager.restoreChaptersForManga(manga, chapters)) { - // manga gets chapters added - backupManager.restoreChapterFetch(source, manga, chapters) - } - // Restore history - backupManager.restoreHistoryForManga(history) - // Restore tracking - backupManager.restoreTrackForManga(manga, tracks) - - trackingFetch(manga, tracks) - } catch (e: Exception) { - Timber.e(e) - - if (e is RuntimeException) { - val cause = e.cause - if (cause is SourceNotFoundException) { - val sourceName = extensionsMap[cause.id.toString()] ?: cause.id.toString() - sourcesMissing.add( - extensionsMap[cause.id.toString()] ?: cause.id.toString() - ) - val errorMessage = getString(R.string.source_not_installed_, sourceName) - errors.add("${manga.title} - $errorMessage") - } else { - if (e.message?.contains("licensed", true) == true) { - lincensedManga++ - } - errors.add("${manga.title} - ${cause?.message ?: e.message}") - } - return - } - errors.add("${manga.title} - ${e.message}") - } - } - - /** - * [refreshes tracking information - * @param manga manga that needs updating. - * @param tracks list containing tracks from restore file. - */ - private suspend fun trackingFetch(manga: Manga, tracks: List) { - tracks.forEach { track -> - val service = trackManager.getService(track.sync_id) - if (service != null && service.isLogged) { - try { - service.refresh(track) - db.insertTrack(track).executeAsBlocking() - } catch (e: Exception) { - errors.add("${manga.title} - ${e.message}") - } - } else { - errors.add("${manga.title} - ${getString(R.string.not_logged_into_, service?.name)}") - val notLoggedIn = getString(R.string.not_logged_into_, service?.name) - trackingErrors.add(notLoggedIn) - } - } - } - - /** - * 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 { message -> - out.write("$message\n") - } - } - return destFile - } - } catch (e: Exception) { - Timber.e(e) - } - return File("") - } - - /** - * keep a partially constructed progress notification for resuse - */ - private val progressNotification by lazy { - NotificationCompat.Builder(this, Notifications.CHANNEL_BACKUP_RESTORE) - .setContentTitle(getString(R.string.app_name)) - .setSmallIcon(R.drawable.ic_tachi) - .setOngoing(true) - .setOnlyAlertOnce(true) - .setAutoCancel(false) - .setColor(ContextCompat.getColor(this, R.color.colorAccent)) - .addAction(R.drawable.ic_close_24dp, getString(android.R.string.cancel), cancelIntent) - } - - /** - * Pending intent of action that cancels the library update - */ - private val cancelIntent by lazy { - NotificationReceiver.cancelRestorePendingBroadcast(this) - } - - /** - * Shows the notification containing the currently updating manga and the progress. - * - * @param manga the manga that's being updated. - * @param current the current progress. - * @param total the total progress. - */ - private fun showProgressNotification(current: Int, total: Int, title: String) { - notificationManager.notify( - Notifications.ID_RESTORE_PROGRESS, - progressNotification - .setContentTitle(title.chop(30)) - .setContentText( - getString( - R.string.restoring_progress, - restoreProgress, - totalAmount - ) - ) - .setProgress(total, current, false) - .build() - ) - } - - /** - * Show the result notification with option to show the error log - */ - private fun showResultNotification(path: String?, file: String?) { - - val content = mutableListOf( - getString( - R.string.restore_completed_content, - restoreProgress - .toString(), - errors.size.toString() - ) - ) - val sourceMissingCount = sourcesMissing.distinct().size - if (sourceMissingCount > 0) { - val sources = sourcesMissing.distinct().filter { it.toLongOrNull() == null } - val missingSourcesString = if (sources.size > 5) { - sources.take(5).joinToString(", ") + "..." - } else { - sources.joinToString(", ") - } - if (sources.isEmpty()) { - content.add( - resources.getQuantityString( - R.plurals.sources_missing, - sourceMissingCount, - sourceMissingCount - ) - ) - } else { - content.add( - resources.getQuantityString( - R.plurals.sources_missing, - sourceMissingCount, - sourceMissingCount - ) + ": " + missingSourcesString - ) - } - } - if (lincensedManga > 0) - content.add( - resources.getQuantityString( - R.plurals.licensed_manga, - lincensedManga, - lincensedManga - ) - ) - val trackingErrors = trackingErrors.distinct() - if (trackingErrors.isNotEmpty()) { - val trackingErrorsString = trackingErrors.distinct().joinToString("\n") - content.add(trackingErrorsString) - } - if (cancelled > 0) - content.add(getString(R.string.restore_content_skipped, cancelled)) - - val restoreString = content.joinToString("\n") - - val resultNotification = NotificationCompat.Builder(this, Notifications.CHANNEL_BACKUP_RESTORE) - .setContentTitle(getString(R.string.restore_completed)) - .setContentText(restoreString) - .setStyle(NotificationCompat.BigTextStyle().bigText(restoreString)) - .setSmallIcon(R.drawable.ic_tachi) - .setPriority(NotificationCompat.PRIORITY_HIGH) - .setColor(ContextCompat.getColor(this, R.color.colorAccent)) - if (errors.size > 0 && !path.isNullOrEmpty() && !file.isNullOrEmpty()) { - resultNotification.addAction( - R.drawable.ic_close_24dp, - getString( - R.string - .view_all_errors - ), - getErrorLogIntent(path, file) - ) - } - notificationManager.notify(Notifications.ID_RESTORE_COMPLETE, resultNotification.build()) - } - - /**Show an error notification if something happens that prevents the restore from starting/working - * - */ - private fun showErrorNotification(errorMessage: String) { - val resultNotification = NotificationCompat.Builder(this, Notifications.CHANNEL_BACKUP_RESTORE) - .setContentTitle(getString(R.string.restore_error)) - .setContentText(errorMessage) - .setSmallIcon(R.drawable.ic_error_24dp) - .setPriority(NotificationCompat.PRIORITY_HIGH) - .setColor(ContextCompat.getColor(this, R.color.md_red_500)) - notificationManager.notify(Notifications.ID_RESTORE_ERROR, resultNotification.build()) - } - - /**Get the PendingIntent for the error log - * - */ - private fun getErrorLogIntent(path: String, file: String): PendingIntent { - val destFile = File(path, file!!) - val uri = destFile.getUriCompat(applicationContext) - return NotificationReceiver.openFileExplorerPendingActivity(this@BackupRestoreService, uri) - } - companion object { /** @@ -516,16 +43,13 @@ class BackupRestoreService : Service() { * @param context context of application * @param uri path of Uri */ - fun start(context: Context, uri: Uri) { + fun start(context: Context, uri: Uri, mode: Int) { if (!isRunning(context)) { val intent = Intent(context, BackupRestoreService::class.java).apply { putExtra(BackupConst.EXTRA_URI, uri) + putExtra(BackupConst.EXTRA_MODE, mode) } - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { - context.startService(intent) - } else { - context.startForegroundService(intent) - } + ContextCompat.startForegroundService(context, intent) } } @@ -536,6 +60,90 @@ class BackupRestoreService : Service() { */ fun stop(context: Context) { context.stopService(Intent(context, BackupRestoreService::class.java)) + + BackupNotifier(context).showRestoreError(context.getString(R.string.restoring_backup_canceled)) } } + + /** + * Wake lock that will be held until the service is destroyed. + */ + private lateinit var wakeLock: PowerManager.WakeLock + + private lateinit var ioScope: CoroutineScope + private var backupRestore: AbstractBackupRestore<*>? = null + private lateinit var notifier: BackupNotifier + + override fun onCreate() { + super.onCreate() + + ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + notifier = BackupNotifier(this) + wakeLock = acquireWakeLock(javaClass.name) + + startForeground(Notifications.ID_RESTORE_PROGRESS, notifier.showRestoreProgress().build()) + } + + override fun stopService(name: Intent?): Boolean { + destroyJob() + return super.stopService(name) + } + + override fun onDestroy() { + destroyJob() + super.onDestroy() + } + + private fun destroyJob() { + backupRestore?.job?.cancel() + ioScope?.cancel() + if (wakeLock.isHeld) { + wakeLock.release() + } + } + + /** + * This method needs to be implemented, but it's not used/needed. + */ + override fun onBind(intent: Intent): IBinder? = 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 { + val uri = intent?.getParcelableExtra(BackupConst.EXTRA_URI) ?: return START_NOT_STICKY + val mode = intent.getIntExtra(BackupConst.EXTRA_MODE, BackupConst.BACKUP_TYPE_FULL) + + // Cancel any previous job if needed. + backupRestore?.job?.cancel() + + backupRestore = when (mode) { + BackupConst.BACKUP_TYPE_FULL -> FullBackupRestore(this, notifier) + else -> LegacyBackupRestore(this, notifier) + } + + val handler = CoroutineExceptionHandler { _, exception -> + Timber.e(exception) + backupRestore?.writeErrorLog() + + notifier.showRestoreError(exception.message) + stopSelf(startId) + } + val job = ioScope.launch(handler) { + if (backupRestore?.restoreBackup(uri) == false) { + notifier.showRestoreError(getString(R.string.restoring_backup_canceled)) + } + } + job.invokeOnCompletion { + stopSelf(startId) + } + backupRestore?.job = job + + return START_NOT_STICKY + } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/FullBackupManager.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/FullBackupManager.kt new file mode 100644 index 0000000000..61a2448579 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/FullBackupManager.kt @@ -0,0 +1,352 @@ +package eu.kanade.tachiyomi.data.backup.full + +import android.content.Context +import android.net.Uri +import com.hippo.unifile.UniFile +import eu.kanade.tachiyomi.data.backup.AbstractBackupManager +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.full.models.Backup +import eu.kanade.tachiyomi.data.backup.full.models.BackupCategory +import eu.kanade.tachiyomi.data.backup.full.models.BackupChapter +import eu.kanade.tachiyomi.data.backup.full.models.BackupFull +import eu.kanade.tachiyomi.data.backup.full.models.BackupHistory +import eu.kanade.tachiyomi.data.backup.full.models.BackupManga +import eu.kanade.tachiyomi.data.backup.full.models.BackupSerializer +import eu.kanade.tachiyomi.data.backup.full.models.BackupSource +import eu.kanade.tachiyomi.data.backup.full.models.BackupTracking +import eu.kanade.tachiyomi.data.database.models.Chapter +import eu.kanade.tachiyomi.data.database.models.History +import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.data.database.models.MangaCategory +import eu.kanade.tachiyomi.data.database.models.Track +import kotlinx.serialization.protobuf.ProtoBuf +import okio.buffer +import okio.gzip +import okio.sink +import timber.log.Timber +import kotlin.math.max + +class FullBackupManager(context: Context) : AbstractBackupManager(context) { + + val parser = ProtoBuf + + /** + * Create backup Json file from database + * + * @param uri path of Uri + * @param isJob backup called from job + */ + override fun createBackup(uri: Uri, flags: Int, isJob: Boolean): String? { + // Create root object + var backup: Backup? = null + + databaseHelper.inTransaction { + val databaseManga = getFavoriteManga() + + backup = Backup( + backupManga(databaseManga, flags), + backupCategories(), + backupExtensionInfo(databaseManga) + ) + } + + try { + val file: UniFile = ( + if (isJob) { + // Get dir of file and create + var dir = UniFile.fromUri(context, uri) + dir = dir.createDirectory("automatic") + + // Delete older backups + val numberOfBackups = numberOfBackups() + val backupRegex = Regex("""tachiyomi_\d+-\d+-\d+_\d+-\d+.proto.gz""") + dir.listFiles { _, filename -> backupRegex.matches(filename) } + .orEmpty() + .sortedByDescending { it.name } + .drop(numberOfBackups - 1) + .forEach { it.delete() } + + // Create new file to place backup + dir.createFile(BackupFull.getDefaultFilename()) + } else { + UniFile.fromUri(context, uri) + } + ) + ?: throw Exception("Couldn't create backup file") + + val byteArray = parser.encodeToByteArray(BackupSerializer, backup!!) + file.openOutputStream().sink().gzip().buffer().use { it.write(byteArray) } + return file.uri.toString() + } catch (e: Exception) { + Timber.e(e) + throw e + } + } + + private fun backupManga(mangas: List, flags: Int): List { + return mangas.map { + backupMangaObject(it, flags) + } + } + + private fun backupExtensionInfo(mangas: List): List { + return mangas + .asSequence() + .map { it.source } + .distinct() + .map { sourceManager.getOrStub(it) } + .map { BackupSource.copyFrom(it) } + .toList() + } + + /** + * Backup the categories of library + * + * @return list of [BackupCategory] to be backed up + */ + private fun backupCategories(): List { + return databaseHelper.getCategories() + .executeAsBlocking() + .map { BackupCategory.copyFrom(it) } + } + + /** + * Convert a manga to Json + * + * @param manga manga that gets converted + * @param options options for the backup + * @return [BackupManga] containing manga in a serializable form + */ + private fun backupMangaObject(manga: Manga, options: Int): BackupManga { + // Entry for this manga + val mangaObject = BackupManga.copyFrom(manga) + + // 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.isNotEmpty()) { + mangaObject.chapters = chapters.map { BackupChapter.copyFrom(it) } + } + } + + // 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.isNotEmpty()) { + mangaObject.categories = categoriesForManga.mapNotNull { it.order } + } + } + + // Check if user wants track information in backup + if (options and BACKUP_TRACK_MASK == BACKUP_TRACK) { + val tracks = databaseHelper.getTracks(manga).executeAsBlocking() + if (tracks.isNotEmpty()) { + mangaObject.tracking = tracks.map { BackupTracking.copyFrom(it) } + } + } + + // 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.isNotEmpty()) { + val history = historyForManga.mapNotNull { history -> + val url = databaseHelper.getChapter(history.chapter_id).executeAsBlocking()?.url + url?.let { BackupHistory(url, history.last_read) } + } + if (history.isNotEmpty()) { + mangaObject.history = history + } + } + } + + return mangaObject + } + + fun restoreMangaNoFetch(manga: Manga, dbManga: Manga) { + manga.id = dbManga.id + manga.copyFrom(dbManga) + insertManga(manga) + } + + /** + * Fetches manga information + * + * @param manga manga that needs updating + * @return Updated manga info. + */ + fun restoreManga(manga: Manga): Manga { + return manga.also { + it.initialized = it.description != null + it.id = insertManga(it) + } + } + + /** + * Restore the categories from Json + * + * @param backupCategories list containing categories + */ + internal fun restoreCategories(backupCategories: List) { + // Get categories from file and from db + val dbCategories = databaseHelper.getCategories().executeAsBlocking() + + // Iterate over them + backupCategories.map { it.getCategoryImpl() }.forEach { category -> + // 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.name == dbCategory.name) { + 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 = databaseHelper.insertCategory(category).executeAsBlocking() + category.id = result.insertedId()?.toInt() + } + } + } + + /** + * Restores the categories a manga is in. + * + * @param manga the manga whose categories have to be restored. + * @param categories the categories to restore. + */ + internal fun restoreCategoriesForManga(manga: Manga, categories: List, backupCategories: List) { + val dbCategories = databaseHelper.getCategories().executeAsBlocking() + val mangaCategoriesToUpdate = ArrayList(categories.size) + categories.forEach { backupCategoryOrder -> + backupCategories.firstOrNull { + it.order == backupCategoryOrder + }?.let { backupCategory -> + dbCategories.firstOrNull { dbCategory -> + dbCategory.name == backupCategory.name + }?.let { dbCategory -> + mangaCategoriesToUpdate += MangaCategory.create(manga, dbCategory) + } + } + } + + // Update database + if (mangaCategoriesToUpdate.isNotEmpty()) { + databaseHelper.deleteOldMangasCategories(listOf(manga)).executeAsBlocking() + databaseHelper.insertMangasCategories(mangaCategoriesToUpdate).executeAsBlocking() + } + } + + /** + * Restore history from Json + * + * @param history list containing history to be restored + */ + internal fun restoreHistoryForManga(history: List) { + // List containing history to be updated + val historyToBeUpdated = ArrayList(history.size) + 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 = 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. + */ + internal fun restoreTrackForManga(manga: Manga, tracks: List) { + // Fix foreign keys with the current manga id + tracks.map { it.manga_id = manga.id!! } + + // Get tracks from database + val dbTracks = databaseHelper.getTracks(manga).executeAsBlocking() + val trackToUpdate = mutableListOf() + + tracks.forEach { track -> + 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.media_id != dbTrack.media_id) { + dbTrack.media_id = track.media_id + } + if (track.library_id != dbTrack.library_id) { + dbTrack.library_id = track.library_id + } + dbTrack.last_chapter_read = 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 + track.id = null + trackToUpdate.add(track) + } + } + // Update database + if (trackToUpdate.isNotEmpty()) { + databaseHelper.insertTracks(trackToUpdate).executeAsBlocking() + } + } + + internal fun restoreChaptersForManga(manga: Manga, chapters: List) { + val dbChapters = databaseHelper.getChapters(manga).executeAsBlocking() + + chapters.forEach { chapter -> + val dbChapter = dbChapters.find { it.url == chapter.url } + if (dbChapter != null) { + chapter.id = dbChapter.id + chapter.copyFrom(dbChapter) + if (dbChapter.read && !chapter.read) { + chapter.read = dbChapter.read + chapter.last_page_read = dbChapter.last_page_read + } else if (chapter.last_page_read == 0 && dbChapter.last_page_read != 0) { + chapter.last_page_read = dbChapter.last_page_read + } + if (!chapter.bookmark && dbChapter.bookmark) { + chapter.bookmark = dbChapter.bookmark + } + } + + chapter.manga_id = manga.id + } + + val newChapters = chapters.groupBy { it.id != null } + newChapters[true]?.let { updateKnownChapters(it) } + newChapters[false]?.let { insertChapters(it) } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/FullBackupRestore.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/FullBackupRestore.kt new file mode 100644 index 0000000000..8e0dd54085 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/FullBackupRestore.kt @@ -0,0 +1,161 @@ +package eu.kanade.tachiyomi.data.backup.full + +import android.content.Context +import android.net.Uri +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.backup.AbstractBackupRestore +import eu.kanade.tachiyomi.data.backup.BackupNotifier +import eu.kanade.tachiyomi.data.backup.full.models.BackupCategory +import eu.kanade.tachiyomi.data.backup.full.models.BackupHistory +import eu.kanade.tachiyomi.data.backup.full.models.BackupManga +import eu.kanade.tachiyomi.data.backup.full.models.BackupSerializer +import eu.kanade.tachiyomi.data.database.models.Chapter +import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.data.database.models.Track +import okio.buffer +import okio.gzip +import okio.source +import java.util.Date + +class FullBackupRestore(context: Context, notifier: BackupNotifier) : AbstractBackupRestore(context, notifier) { + + override suspend fun performRestore(uri: Uri): Boolean { + backupManager = FullBackupManager(context) + + val backupString = context.contentResolver.openInputStream(uri)!!.source().gzip().buffer().use { it.readByteArray() } + val backup = backupManager.parser.decodeFromByteArray(BackupSerializer, backupString) + + restoreAmount = backup.backupManga.size + 1 // +1 for categories + + // Restore categories + if (backup.backupCategories.isNotEmpty()) { + restoreCategories(backup.backupCategories) + } + + // Store source mapping for error messages + sourceMapping = backup.backupSources.map { it.sourceId to it.name }.toMap() + + // Restore individual manga + backup.backupManga.forEach { + if (job?.isActive != true) { + return false + } + + restoreManga(it, backup.backupCategories) + } + + // TODO: optionally trigger online library + tracker update + + return true + } + + private fun restoreCategories(backupCategories: List) { + db.inTransaction { + backupManager.restoreCategories(backupCategories) + } + + restoreProgress += 1 + showRestoreProgress(restoreProgress, restoreAmount, context.getString(R.string.categories)) + } + + private fun restoreManga(backupManga: BackupManga, backupCategories: List) { + val manga = backupManga.getMangaImpl() + val chapters = backupManga.getChaptersImpl() + val categories = backupManga.categories + val history = backupManga.history + val tracks = backupManga.getTrackingImpl() + + try { + restoreMangaData(manga, chapters, categories, history, tracks, backupCategories) + } catch (e: Exception) { + val sourceName = sourceMapping[manga.source] ?: manga.source.toString() + errors.add(Date() to "${manga.title} [$sourceName]: ${e.message}") + } + + restoreProgress += 1 + showRestoreProgress(restoreProgress, restoreAmount, manga.title) + } + + /** + * 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 + */ + private fun restoreMangaData( + manga: Manga, + chapters: List, + categories: List, + history: List, + tracks: List, + backupCategories: List + ) { + db.inTransaction { + val dbManga = backupManager.getMangaFromDatabase(manga) + if (dbManga == null) { + // Manga not in database + restoreMangaFetch(manga, chapters, categories, history, tracks, backupCategories) + } else { + // Manga in database + // Copy information from manga already in database + backupManager.restoreMangaNoFetch(manga, dbManga) + // Fetch rest of manga information + restoreMangaNoFetch(manga, chapters, categories, history, tracks, backupCategories) + } + } + } + + /** + * 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 restoreMangaFetch( + manga: Manga, + chapters: List, + categories: List, + history: List, + tracks: List, + backupCategories: List + ) { + try { + val fetchedManga = backupManager.restoreManga(manga) + fetchedManga.id ?: return + + backupManager.restoreChaptersForManga(fetchedManga, chapters) + + restoreExtraForManga(fetchedManga, categories, history, tracks, backupCategories) + } catch (e: Exception) { + errors.add(Date() to "${manga.title} - ${e.message}") + } + } + + private fun restoreMangaNoFetch( + backupManga: Manga, + chapters: List, + categories: List, + history: List, + tracks: List, + backupCategories: List + ) { + backupManager.restoreChaptersForManga(backupManga, chapters) + + restoreExtraForManga(backupManga, categories, history, tracks, backupCategories) + } + + private fun restoreExtraForManga(manga: Manga, categories: List, history: List, tracks: List, backupCategories: List) { + // Restore categories + backupManager.restoreCategoriesForManga(manga, categories, backupCategories) + + // Restore history + backupManager.restoreHistoryForManga(history) + + // Restore tracking + backupManager.restoreTrackForManga(manga, tracks) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/FullBackupRestoreValidator.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/FullBackupRestoreValidator.kt new file mode 100644 index 0000000000..bb932c52c2 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/FullBackupRestoreValidator.kt @@ -0,0 +1,47 @@ +package eu.kanade.tachiyomi.data.backup.full + +import android.content.Context +import android.net.Uri +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.backup.AbstractBackupRestoreValidator +import eu.kanade.tachiyomi.data.backup.full.models.BackupSerializer +import okio.buffer +import okio.gzip +import okio.source + +class FullBackupRestoreValidator : AbstractBackupRestoreValidator() { + /** + * Checks for critical backup file data. + * + * @throws Exception if manga cannot be found. + * @return List of missing sources or missing trackers. + */ + override fun validate(context: Context, uri: Uri): Results { + val backupManager = FullBackupManager(context) + + val backupString = context.contentResolver.openInputStream(uri)!!.source().gzip().buffer().use { it.readByteArray() } + val backup = backupManager.parser.decodeFromByteArray(BackupSerializer, backupString) + + if (backup.backupManga.isEmpty()) { + throw Exception(context.getString(R.string.backup_has_no_manga)) + } + + val sources = backup.backupSources.map { it.sourceId to it.name }.toMap() + val missingSources = sources + .filter { sourceManager.get(it.key) == null } + .values + .sorted() + + val trackers = backup.backupManga + .flatMap { it.tracking } + .map { it.syncId } + .distinct() + val missingTrackers = trackers + .mapNotNull { trackManager.getService(it) } + .filter { !it.isLogged } + .map { context.getString(it.nameRes()) } + .sorted() + + return Results(missingSources, missingTrackers) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/Backup.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/Backup.kt new file mode 100644 index 0000000000..b938639e7d --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/Backup.kt @@ -0,0 +1,12 @@ +package eu.kanade.tachiyomi.data.backup.full.models + +import kotlinx.serialization.Serializable +import kotlinx.serialization.protobuf.ProtoNumber + +@Serializable +data class Backup( + @ProtoNumber(1) val backupManga: List, + @ProtoNumber(2) var backupCategories: List = emptyList(), + // Bump by 100 to specify this is a 0.x value + @ProtoNumber(100) var backupSources: List = emptyList(), +) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupCategory.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupCategory.kt new file mode 100644 index 0000000000..e1d543eda1 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupCategory.kt @@ -0,0 +1,33 @@ +package eu.kanade.tachiyomi.data.backup.full.models + +import eu.kanade.tachiyomi.data.database.models.Category +import eu.kanade.tachiyomi.data.database.models.CategoryImpl +import kotlinx.serialization.Serializable +import kotlinx.serialization.protobuf.ProtoNumber + +@Serializable +class BackupCategory( + @ProtoNumber(1) var name: String, + @ProtoNumber(2) var order: Int = 0, + // @ProtoNumber(3) val updateInterval: Int = 0, 1.x value not used in 0.x + // Bump by 100 to specify this is a 0.x value + @ProtoNumber(100) var flags: Int = 0, +) { + fun getCategoryImpl(): CategoryImpl { + return CategoryImpl().apply { + name = this@BackupCategory.name + flags = this@BackupCategory.flags + order = this@BackupCategory.order + } + } + + companion object { + fun copyFrom(category: Category): BackupCategory { + return BackupCategory( + name = category.name, + order = category.order, + flags = category.flags + ) + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupChapter.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupChapter.kt new file mode 100644 index 0000000000..05111123b9 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupChapter.kt @@ -0,0 +1,56 @@ +package eu.kanade.tachiyomi.data.backup.full.models + +import eu.kanade.tachiyomi.data.database.models.Chapter +import eu.kanade.tachiyomi.data.database.models.ChapterImpl +import kotlinx.serialization.Serializable +import kotlinx.serialization.protobuf.ProtoNumber + +@Serializable +data class BackupChapter( + // in 1.x some of these values have different names + // url is called key in 1.x + @ProtoNumber(1) var url: String, + @ProtoNumber(2) var name: String, + @ProtoNumber(3) var scanlator: String? = null, + @ProtoNumber(4) var read: Boolean = false, + @ProtoNumber(5) var bookmark: Boolean = false, + // lastPageRead is called progress in 1.x + @ProtoNumber(6) var lastPageRead: Int = 0, + @ProtoNumber(7) var dateFetch: Long = 0, + @ProtoNumber(8) var dateUpload: Long = 0, + // chapterNumber is called number is 1.x + @ProtoNumber(9) var chapterNumber: Float = 0F, + @ProtoNumber(10) var sourceOrder: Int = 0, +) { + fun toChapterImpl(): ChapterImpl { + return ChapterImpl().apply { + url = this@BackupChapter.url + name = this@BackupChapter.name + chapter_number = this@BackupChapter.chapterNumber + scanlator = this@BackupChapter.scanlator + read = this@BackupChapter.read + bookmark = this@BackupChapter.bookmark + last_page_read = this@BackupChapter.lastPageRead + date_fetch = this@BackupChapter.dateFetch + date_upload = this@BackupChapter.dateUpload + source_order = this@BackupChapter.sourceOrder + } + } + + companion object { + fun copyFrom(chapter: Chapter): BackupChapter { + return BackupChapter( + url = chapter.url, + name = chapter.name, + chapterNumber = chapter.chapter_number, + scanlator = chapter.scanlator, + read = chapter.read, + bookmark = chapter.bookmark, + lastPageRead = chapter.last_page_read, + dateFetch = chapter.date_fetch, + dateUpload = chapter.date_upload, + sourceOrder = chapter.source_order + ) + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupFull.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupFull.kt new file mode 100644 index 0000000000..3ed6328b06 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupFull.kt @@ -0,0 +1,12 @@ +package eu.kanade.tachiyomi.data.backup.full.models + +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +object BackupFull { + fun getDefaultFilename(): String { + val date = SimpleDateFormat("yyyy-MM-dd_HH-mm", Locale.getDefault()).format(Date()) + return "tachiyomi_$date.proto.gz" + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupHistory.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupHistory.kt new file mode 100644 index 0000000000..3cbfbc622b --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupHistory.kt @@ -0,0 +1,10 @@ +package eu.kanade.tachiyomi.data.backup.full.models + +import kotlinx.serialization.Serializable +import kotlinx.serialization.protobuf.ProtoNumber + +@Serializable +data class BackupHistory( + @ProtoNumber(0) var url: String, + @ProtoNumber(1) var lastRead: Long +) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupManga.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupManga.kt new file mode 100644 index 0000000000..8e863ad745 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupManga.kt @@ -0,0 +1,87 @@ +package eu.kanade.tachiyomi.data.backup.full.models + +import eu.kanade.tachiyomi.data.database.models.ChapterImpl +import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.data.database.models.MangaImpl +import eu.kanade.tachiyomi.data.database.models.TrackImpl +import kotlinx.serialization.Serializable +import kotlinx.serialization.protobuf.ProtoNumber + +@Serializable +data class BackupManga( + // in 1.x some of these values have different names + @ProtoNumber(1) var source: Long, + // url is called key in 1.x + @ProtoNumber(2) var url: String, + @ProtoNumber(3) var title: String = "", + @ProtoNumber(4) var artist: String? = null, + @ProtoNumber(5) var author: String? = null, + @ProtoNumber(6) var description: String? = null, + @ProtoNumber(7) var genre: List = emptyList(), + @ProtoNumber(8) var status: Int = 0, + // thumbnailUrl is called cover in 1.x + @ProtoNumber(9) var thumbnailUrl: String? = null, + // @ProtoNumber(10) val customCover: String = "", 1.x value, not used in 0.x + // @ProtoNumber(11) val lastUpdate: Long = 0, 1.x value, not used in 0.x + // @ProtoNumber(12) val lastInit: Long = 0, 1.x value, not used in 0.x + @ProtoNumber(13) var dateAdded: Long = 0, + @ProtoNumber(14) var viewer: Int = 0, + // @ProtoNumber(15) val flags: Int = 0, 1.x value, not used in 0.x + @ProtoNumber(16) var chapters: List = emptyList(), + @ProtoNumber(17) var categories: List = emptyList(), + @ProtoNumber(18) var tracking: List = emptyList(), + // Bump by 100 for values that are not saved/implemented in 1.x but are used in 0.x + @ProtoNumber(100) var favorite: Boolean = true, + @ProtoNumber(101) var chapterFlags: Int = 0, + @ProtoNumber(102) var history: List = emptyList(), +) { + fun getMangaImpl(): MangaImpl { + return MangaImpl().apply { + url = this@BackupManga.url + title = this@BackupManga.title + artist = this@BackupManga.artist + author = this@BackupManga.author + description = this@BackupManga.description + genre = this@BackupManga.genre.joinToString() + status = this@BackupManga.status + thumbnail_url = this@BackupManga.thumbnailUrl + favorite = this@BackupManga.favorite + source = this@BackupManga.source + date_added = this@BackupManga.dateAdded + viewer = this@BackupManga.viewer + chapter_flags = this@BackupManga.chapterFlags + } + } + + fun getChaptersImpl(): List { + return chapters.map { + it.toChapterImpl() + } + } + + fun getTrackingImpl(): List { + return tracking.map { + it.getTrackingImpl() + } + } + + companion object { + fun copyFrom(manga: Manga): BackupManga { + return BackupManga( + url = manga.url, + title = manga.title, + artist = manga.artist, + author = manga.author, + description = manga.description, + genre = manga.getGenres() ?: emptyList(), + status = manga.status, + thumbnailUrl = manga.thumbnail_url, + favorite = manga.favorite, + source = manga.source, + dateAdded = manga.date_added, + viewer = manga.viewer, + chapterFlags = manga.chapter_flags + ) + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupSerializer.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupSerializer.kt new file mode 100644 index 0000000000..55b1c6afc6 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupSerializer.kt @@ -0,0 +1,6 @@ +package eu.kanade.tachiyomi.data.backup.full.models + +import kotlinx.serialization.Serializer + +@Serializer(forClass = Backup::class) +object BackupSerializer diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupSource.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupSource.kt new file mode 100644 index 0000000000..78b993373a --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupSource.kt @@ -0,0 +1,20 @@ +package eu.kanade.tachiyomi.data.backup.full.models + +import eu.kanade.tachiyomi.source.Source +import kotlinx.serialization.Serializable +import kotlinx.serialization.protobuf.ProtoNumber + +@Serializable +data class BackupSource( + @ProtoNumber(0) var name: String = "", + @ProtoNumber(1) var sourceId: Long +) { + companion object { + fun copyFrom(source: Source): BackupSource { + return BackupSource( + name = source.name, + sourceId = source.id + ) + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupTracking.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupTracking.kt new file mode 100644 index 0000000000..734bebdb98 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupTracking.kt @@ -0,0 +1,65 @@ +package eu.kanade.tachiyomi.data.backup.full.models + +import eu.kanade.tachiyomi.data.database.models.Track +import eu.kanade.tachiyomi.data.database.models.TrackImpl +import kotlinx.serialization.Serializable +import kotlinx.serialization.protobuf.ProtoNumber + +@Serializable +data class BackupTracking( + // in 1.x some of these values have different types or names + // syncId is called siteId in 1,x + @ProtoNumber(1) var syncId: Int, + // LibraryId is not null in 1.x + @ProtoNumber(2) var libraryId: Long, + @ProtoNumber(3) var mediaId: Int = 0, + // trackingUrl is called mediaUrl in 1.x + @ProtoNumber(4) var trackingUrl: String = "", + @ProtoNumber(5) var title: String = "", + // lastChapterRead is called last read, and it has been changed to a float in 1.x + @ProtoNumber(6) var lastChapterRead: Float = 0F, + @ProtoNumber(7) var totalChapters: Int = 0, + @ProtoNumber(8) var score: Float = 0F, + @ProtoNumber(9) var status: Int = 0, + // startedReadingDate is called startReadTime in 1.x + // @ProtoNumber(10) var startedReadingDate: Long = 0, + // finishedReadingDate is called endReadTime in 1.x + // @ProtoNumber(11) var finishedReadingDate: Long = 0, +) { + fun getTrackingImpl(): TrackImpl { + return TrackImpl().apply { + sync_id = this@BackupTracking.syncId + media_id = this@BackupTracking.mediaId + library_id = this@BackupTracking.libraryId + title = this@BackupTracking.title + // convert from float to int because of 1.x types + last_chapter_read = this@BackupTracking.lastChapterRead.toInt() + total_chapters = this@BackupTracking.totalChapters + score = this@BackupTracking.score + status = this@BackupTracking.status + // started_reading_date = this@BackupTracking.startedReadingDate + // finished_reading_date = this@BackupTracking.finishedReadingDate + tracking_url = this@BackupTracking.trackingUrl + } + } + + companion object { + fun copyFrom(track: Track): BackupTracking { + return BackupTracking( + syncId = track.sync_id, + mediaId = track.media_id, + // forced not null so its compatible with 1.x backup system + libraryId = track.library_id!!, + title = track.title, + // convert to float for 1.x + lastChapterRead = track.last_chapter_read.toFloat(), + totalChapters = track.total_chapters, + score = track.score, + status = track.status, + // startedReadingDate = track.started_reading_date, + // finishedReadingDate = track.finished_reading_date, + trackingUrl = track.tracking_url + ) + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupManager.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/LegacyBackupManager.kt similarity index 63% rename from app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupManager.kt rename to app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/LegacyBackupManager.kt index 882c67e9e9..eca634f1f4 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/LegacyBackupManager.kt @@ -1,4 +1,4 @@ -package eu.kanade.tachiyomi.data.backup +package eu.kanade.tachiyomi.data.backup.legacy import android.content.Context import android.net.Uri @@ -12,6 +12,7 @@ import com.google.gson.JsonArray import com.google.gson.JsonElement import com.google.gson.JsonObject import com.hippo.unifile.UniFile +import eu.kanade.tachiyomi.data.backup.AbstractBackupManager 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 @@ -20,21 +21,20 @@ import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_HIST 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 -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.EXTENSIONS -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.CategoryTypeAdapter -import eu.kanade.tachiyomi.data.backup.serializer.ChapterTypeAdapter -import eu.kanade.tachiyomi.data.backup.serializer.HistoryTypeAdapter -import eu.kanade.tachiyomi.data.backup.serializer.MangaTypeAdapter -import eu.kanade.tachiyomi.data.backup.serializer.TrackTypeAdapter -import eu.kanade.tachiyomi.data.database.DatabaseHelper +import eu.kanade.tachiyomi.data.backup.legacy.models.Backup +import eu.kanade.tachiyomi.data.backup.legacy.models.Backup.CATEGORIES +import eu.kanade.tachiyomi.data.backup.legacy.models.Backup.CHAPTERS +import eu.kanade.tachiyomi.data.backup.legacy.models.Backup.CURRENT_VERSION +import eu.kanade.tachiyomi.data.backup.legacy.models.Backup.EXTENSIONS +import eu.kanade.tachiyomi.data.backup.legacy.models.Backup.HISTORY +import eu.kanade.tachiyomi.data.backup.legacy.models.Backup.MANGA +import eu.kanade.tachiyomi.data.backup.legacy.models.Backup.TRACK +import eu.kanade.tachiyomi.data.backup.legacy.models.DHistory +import eu.kanade.tachiyomi.data.backup.legacy.serializer.CategoryTypeAdapter +import eu.kanade.tachiyomi.data.backup.legacy.serializer.ChapterTypeAdapter +import eu.kanade.tachiyomi.data.backup.legacy.serializer.HistoryTypeAdapter +import eu.kanade.tachiyomi.data.backup.legacy.serializer.MangaTypeAdapter +import eu.kanade.tachiyomi.data.backup.legacy.serializer.TrackTypeAdapter import eu.kanade.tachiyomi.data.database.models.CategoryImpl import eu.kanade.tachiyomi.data.database.models.Chapter import eu.kanade.tachiyomi.data.database.models.ChapterImpl @@ -44,65 +44,16 @@ import eu.kanade.tachiyomi.data.database.models.MangaCategory import eu.kanade.tachiyomi.data.database.models.MangaImpl import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.database.models.TrackImpl -import eu.kanade.tachiyomi.data.preference.PreferencesHelper -import eu.kanade.tachiyomi.data.preference.getOrDefault -import eu.kanade.tachiyomi.data.track.TrackManager +import eu.kanade.tachiyomi.data.database.models.toMangaInfo +import eu.kanade.tachiyomi.source.LocalSource import eu.kanade.tachiyomi.source.Source -import eu.kanade.tachiyomi.source.SourceManager -import eu.kanade.tachiyomi.source.fetchMangaDetailsAsync -import eu.kanade.tachiyomi.util.chapter.syncChaptersWithSource -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import rx.Observable +import eu.kanade.tachiyomi.source.model.toSManga import timber.log.Timber -import uy.kohesive.injekt.injectLazy import kotlin.math.max -class BackupManager(val context: Context, version: Int = CURRENT_VERSION) { +class LegacyBackupManager(context: Context, version: Int = CURRENT_VERSION) : AbstractBackupManager(context) { - /** - * Database. - */ - internal val databaseHelper: DatabaseHelper by injectLazy() - - /** - * Source manager. - */ - internal val sourceManager: SourceManager by injectLazy() - - /** - * Tracking manager - */ - internal val trackManager: TrackManager 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 = when (version) { - 1 -> GsonBuilder().create() + val parser: Gson = when (version) { 2 -> GsonBuilder() .registerTypeAdapter(MangaTypeAdapter.build()) @@ -111,7 +62,7 @@ class BackupManager(val context: Context, version: Int = CURRENT_VERSION) { .registerTypeAdapter(HistoryTypeAdapter.build()) .registerTypeHierarchyAdapter(TrackTypeAdapter.build()) .create() - else -> throw Exception("Json version unknown") + else -> throw Exception("Unknown backup version") } /** @@ -120,7 +71,7 @@ class BackupManager(val context: Context, version: Int = CURRENT_VERSION) { * @param uri path of Uri * @param isJob backup called from job */ - fun createBackup(uri: Uri, flags: Int, isJob: Boolean): String? { + override fun createBackup(uri: Uri, flags: Int, isJob: Boolean): String? { // Create root object val root = JsonObject() @@ -140,7 +91,6 @@ class BackupManager(val context: Context, version: Int = CURRENT_VERSION) { root[EXTENSIONS] = extensionEntries databaseHelper.inTransaction { - // Get manga from database val mangas = getFavoriteManga() val extensions: MutableSet = mutableSetOf() @@ -150,7 +100,7 @@ class BackupManager(val context: Context, version: Int = CURRENT_VERSION) { mangaEntries.add(backupMangaObject(manga, flags)) // Maintain set of extensions/sources used (excludes local source) - if (manga.source != 0L) { + if (manga.source != LocalSource.ID) { sourceManager.get(manga.source)?.let { extensions.add("${manga.source}:${it.name}") } @@ -167,39 +117,33 @@ class BackupManager(val context: Context, version: Int = CURRENT_VERSION) { } try { - // When BackupCreatorJob - if (isJob) { - // Get dir of file and create - var dir = UniFile.fromUri(context, uri) - dir = dir.createDirectory("automatic") + val file: UniFile = ( + if (isJob) { + // Get dir of file and create + var dir = UniFile.fromUri(context, uri) + dir = dir.createDirectory("automatic") - // Delete older backups - val numberOfBackups = 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() } + // Delete older backups + val numberOfBackups = 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 { - parser.toJson(root, it) + // Create new file to place backup + dir.createFile(Backup.getDefaultFilename()) + } else { + UniFile.fromUri(context, uri) } + ) + ?: throw Exception("Couldn't create backup file") - return newFile.uri.toString() - } else { - val file = UniFile.fromUri(context, uri) - ?: throw Exception("Couldn't create backup file") - file.openOutputStream().bufferedWriter().use { - parser.toJson(root, it) - } - - return file.uri.toString() + file.openOutputStream().bufferedWriter().use { + parser.toJson(root, it) } + return file.uri.toString() } catch (e: Exception) { Timber.e(e) throw e @@ -291,57 +235,22 @@ class BackupManager(val context: Context, version: Int = CURRENT_VERSION) { } /** - * [Observable] that fetches manga information + * Fetches manga information * * @param source source of manga * @param manga manga that needs updating - * @return [Observable] that contains manga + * @return Updated manga. */ - suspend fun restoreMangaFetch(source: Source, manga: Manga): Manga { - return withContext(Dispatchers.IO) { - val networkManga = source.fetchMangaDetailsAsync(manga)!! - manga.copyFrom(networkManga) - manga.favorite = true - manga.initialized = true - manga.id = insertManga(manga) - manga + suspend fun fetchManga(source: Source, manga: Manga): Manga { + val networkManga = source.getMangaDetails(manga.toMangaInfo()) + return manga.also { + it.copyFrom(networkManga.toSManga()) + it.favorite = true + it.initialized = true + it.id = insertManga(manga) } } - /** - * [Observable] that fetches chapter information - * - * @param source source of manga - * @param manga manga that needs updating - * @return [Observable] that contains manga - */ - suspend fun restoreChapterFetch(source: Source, manga: Manga, chapters: List) { - withContext(Dispatchers.IO) { - val fetchChapters = source.fetchChapterList(manga).toBlocking().single() - val syncChaptersWithSource = - syncChaptersWithSource(databaseHelper, fetchChapters, manga, source) - if (syncChaptersWithSource.first.isNotEmpty()) { - chapters.forEach { it.manga_id = manga.id } - insertChapters(chapters) - } - } - } - - /** - * Restore extensions list from json and map them - */ - internal fun getExtensionsMap(extensions: JsonArray): Map { - val extensionsList = parser.fromJson>(extensions) - return extensionsList.mapNotNull { - val split = it.split(":") - if (split.size == 2) { - split.first() to split.last() - } else { - null - } - }.toMap() - } - /** * Restore the categories from Json * @@ -359,7 +268,7 @@ class BackupManager(val context: Context, version: Int = CURRENT_VERSION) { 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) { + if (category.name == dbCategory.name) { category.id = dbCategory.id found = true break @@ -384,10 +293,10 @@ class BackupManager(val context: Context, version: Int = CURRENT_VERSION) { */ internal fun restoreCategoriesForManga(manga: Manga, categories: List) { val dbCategories = databaseHelper.getCategories().executeAsBlocking() - val mangaCategoriesToUpdate = ArrayList() + val mangaCategoriesToUpdate = ArrayList(categories.size) for (backupCategoryStr in categories) { for (dbCategory in dbCategories) { - if (backupCategoryStr.toLowerCase() == dbCategory.nameLower) { + if (backupCategoryStr == dbCategory.name) { mangaCategoriesToUpdate.add(MangaCategory.create(manga, dbCategory)) break } @@ -396,9 +305,7 @@ class BackupManager(val context: Context, version: Int = CURRENT_VERSION) { // Update database if (mangaCategoriesToUpdate.isNotEmpty()) { - val mangaAsList = ArrayList() - mangaAsList.add(manga) - databaseHelper.deleteOldMangasCategories(mangaAsList).executeAsBlocking() + databaseHelper.deleteOldMangasCategories(listOf(manga)).executeAsBlocking() databaseHelper.insertMangasCategories(mangaCategoriesToUpdate).executeAsBlocking() } } @@ -410,7 +317,7 @@ class BackupManager(val context: Context, version: Int = CURRENT_VERSION) { */ internal fun restoreHistoryForManga(history: List) { // List containing history to be updated - val historyToBeUpdated = ArrayList() + val historyToBeUpdated = ArrayList(history.size) for ((url, lastRead) in history) { val dbHistory = databaseHelper.getHistoryByChapterUrl(url).executeAsBlocking() // Check if history already in database and update @@ -439,14 +346,14 @@ class BackupManager(val context: Context, version: Int = CURRENT_VERSION) { * @param tracks the track list to restore. */ internal fun restoreTrackForManga(manga: Manga, tracks: List) { - // Fix foreign keys with the current manga id - tracks.map { it.manga_id = manga.id!! } - // Get tracks from database val dbTracks = databaseHelper.getTracks(manga).executeAsBlocking() - val trackToUpdate = ArrayList() + val trackToUpdate = ArrayList(tracks.size) + + tracks.forEach { track -> + // Fix foreign keys with the current manga id + track.manga_id = manga.id!! - for (track in tracks) { val service = trackManager.getService(track.sync_id) if (service != null && service.isLogged) { var isInDatabase = false @@ -489,8 +396,9 @@ class BackupManager(val context: Context, version: Int = CURRENT_VERSION) { val dbChapters = databaseHelper.getChapters(manga).executeAsBlocking() // Return if fetch is needed - if (dbChapters.isEmpty() || dbChapters.size < chapters.size) + if (dbChapters.isEmpty() || dbChapters.size < chapters.size) { return false + } for (chapter in chapters) { val pos = dbChapters.indexOf(chapter) @@ -500,50 +408,13 @@ class BackupManager(val context: Context, version: Int = CURRENT_VERSION) { 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) + chapter.manga_id = manga.id + } + + // Filter the chapters that couldn't be found. + updateChapters(chapters.filter { it.id != null }) + return true } - - /** - * Returns manga - * - * @return [Manga], null if not found - */ - internal fun getMangaFromDatabase(manga: Manga): Manga? = - databaseHelper.getManga(manga.url, manga.source).executeAsBlocking() - - /** - * Returns list containing manga from library - * - * @return [Manga] from library - */ - internal fun getFavoriteManga(): List = - databaseHelper.getFavoriteMangas().executeAsBlocking() - - /** - * Inserts manga and returns id - * - * @return id of [Manga], null if not found - */ - internal fun insertManga(manga: Manga): Long? = - databaseHelper.insertManga(manga).executeAsBlocking().insertedId() - - /** - * Inserts list of chapters - */ - private fun insertChapters(chapters: List) { - databaseHelper.updateChaptersBackup(chapters).executeAsBlocking() - } - - /** - * Return number of backups. - * - * @return number of backups selected by user - */ - fun numberOfBackups(): Int = preferences.numberOfBackups().getOrDefault() } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/LegacyBackupRestore.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/LegacyBackupRestore.kt new file mode 100644 index 0000000000..5607f10403 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/LegacyBackupRestore.kt @@ -0,0 +1,194 @@ +package eu.kanade.tachiyomi.data.backup.legacy + +import android.content.Context +import android.net.Uri +import com.github.salomonbrys.kotson.fromJson +import com.google.gson.JsonArray +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import com.google.gson.stream.JsonReader +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.backup.AbstractBackupRestore +import eu.kanade.tachiyomi.data.backup.BackupNotifier +import eu.kanade.tachiyomi.data.backup.legacy.models.Backup +import eu.kanade.tachiyomi.data.backup.legacy.models.Backup.MANGAS +import eu.kanade.tachiyomi.data.backup.legacy.models.DHistory +import eu.kanade.tachiyomi.data.database.models.Chapter +import eu.kanade.tachiyomi.data.database.models.ChapterImpl +import eu.kanade.tachiyomi.data.database.models.Manga +import eu.kanade.tachiyomi.data.database.models.MangaImpl +import eu.kanade.tachiyomi.data.database.models.Track +import eu.kanade.tachiyomi.data.database.models.TrackImpl +import eu.kanade.tachiyomi.source.Source +import java.util.Date + +class LegacyBackupRestore(context: Context, notifier: BackupNotifier) : AbstractBackupRestore(context, notifier) { + + override suspend fun performRestore(uri: Uri): Boolean { + val reader = JsonReader(context.contentResolver.openInputStream(uri)!!.bufferedReader()) + val json = JsonParser.parseReader(reader).asJsonObject + + val version = json.get(Backup.VERSION)?.asInt ?: 1 + backupManager = LegacyBackupManager(context, version) + + val mangasJson = json.get(MANGAS).asJsonArray + restoreAmount = mangasJson.size() + 1 // +1 for categories + + // Restore categories + json.get(Backup.CATEGORIES)?.let { restoreCategories(it) } + + // Store source mapping for error messages + sourceMapping = LegacyBackupRestoreValidator.getSourceMapping(json) + + // Restore individual manga + mangasJson.forEach { + if (job?.isActive != true) { + return false + } + + restoreManga(it.asJsonObject) + } + + return true + } + + private fun restoreCategories(categoriesJson: JsonElement) { + db.inTransaction { + backupManager.restoreCategories(categoriesJson.asJsonArray) + } + + restoreProgress += 1 + showRestoreProgress(restoreProgress, restoreAmount, context.getString(R.string.categories)) + } + + private suspend fun restoreManga(mangaJson: JsonObject) { + val manga = backupManager.parser.fromJson( + mangaJson.get( + Backup.MANGA + ) + ) + val chapters = backupManager.parser.fromJson>( + mangaJson.get(Backup.CHAPTERS) + ?: JsonArray() + ) + val categories = backupManager.parser.fromJson>( + mangaJson.get(Backup.CATEGORIES) + ?: JsonArray() + ) + val history = backupManager.parser.fromJson>( + mangaJson.get(Backup.HISTORY) + ?: JsonArray() + ) + val tracks = backupManager.parser.fromJson>( + mangaJson.get(Backup.TRACK) + ?: JsonArray() + ) + + val source = backupManager.sourceManager.get(manga.source) + val sourceName = sourceMapping[manga.source] ?: manga.source.toString() + + try { + if (source != null) { + restoreMangaData(manga, source, chapters, categories, history, tracks) + } else { + errors.add(Date() to "${manga.title} [$sourceName]: ${context.getString(R.string.source_not_installed_, sourceName)}") + } + } catch (e: Exception) { + errors.add(Date() to "${manga.title} [$sourceName]: ${e.message}") + } + + restoreProgress += 1 + showRestoreProgress(restoreProgress, restoreAmount, manga.title) + } + + /** + * Returns a manga restore observable + * + * @param manga manga data from json + * @param source source to get manga data from + * @param chapters chapters data from json + * @param categories categories data from json + * @param history history data from json + * @param tracks tracking data from json + */ + private suspend fun restoreMangaData( + manga: Manga, + source: Source, + chapters: List, + categories: List, + history: List, + tracks: List + ) { + val dbManga = backupManager.getMangaFromDatabase(manga) + + db.inTransaction { + if (dbManga == null) { + // Manga not in database + restoreMangaFetch(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 + restoreMangaNoFetch(source, manga, chapters, categories, history, tracks) + } + } + } + + /** + * Fetches manga information. + * + * @param manga manga that needs updating + * @param chapters chapters of manga that needs updating + * @param categories categories that need updating + */ + private suspend fun restoreMangaFetch( + source: Source, + manga: Manga, + chapters: List, + categories: List, + history: List, + tracks: List + ) { + try { + val fetchedManga = backupManager.fetchManga(source, manga) + fetchedManga.id ?: return + + updateChapters(source, fetchedManga, chapters) + + restoreExtraForManga(fetchedManga, categories, history, tracks) + + updateTracking(fetchedManga, tracks) + } catch (e: Exception) { + errors.add(Date() to "${manga.title} - ${e.message}") + } + } + + private suspend fun restoreMangaNoFetch( + source: Source, + backupManga: Manga, + chapters: List, + categories: List, + history: List, + tracks: List + ) { + if (!backupManager.restoreChaptersForManga(backupManga, chapters)) { + updateChapters(source, backupManga, chapters) + } + + restoreExtraForManga(backupManga, categories, history, tracks) + + updateTracking(backupManga, tracks) + } + + private fun restoreExtraForManga(manga: Manga, categories: List, history: List, tracks: List) { + // Restore categories + backupManager.restoreCategoriesForManga(manga, categories) + + // Restore history + backupManager.restoreHistoryForManga(history) + + // Restore tracking + backupManager.restoreTrackForManga(manga, tracks) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/LegacyBackupRestoreValidator.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/LegacyBackupRestoreValidator.kt new file mode 100644 index 0000000000..0398cc4d4b --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/LegacyBackupRestoreValidator.kt @@ -0,0 +1,66 @@ +package eu.kanade.tachiyomi.data.backup.legacy + +import android.content.Context +import android.net.Uri +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import com.google.gson.stream.JsonReader +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.backup.AbstractBackupRestoreValidator +import eu.kanade.tachiyomi.data.backup.legacy.models.Backup + +class LegacyBackupRestoreValidator : AbstractBackupRestoreValidator() { + /** + * Checks for critical backup file data. + * + * @throws Exception if version or manga cannot be found. + * @return List of missing sources or missing trackers. + */ + override fun validate(context: Context, uri: Uri): Results { + val reader = JsonReader(context.contentResolver.openInputStream(uri)!!.bufferedReader()) + val json = JsonParser.parseReader(reader).asJsonObject + + val version = json.get(Backup.VERSION) + val mangasJson = json.get(Backup.MANGAS) + if (version == null || mangasJson == null) { + throw Exception(context.getString(R.string.file_is_missing_data)) + } + + val mangas = mangasJson.asJsonArray + if (mangas.size() == 0) { + throw Exception(context.getString(R.string.backup_has_no_manga)) + } + + val sources = getSourceMapping(json) + val missingSources = sources + .filter { sourceManager.get(it.key) == null } + .values + .sorted() + + val trackers = mangas + .filter { it.asJsonObject.has("track") } + .flatMap { it.asJsonObject["track"].asJsonArray } + .map { it.asJsonObject["s"].asInt } + .distinct() + val missingTrackers = trackers + .mapNotNull { trackManager.getService(it) } + .filter { !it.isLogged } + .map { context.getString(it.nameRes()) } + .sorted() + + return Results(missingSources, missingTrackers) + } + + companion object { + fun getSourceMapping(json: JsonObject): Map { + val extensionsMapping = json.get(Backup.EXTENSIONS) ?: return emptyMap() + + return extensionsMapping.asJsonArray + .map { + val items = it.asString.split(":") + items[0].toLong() to items[1] + } + .toMap() + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/Backup.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/models/Backup.kt similarity index 91% rename from app/src/main/java/eu/kanade/tachiyomi/data/backup/models/Backup.kt rename to app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/models/Backup.kt index 917f27754a..32dfa9245c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/Backup.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/models/Backup.kt @@ -1,4 +1,4 @@ -package eu.kanade.tachiyomi.data.backup.models +package eu.kanade.tachiyomi.data.backup.legacy.models import java.text.SimpleDateFormat import java.util.Date diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/DHistory.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/models/DHistory.kt similarity index 51% rename from app/src/main/java/eu/kanade/tachiyomi/data/backup/models/DHistory.kt rename to app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/models/DHistory.kt index a5e1c1a0f3..9a0ea06609 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/DHistory.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/models/DHistory.kt @@ -1,3 +1,3 @@ -package eu.kanade.tachiyomi.data.backup.models +package eu.kanade.tachiyomi.data.backup.legacy.models data class DHistory(val url: String, val lastRead: Long) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/CategoryTypeAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/serializer/CategoryTypeAdapter.kt similarity index 92% rename from app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/CategoryTypeAdapter.kt rename to app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/serializer/CategoryTypeAdapter.kt index 1beb5d9798..d346af19cf 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/CategoryTypeAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/serializer/CategoryTypeAdapter.kt @@ -1,4 +1,4 @@ -package eu.kanade.tachiyomi.data.backup.serializer +package eu.kanade.tachiyomi.data.backup.legacy.serializer import com.github.salomonbrys.kotson.typeAdapter import com.google.gson.TypeAdapter diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/ChapterTypeAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/serializer/ChapterTypeAdapter.kt similarity index 96% rename from app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/ChapterTypeAdapter.kt rename to app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/serializer/ChapterTypeAdapter.kt index 9bd6e8e1e6..cacc8cb25b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/ChapterTypeAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/serializer/ChapterTypeAdapter.kt @@ -1,4 +1,4 @@ -package eu.kanade.tachiyomi.data.backup.serializer +package eu.kanade.tachiyomi.data.backup.legacy.serializer import com.github.salomonbrys.kotson.typeAdapter import com.google.gson.TypeAdapter diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/HistoryTypeAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/serializer/HistoryTypeAdapter.kt similarity index 85% rename from app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/HistoryTypeAdapter.kt rename to app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/serializer/HistoryTypeAdapter.kt index 863a1a1f30..4f7d5d9ff1 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/HistoryTypeAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/serializer/HistoryTypeAdapter.kt @@ -1,8 +1,8 @@ -package eu.kanade.tachiyomi.data.backup.serializer +package eu.kanade.tachiyomi.data.backup.legacy.serializer import com.github.salomonbrys.kotson.typeAdapter import com.google.gson.TypeAdapter -import eu.kanade.tachiyomi.data.backup.models.DHistory +import eu.kanade.tachiyomi.data.backup.legacy.models.DHistory /** * JSON Serializer used to write / read [DHistory] to / from json diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/MangaTypeAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/serializer/MangaTypeAdapter.kt similarity index 85% rename from app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/MangaTypeAdapter.kt rename to app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/serializer/MangaTypeAdapter.kt index e10adc2fab..b902cbb5b7 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/MangaTypeAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/serializer/MangaTypeAdapter.kt @@ -1,9 +1,8 @@ -package eu.kanade.tachiyomi.data.backup.serializer +package eu.kanade.tachiyomi.data.backup.legacy.serializer import com.github.salomonbrys.kotson.typeAdapter import com.google.gson.TypeAdapter import eu.kanade.tachiyomi.data.database.models.MangaImpl -import kotlin.math.max /** * JSON Serializer used to write / read [MangaImpl] to / from json @@ -15,9 +14,9 @@ object MangaTypeAdapter { write { beginArray() value(it.url) - value(it.originalTitle) + value(it.title) value(it.source) - value(max(0, it.viewer)) + value(it.viewer) value(it.chapter_flags) endArray() } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/TrackTypeAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/serializer/TrackTypeAdapter.kt similarity index 96% rename from app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/TrackTypeAdapter.kt rename to app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/serializer/TrackTypeAdapter.kt index de78b8c115..84c0cd829d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/TrackTypeAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/serializer/TrackTypeAdapter.kt @@ -1,4 +1,4 @@ -package eu.kanade.tachiyomi.data.backup.serializer +package eu.kanade.tachiyomi.data.backup.legacy.serializer import com.github.salomonbrys.kotson.typeAdapter import com.google.gson.TypeAdapter diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Manga.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Manga.kt index 403c0fe3e0..f68d78aaa6 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Manga.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Manga.kt @@ -6,6 +6,7 @@ import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.ui.reader.ReaderActivity import eu.kanade.tachiyomi.util.storage.DiskUtil +import tachiyomi.source.model.MangaInfo import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get import java.util.Locale @@ -64,6 +65,10 @@ interface Manga : SManga { ).toLowerCase(Locale.getDefault()) } + fun getGenres(): List? { + return genre?.split(", ")?.map { it.trim() } + } + /** * The type of comic the manga is (ie. manga, manhwa, manhua) */ @@ -221,3 +226,16 @@ interface Manga : SManga { } } } + +fun Manga.toMangaInfo(): MangaInfo { + return MangaInfo( + artist = this.artist ?: "", + author = this.author ?: "", + cover = this.thumbnail_url ?: "", + description = this.description ?: "", + genres = this.getGenres() ?: emptyList(), + key = this.url, + status = this.status, + title = this.title + ) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/ChapterQueries.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/ChapterQueries.kt index 10aacb08f1..dd98b3f7e7 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/ChapterQueries.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/queries/ChapterQueries.kt @@ -8,6 +8,7 @@ import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.database.models.MangaChapter import eu.kanade.tachiyomi.data.database.models.MangaChapterHistory import eu.kanade.tachiyomi.data.database.resolvers.ChapterBackupPutResolver +import eu.kanade.tachiyomi.data.database.resolvers.ChapterKnownBackupPutResolver import eu.kanade.tachiyomi.data.database.resolvers.ChapterProgressPutResolver import eu.kanade.tachiyomi.data.database.resolvers.ChapterSourceOrderPutResolver import eu.kanade.tachiyomi.data.database.resolvers.MangaChapterGetResolver @@ -110,6 +111,11 @@ interface ChapterQueries : DbProvider { .withPutResolver(ChapterBackupPutResolver()) .prepare() + fun updateKnownChaptersBackup(chapters: List) = db.put() + .objects(chapters) + .withPutResolver(ChapterKnownBackupPutResolver()) + .prepare() + fun updateChapterProgress(chapter: Chapter) = db.put() .`object`(chapter) .withPutResolver(ChapterProgressPutResolver()) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/ChapterKnownBackupPutResolver.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/ChapterKnownBackupPutResolver.kt new file mode 100644 index 0000000000..5b8882e115 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/ChapterKnownBackupPutResolver.kt @@ -0,0 +1,34 @@ +package eu.kanade.tachiyomi.data.database.resolvers + +import androidx.core.content.contentValuesOf +import com.pushtorefresh.storio.sqlite.StorIOSQLite +import com.pushtorefresh.storio.sqlite.operations.put.PutResolver +import com.pushtorefresh.storio.sqlite.operations.put.PutResult +import com.pushtorefresh.storio.sqlite.queries.UpdateQuery +import eu.kanade.tachiyomi.data.database.inTransactionReturn +import eu.kanade.tachiyomi.data.database.models.Chapter +import eu.kanade.tachiyomi.data.database.tables.ChapterTable + +class ChapterKnownBackupPutResolver : PutResolver() { + + override fun performPut(db: StorIOSQLite, chapter: Chapter) = db.inTransactionReturn { + val updateQuery = mapToUpdateQuery(chapter) + val contentValues = mapToContentValues(chapter) + + val numberOfRowsUpdated = db.lowLevel().update(updateQuery, contentValues) + PutResult.newUpdateResult(numberOfRowsUpdated, updateQuery.table()) + } + + fun mapToUpdateQuery(chapter: Chapter) = UpdateQuery.builder() + .table(ChapterTable.TABLE) + .where("${ChapterTable.COL_ID} = ?") + .whereArgs(chapter.id) + .build() + + fun mapToContentValues(chapter: Chapter) = + contentValuesOf( + ChapterTable.COL_READ to chapter.read, + ChapterTable.COL_BOOKMARK to chapter.bookmark, + ChapterTable.COL_LAST_PAGE_READ to chapter.last_page_read + ) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationReceiver.kt b/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationReceiver.kt index 1878aeba2b..6de223a8b2 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationReceiver.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationReceiver.kt @@ -289,6 +289,8 @@ class NotificationReceiver : BroadcastReceiver() { // Value containing chapter url. private const val EXTRA_CHAPTER_URL = "$ID.$NAME.EXTRA_CHAPTER_URL" + private const val EXTRA_IS_LEGACY_BACKUP = "$ID.$NAME.EXTRA_IS_LEGACY_BACKUP" + /** * Returns a [PendingIntent] that resumes the download of a chapter * @@ -551,24 +553,27 @@ class NotificationReceiver : BroadcastReceiver() { * @param notificationId id of notification * @return [PendingIntent] */ - internal fun shareBackupPendingBroadcast(context: Context, uri: Uri, notificationId: Int): PendingIntent { + internal fun shareBackupPendingBroadcast(context: Context, uri: Uri, isLegacyFormat: Boolean, notificationId: Int): PendingIntent { val intent = Intent(context, NotificationReceiver::class.java).apply { action = ACTION_SHARE_BACKUP putExtra(EXTRA_URI, uri) + putExtra(EXTRA_IS_LEGACY_BACKUP, isLegacyFormat) putExtra(EXTRA_NOTIFICATION_ID, notificationId) } return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) } /** - * Returns [PendingIntent] that starts a service which stops the restore service + * Returns [PendingIntent] that cancels a backup restore job. * * @param context context of application + * @param notificationId id of notification * @return [PendingIntent] */ - internal fun cancelRestorePendingBroadcast(context: Context): PendingIntent { + internal fun cancelRestorePendingBroadcast(context: Context, notificationId: Int): PendingIntent { val intent = Intent(context, NotificationReceiver::class.java).apply { action = ACTION_CANCEL_RESTORE + putExtra(EXTRA_NOTIFICATION_ID, notificationId) } return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt index c7e835f68b..89205ac271 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt @@ -155,6 +155,8 @@ object PreferenceKeys { const val hideBottomNavOnScroll = "hide_bottom_nav_on_scroll" + const val createLegacyBackup = "create_legacy_backup" + const val enableDoh = "enable_doh" fun trackUsername(syncId: Int) = "pref_mangasync_username_$syncId" diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt index 53248fcde0..0dcb5e3c6f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt @@ -10,6 +10,8 @@ import com.f2prateek.rx.preferences.RxSharedPreferences import com.tfcporciuncula.flow.FlowSharedPreferences import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.track.TrackService +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.onEach import java.io.File import java.text.DateFormat import java.text.SimpleDateFormat @@ -18,6 +20,11 @@ import eu.kanade.tachiyomi.data.preference.PreferenceKeys as Keys fun Preference.getOrDefault(): T = get() ?: defaultValue()!! +fun com.tfcporciuncula.flow.Preference.asImmediateFlow(block: (value: T) -> Unit): Flow { + block(get()) + return asFlow() + .onEach { block(it) } +} fun Preference.invert(): Boolean = getOrDefault().let { set(!it); !it } private class DateFormatConverter : Preference.Adapter { @@ -147,7 +154,7 @@ class PreferencesHelper(val context: Context) { fun anilistScoreType() = rxPrefs.getString("anilist_score_type", "POINT_10") - fun backupsDirectory() = rxPrefs.getString(Keys.backupDirectory, defaultBackupDir.toString()) + fun backupsDirectory() = flowPrefs.getString(Keys.backupDirectory, defaultBackupDir.toString()) fun dateFormat() = rxPrefs.getObject(Keys.dateFormat, DateFormat.getDateInstance(DateFormat.SHORT), DateFormatConverter()) @@ -155,9 +162,9 @@ class PreferencesHelper(val context: Context) { fun downloadOnlyOverWifi() = prefs.getBoolean(Keys.downloadOnlyOverWifi, true) - fun numberOfBackups() = rxPrefs.getInteger(Keys.numberOfBackups, 1) + fun numberOfBackups() = flowPrefs.getInt(Keys.numberOfBackups, 1) - fun backupInterval() = rxPrefs.getInteger(Keys.backupInterval, 0) + fun backupInterval() = flowPrefs.getInt(Keys.backupInterval, 0) fun removeAfterReadSlots() = prefs.getInt(Keys.removeAfterReadSlots, -1) @@ -291,5 +298,6 @@ class PreferencesHelper(val context: Context) { fun hideBottomNavOnScroll() = flowPrefs.getBoolean(Keys.hideBottomNavOnScroll, true) + fun createLegacyBackup() = flowPrefs.getBoolean(Keys.createLegacyBackup, true) fun enableDoh() = prefs.getBoolean(Keys.enableDoh, false) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackService.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackService.kt index 4b1df5a4a6..9d9336196e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackService.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackService.kt @@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.data.track import androidx.annotation.CallSuper import androidx.annotation.DrawableRes +import androidx.annotation.StringRes import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.track.model.TrackSearch @@ -18,7 +19,8 @@ abstract class TrackService(val id: Int) { get() = networkService.client // Name of the manga sync service to display - abstract val name: String + @StringRes + abstract fun nameRes(): Int @DrawableRes abstract fun getLogo(): Int diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/Anilist.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/Anilist.kt index f4f76962ec..1855bc8542 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/Anilist.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/Anilist.kt @@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.data.track.anilist import android.content.Context import android.graphics.Color +import androidx.annotation.StringRes import com.google.gson.Gson import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.models.Track @@ -12,7 +13,8 @@ import uy.kohesive.injekt.injectLazy class Anilist(private val context: Context, id: Int) : TrackService(id) { - override val name = "AniList" + @StringRes + override fun nameRes() = R.string.anilist private val gson: Gson by injectLazy() diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/Bangumi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/Bangumi.kt index bd68882ee0..faee9d9086 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/Bangumi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/Bangumi.kt @@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.data.track.bangumi import android.content.Context import android.graphics.Color +import androidx.annotation.StringRes import com.google.gson.Gson import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.models.Track @@ -12,7 +13,8 @@ import uy.kohesive.injekt.injectLazy class Bangumi(private val context: Context, id: Int) : TrackService(id) { - override val name = "Bangumi" + @StringRes + override fun nameRes() = R.string.bangumi private val gson: Gson by injectLazy() diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/Kitsu.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/Kitsu.kt index 5cfde9e2ed..feb5cee3b7 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/Kitsu.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/Kitsu.kt @@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.data.track.kitsu import android.content.Context import android.graphics.Color +import androidx.annotation.StringRes import com.google.gson.Gson import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.models.Track @@ -24,7 +25,8 @@ class Kitsu(private val context: Context, id: Int) : TrackService(id) { const val DEFAULT_SCORE = 0f } - override val name = "Kitsu" + @StringRes + override fun nameRes() = R.string.kitsu private val gson: Gson by injectLazy() diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeList.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeList.kt index 6f03090e6c..c763106b35 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeList.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeList.kt @@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.data.track.myanimelist import android.content.Context import android.graphics.Color +import androidx.annotation.StringRes import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.preference.getOrDefault @@ -15,7 +16,8 @@ class MyAnimeList(private val context: Context, id: Int) : TrackService(id) { private val interceptor by lazy { MyAnimeListInterceptor(this) } private val api by lazy { MyAnimeListApi(client, interceptor) } - override val name = "MyAnimeList" + @StringRes + override fun nameRes() = R.string.myanimelist override fun getLogo() = R.drawable.ic_tracker_mal diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/Shikimori.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/Shikimori.kt index 2b1321a8e1..82ebc9bbe8 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/Shikimori.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/Shikimori.kt @@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.data.track.shikimori import android.content.Context import android.graphics.Color +import androidx.annotation.StringRes import com.google.gson.Gson import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.models.Track @@ -11,7 +12,8 @@ import uy.kohesive.injekt.injectLazy class Shikimori(private val context: Context, id: Int) : TrackService(id) { - override val name = "Shikimori" + @StringRes + override fun nameRes() = R.string.shikimori private val gson: Gson by injectLazy() diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/CatalogueSource.kt b/app/src/main/java/eu/kanade/tachiyomi/source/CatalogueSource.kt index c78033ea60..f9e416def6 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/CatalogueSource.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/CatalogueSource.kt @@ -9,7 +9,7 @@ interface CatalogueSource : Source { /** * An ISO 639-1 compliant language code (two letters in lower case). */ - val lang: String + override val lang: String /** * Whether the source has support for latest updates. diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/Source.kt b/app/src/main/java/eu/kanade/tachiyomi/source/Source.kt index 9fb88c7337..d3b93c381d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/Source.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/Source.kt @@ -5,26 +5,36 @@ import eu.kanade.tachiyomi.extension.ExtensionManager import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SManga +import eu.kanade.tachiyomi.source.model.toChapterInfo +import eu.kanade.tachiyomi.source.model.toMangaInfo +import eu.kanade.tachiyomi.source.model.toPageUrl +import eu.kanade.tachiyomi.source.model.toSChapter +import eu.kanade.tachiyomi.source.model.toSManga import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import rx.Observable +import tachiyomi.source.model.ChapterInfo +import tachiyomi.source.model.MangaInfo import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get /** * A basic interface for creating a source. It could be an online source, a local source, etc... */ -interface Source { +interface Source : tachiyomi.source.Source { /** * Id for the source. Must be unique. */ - val id: Long + override val id: Long /** * Name of the source. */ - val name: String + override val name: String + + override val lang: String + get() = "" /** * Returns an observable with the updated details for a manga. @@ -46,6 +56,40 @@ interface Source { * @param chapter the chapter. */ fun fetchPageList(chapter: SChapter): Observable> + + /** + * [1.x API] Get the updated details for a manga. + */ + @Suppress("DEPRECATION") + override suspend fun getMangaDetails(manga: MangaInfo): MangaInfo { + return withContext(Dispatchers.IO) { + val sManga = manga.toSManga() + val networkManga = fetchMangaDetails(sManga).toBlocking().single() + sManga.copyFrom(networkManga) + sManga.toMangaInfo() + } + } + + /** + * [1.x API] Get all the available chapters for a manga. + */ + @Suppress("DEPRECATION") + override suspend fun getChapterList(manga: MangaInfo): List { + return withContext(Dispatchers.IO) { + fetchChapterList(manga.toSManga()).toBlocking().single().map { it.toChapterInfo() } + } + } + + /** + * [1.x API] Get the list of pages a chapter has. + */ + @Suppress("DEPRECATION") + override suspend fun getPageList(chapter: ChapterInfo): List { + return withContext(Dispatchers.IO) { + fetchPageList(chapter.toSChapter()).toBlocking().single() + .map { it.toPageUrl() } + } + } } suspend fun Source.fetchMangaDetailsAsync(manga: SManga): SManga? { diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/model/Page.kt b/app/src/main/java/eu/kanade/tachiyomi/source/model/Page.kt index 1ca0778b6f..bbf9a36788 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/model/Page.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/model/Page.kt @@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.source.model import android.net.Uri import eu.kanade.tachiyomi.network.ProgressListener import rx.subjects.Subject +import tachiyomi.source.model.PageUrl open class Page( val index: Int, @@ -55,3 +56,16 @@ open class Page( const val ERROR = 4 } } + +fun Page.toPageUrl(): PageUrl { + return PageUrl( + url = this.imageUrl ?: this.url + ) +} + +fun PageUrl.toPage(index: Int): Page { + return Page( + index = index, + imageUrl = this.url + ) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/model/SChapter.kt b/app/src/main/java/eu/kanade/tachiyomi/source/model/SChapter.kt index 756b8f2c84..c228c16384 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/model/SChapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/model/SChapter.kt @@ -1,6 +1,7 @@ package eu.kanade.tachiyomi.source.model import eu.kanade.tachiyomi.data.database.models.ChapterImpl +import tachiyomi.source.model.ChapterInfo import java.io.Serializable interface SChapter : Serializable { @@ -39,3 +40,24 @@ interface SChapter : Serializable { } } } + +fun SChapter.toChapterInfo(): ChapterInfo { + return ChapterInfo( + dateUpload = this.date_upload, + key = this.url, + name = this.name, + number = this.chapter_number, + scanlator = this.scanlator ?: "" + ) +} + +fun ChapterInfo.toSChapter(): SChapter { + val chapter = this + return SChapter.create().apply { + url = chapter.key + name = chapter.name + date_upload = chapter.dateUpload + chapter_number = chapter.number + scanlator = chapter.scanlator + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/model/SManga.kt b/app/src/main/java/eu/kanade/tachiyomi/source/model/SManga.kt index 9144cba791..9141de9812 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/model/SManga.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/model/SManga.kt @@ -1,6 +1,7 @@ package eu.kanade.tachiyomi.source.model import eu.kanade.tachiyomi.data.database.models.MangaImpl +import tachiyomi.source.model.MangaInfo import java.io.Serializable interface SManga : Serializable { @@ -67,3 +68,30 @@ interface SManga : Serializable { } } } + +fun SManga.toMangaInfo(): MangaInfo { + return MangaInfo( + key = this.url, + title = this.title, + artist = this.artist ?: "", + author = this.author ?: "", + description = this.description ?: "", + genres = this.genre?.split(", ") ?: emptyList(), + status = this.status, + cover = this.thumbnail_url ?: "" + ) +} + +fun MangaInfo.toSManga(): SManga { + val mangaInfo = this + return SManga.create().apply { + url = mangaInfo.key + title = mangaInfo.title + artist = mangaInfo.artist + author = mangaInfo.author + description = mangaInfo.description + genre = mangaInfo.genres.joinToString(", ") + status = mangaInfo.status + thumbnail_url = mangaInfo.cover + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryPresenter.kt index 371be6f45a..3c49103380 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryPresenter.kt @@ -269,7 +269,7 @@ class LibraryPresenter( tracks.any { it.sync_id == service.id } } val service = if (filterTrackers.isNotEmpty()) loggedServices.find { - it.name == filterTrackers + context.getString(it.nameRes()) == filterTrackers } else null if (filterTracked == STATE_INCLUDE) { if (!hasTrack) return false diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/filter/FilterBottomSheet.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/filter/FilterBottomSheet.kt index b5992ada2d..fc16e92fec 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/filter/FilterBottomSheet.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/filter/FilterBottomSheet.kt @@ -36,6 +36,7 @@ import kotlinx.android.synthetic.main.filter_bottom_sheet.view.* import kotlinx.android.synthetic.main.library_grid_recycler.* import kotlinx.android.synthetic.main.library_list_controller.* import kotlinx.android.synthetic.main.main_activity.* +import kotlinx.android.synthetic.main.track_item.* import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope @@ -311,7 +312,7 @@ class FilterBottomSheet @JvmOverloads constructor(context: Context, attrs: Attri if (filterItems.contains(tracked)) { val loggedServices = Injekt.get().services.filter { it.isLogged } if (loggedServices.size > 1) { - val serviceNames = loggedServices.map { it.name } + val serviceNames = loggedServices.map { context.getString(it.nameRes()) } withContext(Dispatchers.Main) { trackers = inflate(R.layout.filter_buttons) as FilterTagGroup trackers?.setup( diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackHolder.kt index 95d933b3ce..f94f32f15e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackHolder.kt @@ -31,7 +31,8 @@ class TrackHolder(view: View, adapter: TrackAdapter) : BaseViewHolder(view) { logo_container.updateLayoutParams { bottomToBottom = if (track != null) divider.id else track_details.id } - track_logo.contentDescription = item.service.name + val serviceName = track_logo.context.getString(item.service.nameRes()) + track_logo.contentDescription = serviceName track_group.visibleIf(track != null) add_tracking.visibleIf(track == null) if (track != null) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackRemoveDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackRemoveDialog.kt index 8858ba077a..201a2cb2ed 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackRemoveDialog.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackRemoveDialog.kt @@ -42,10 +42,11 @@ class TrackRemoveDialog : DialogController .negativeButton(android.R.string.cancel) if (item.service.canRemoveFromService()) { + val serviceName = activity!!.getString(item.service.nameRes()) dialog.checkBoxPrompt( text = activity!!.getString( R.string.remove_tracking_from_, - item.service.name + serviceName ), isCheckedDefault = true, onToggle = null diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsBackupController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsBackupController.kt index 6a51c8e219..c176c1000c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsBackupController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsBackupController.kt @@ -4,25 +4,34 @@ import android.Manifest.permission.WRITE_EXTERNAL_STORAGE import android.app.Activity import android.app.Dialog import android.content.ActivityNotFoundException +import android.content.Context import android.content.Intent import android.net.Uri import android.os.Bundle import android.view.View import androidx.core.net.toUri +import androidx.core.os.bundleOf +import androidx.documentfile.provider.DocumentFile import androidx.preference.PreferenceScreen import com.afollestad.materialdialogs.MaterialDialog import com.afollestad.materialdialogs.list.listItemsMultiChoice import com.hippo.unifile.UniFile import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.backup.BackupConst import eu.kanade.tachiyomi.data.backup.BackupCreateService import eu.kanade.tachiyomi.data.backup.BackupCreatorJob import eu.kanade.tachiyomi.data.backup.BackupRestoreService -import eu.kanade.tachiyomi.data.backup.models.Backup -import eu.kanade.tachiyomi.data.preference.getOrDefault +import eu.kanade.tachiyomi.data.backup.full.FullBackupRestoreValidator +import eu.kanade.tachiyomi.data.backup.full.models.BackupFull +import eu.kanade.tachiyomi.data.backup.legacy.LegacyBackupRestoreValidator +import eu.kanade.tachiyomi.data.backup.legacy.models.Backup +import eu.kanade.tachiyomi.data.preference.asImmediateFlow import eu.kanade.tachiyomi.ui.base.controller.DialogController import eu.kanade.tachiyomi.util.system.getFilePicker import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.view.requestPermissionsSafe +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import eu.kanade.tachiyomi.data.preference.PreferenceKeys as Keys class SettingsBackupController : SettingsController() { @@ -40,31 +49,45 @@ class SettingsBackupController : SettingsController() { override fun setupPreferenceScreen(screen: PreferenceScreen) = with(screen) { titleRes = R.string.backup - preference { - titleRes = R.string.create_backup - summaryRes = R.string.can_be_used_to_restore - - onClick { - val ctrl = CreateBackupDialog() - ctrl.targetController = this@SettingsBackupController - ctrl.showDialog(router) - } - } - preference { - titleRes = R.string.restore_backup - summaryRes = R.string.restore_from_backup_file - - onClick { - val intent = Intent(Intent.ACTION_GET_CONTENT) - intent.addCategory(Intent.CATEGORY_OPENABLE) - intent.type = "application/*" - val title = resources?.getString(R.string.select_backup_file) - val chooser = Intent.createChooser(intent, title) - startActivityForResult(chooser, CODE_BACKUP_RESTORE) - } - } preferenceCategory { - titleRes = R.string.service + titleRes = R.string.backup + + preference { + key = "pref_create_backup" + titleRes = R.string.create_backup + summaryRes = R.string.can_be_used_to_restore + + onClick { backup(context, BackupConst.BACKUP_TYPE_FULL) } + } + preference { + key = "pref_create_legacy_backup" + titleRes = R.string.create_legacy_backup + summaryRes = R.string.can_be_used_in_older_tachi + + onClick { backup(context, BackupConst.BACKUP_TYPE_LEGACY) } + } + preference { + key = "pref_restore_backup" + titleRes = R.string.restore_backup + summaryRes = R.string.restore_from_backup_file + + onClick { + if (!BackupRestoreService.isRunning(context)) { + val intent = Intent(Intent.ACTION_GET_CONTENT) + intent.addCategory(Intent.CATEGORY_OPENABLE) + intent.type = "application/*" + val title = resources?.getString(R.string.select_backup_file) + val chooser = Intent.createChooser(intent, title) + startActivityForResult(chooser, CODE_BACKUP_RESTORE) + } else { + context.toast(R.string.restore_in_progress) + } + } + } + } + + preferenceCategory { + titleRes = R.string.automatic_backups intListPreference(activity) { key = Keys.backupInterval @@ -82,21 +105,18 @@ class SettingsBackupController : SettingsController() { onChange { newValue -> // Always cancel the previous task, it seems that sometimes they are not updated - BackupCreatorJob.setupTask(0) val interval = newValue as Int - if (interval > 0) { - BackupCreatorJob.setupTask(interval) - } + BackupCreatorJob.setupTask(context, interval) true } } - val backupDir = preference { + preference { key = Keys.backupDirectory titleRes = R.string.backup_location onClick { - val currentDir = preferences.backupsDirectory().getOrDefault() + val currentDir = preferences.backupsDirectory().get() try { val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) startActivityForResult(intent, CODE_BACKUP_DIR) @@ -106,94 +126,145 @@ class SettingsBackupController : SettingsController() { } } - preferences.backupsDirectory().asObservable() - .subscribeUntilDestroy { path -> + preferences.backupsDirectory().asFlow() + .onEach { path -> val dir = UniFile.fromUri(context, path.toUri()) summary = dir.filePath + "/automatic" } + .launchIn(viewScope) + preferences.backupInterval().asImmediateFlow { isVisible = it > 0 } + .launchIn(viewScope) } - val backupNumber = intListPreference(activity) { + intListPreference(activity) { key = Keys.numberOfBackups titleRes = R.string.max_auto_backups entries = listOf("1", "2", "3", "4", "5") entryRange = 1..5 defaultValue = 1 - } - preferences.backupInterval().asObservable() - .subscribeUntilDestroy { - backupDir.isVisible = it > 0 - backupNumber.isVisible = it > 0 - } + preferences.backupInterval().asImmediateFlow { isVisible = it > 0 } + .launchIn(viewScope) + } + switchPreference { + key = Keys.createLegacyBackup + titleRes = R.string.also_create_legacy_backup + defaultValue = true + + preferences.backupInterval().asImmediateFlow { isVisible = it > 0 } + .launchIn(viewScope) + } + } + } + + private fun backup(context: Context, type: Int) { + if (!BackupCreateService.isRunning(context)) { + val ctrl = CreateBackupDialog(type) + ctrl.targetController = this@SettingsBackupController + ctrl.showDialog(router) + } else { + context.toast(R.string.backup_in_progress) } } override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - when (requestCode) { - CODE_BACKUP_DIR -> if (data != null && resultCode == Activity.RESULT_OK) { - val activity = activity ?: return - // Get uri of backup folder. - val uri = data.data + if (data != null && resultCode == Activity.RESULT_OK) { + val activity = activity ?: return + val uri = data.data + when (requestCode) { + CODE_BACKUP_DIR -> { + // Get UriPermission so it's possible to write files + val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or + Intent.FLAG_GRANT_WRITE_URI_PERMISSION - // Get UriPermission so it's possible to write files - val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or - Intent.FLAG_GRANT_WRITE_URI_PERMISSION + if (uri != null) { + activity.contentResolver.takePersistableUriPermission(uri, flags) + } - if (uri != null) { - activity.contentResolver.takePersistableUriPermission(uri, flags) + // Set backup Uri + preferences.backupsDirectory().set(uri.toString()) } + CODE_FULL_BACKUP_CREATE, CODE_LEGACY_BACKUP_CREATE -> { + val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or + Intent.FLAG_GRANT_WRITE_URI_PERMISSION - // Set backup Uri - preferences.backupsDirectory().set(uri.toString()) - } - CODE_BACKUP_CREATE -> if (data != null && resultCode == Activity.RESULT_OK) { - val activity = activity ?: return + if (uri != null) { + activity.contentResolver.takePersistableUriPermission(uri, flags) + } - val uri = data.data - val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or - Intent.FLAG_GRANT_WRITE_URI_PERMISSION + val file = UniFile.fromUri(activity, uri) - if (uri != null) { - activity.contentResolver.takePersistableUriPermission(uri, flags) + activity.toast(R.string.creating_backup) + + BackupCreateService.start( + activity, + file.uri, + backupFlags, + if (requestCode == CODE_FULL_BACKUP_CREATE) BackupConst.BACKUP_TYPE_FULL else BackupConst.BACKUP_TYPE_LEGACY + ) + } + CODE_BACKUP_RESTORE -> { + uri?.path?.let { + val fileName = DocumentFile.fromSingleUri(activity, uri)?.name ?: uri.toString() + when { + fileName.endsWith(".proto.gz") -> { + RestoreBackupDialog( + uri, + BackupConst.BACKUP_TYPE_FULL + ).showDialog(router) + } + fileName.endsWith(".json") -> { + RestoreBackupDialog( + uri, + BackupConst.BACKUP_TYPE_LEGACY + ).showDialog(router) + } + else -> { + activity.toast(activity.getString(R.string.invalid_backup_file_type, fileName)) + } + } + } } - - val file = UniFile.fromUri(activity, uri) - - activity.toast(R.string.creating_backup) - - BackupCreateService.start(activity, file.uri, backupFlags) - } - CODE_BACKUP_RESTORE -> if (data != null && resultCode == Activity.RESULT_OK) { - val uri = data.data - if (uri != null) - RestoreBackupDialog(uri).showDialog(router) } } } - fun createBackup(flags: Int) { + fun createBackup(flags: Int, type: Int) { backupFlags = flags + val code = when (type) { + BackupConst.BACKUP_TYPE_FULL -> CODE_FULL_BACKUP_CREATE + else -> CODE_LEGACY_BACKUP_CREATE + } + val fileName = when (type) { + BackupConst.BACKUP_TYPE_FULL -> BackupFull.getDefaultFilename() + else -> Backup.getDefaultFilename() + } // Setup custom file picker intent // Get dirs - val currentDir = preferences.backupsDirectory().getOrDefault() + val currentDir = preferences.backupsDirectory().get() try { // Use Android's built-in file creator val intent = Intent(Intent.ACTION_CREATE_DOCUMENT) .addCategory(Intent.CATEGORY_OPENABLE) .setType("application/*") - .putExtra(Intent.EXTRA_TITLE, Backup.getDefaultFilename()) + .putExtra(Intent.EXTRA_TITLE, fileName) - startActivityForResult(intent, CODE_BACKUP_CREATE) + startActivityForResult(intent, code) } catch (e: ActivityNotFoundException) { - // Handle errors where the android ROM doesn't support the built in picker - startActivityForResult(preferences.context.getFilePicker(currentDir), CODE_BACKUP_CREATE) + activity?.toast(R.string.file_picker_error) } } - class CreateBackupDialog : DialogController() { + class CreateBackupDialog(bundle: Bundle? = null) : DialogController(bundle) { + constructor(type: Int) : this( + bundleOf( + KEY_TYPE to type + ) + ) + override fun onCreateDialog(savedViewState: Bundle?): Dialog { + val type = args.getInt(KEY_TYPE) val activity = activity!! val options = arrayOf( R.string.manga, @@ -222,41 +293,79 @@ class SettingsBackupController : SettingsController() { } } - (targetController as? SettingsBackupController)?.createBackup(flags) + (targetController as? SettingsBackupController)?.createBackup(flags, type) } .positiveButton(R.string.create) .negativeButton(android.R.string.cancel) } + + private companion object { + const val KEY_TYPE = "CreateBackupDialog.type" + } } class RestoreBackupDialog(bundle: Bundle? = null) : DialogController(bundle) { - constructor(uri: Uri) : this( - Bundle().apply { - putParcelable(KEY_URI, uri) - } + constructor(uri: Uri, type: Int) : this( + bundleOf( + KEY_URI to uri, + KEY_TYPE to type + ) ) override fun onCreateDialog(savedViewState: Bundle?): Dialog { - return MaterialDialog(activity!!) - .title(R.string.restore_backup) - .message(R.string.restore_message) - .positiveButton(R.string.restore) { - val context = applicationContext - if (context != null) { - activity?.toast(R.string.restoring_backup) - BackupRestoreService.start(context, args.getParcelable(KEY_URI)!!) - } + val activity = activity!! + val uri: Uri = args.getParcelable(KEY_URI)!! + val type: Int = args.getInt(KEY_TYPE) + + return try { + var message = if (type == BackupConst.BACKUP_TYPE_FULL) { + activity.getString(R.string.restore_content_full) + } else { + activity.getString(R.string.restore_content) } + + val validator = if (type == BackupConst.BACKUP_TYPE_FULL) { + FullBackupRestoreValidator() + } else { + LegacyBackupRestoreValidator() + } + + val results = validator.validate(activity, uri) + if (results.missingSources.isNotEmpty()) { + message += "\n\n${activity.getString(R.string.restore_missing_sources)}\n${results.missingSources.joinToString("\n") { "- $it" }}" + } + if (results.missingTrackers.isNotEmpty()) { + message += "\n\n${activity.getString(R.string.restore_missing_trackers)}\n${results.missingTrackers.joinToString("\n") { "- $it" }}" + } + + return MaterialDialog(activity) + .title(R.string.restore_backup) + .message(text = message) + .positiveButton(R.string.restore) { + val context = applicationContext + if (context != null) { + activity.toast(R.string.restoring_backup) + BackupRestoreService.start(context, uri, type) + } + } + } catch (e: Exception) { + MaterialDialog(activity) + .title(R.string.invalid_backup_file) + .message(text = e.message) + .positiveButton(android.R.string.cancel) + } } private companion object { const val KEY_URI = "RestoreBackupDialog.uri" + const val KEY_TYPE = "RestoreBackupDialog.type" } } private companion object { - const val CODE_BACKUP_CREATE = 501 - const val CODE_BACKUP_RESTORE = 502 + const val CODE_LEGACY_BACKUP_CREATE = 501 const val CODE_BACKUP_DIR = 503 + const val CODE_FULL_BACKUP_CREATE = 504 + const val CODE_BACKUP_RESTORE = 505 } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsController.kt index 53df954425..5e22e8e3f7 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsController.kt @@ -16,6 +16,7 @@ import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.ui.base.controller.BaseController import eu.kanade.tachiyomi.util.view.scrollViewWith +import kotlinx.coroutines.MainScope import rx.Observable import rx.Subscription import rx.subscriptions.CompositeSubscription @@ -25,6 +26,7 @@ import uy.kohesive.injekt.api.get abstract class SettingsController : PreferenceController() { val preferences: PreferencesHelper = Injekt.get() + val viewScope = MainScope() var untilDestroySubscriptions = CompositeSubscription() private set diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsTrackingController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsTrackingController.kt index cac8a04edf..5d12008e84 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsTrackingController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsTrackingController.kt @@ -78,7 +78,7 @@ class SettingsTrackingController : return initThenAdd( LoginPreference(context).apply { key = Keys.trackUsername(service.id) - title = service.name + title = context.getString(service.nameRes()) }, block ) diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/chapter/NoChaptersException.kt b/app/src/main/java/eu/kanade/tachiyomi/util/chapter/NoChaptersException.kt new file mode 100644 index 0000000000..1801792481 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/util/chapter/NoChaptersException.kt @@ -0,0 +1,3 @@ +package eu.kanade.tachiyomi.util.chapter + +class NoChaptersException : Exception() diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/system/ContextExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/system/ContextExtensions.kt index c2acf33e93..0cc4fc7dde 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/system/ContextExtensions.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/system/ContextExtensions.kt @@ -30,6 +30,7 @@ import androidx.core.net.toUri import com.nononsenseapps.filepicker.FilePickerActivity import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.widget.CustomLayoutPickerActivity +import java.io.File /** * Display a toast in this context. @@ -160,6 +161,15 @@ fun Context.notificationBuilder(channelId: String, block: (NotificationCompat.Bu return builder } +/** + * Convenience method to acquire a partial wake lock. + */ +fun Context.acquireWakeLock(tag: String): PowerManager.WakeLock { + val wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "$tag:WakeLock") + wakeLock.acquire() + return wakeLock +} + /** * Property to get the notification manager from the context. */ @@ -290,3 +300,12 @@ fun Context.isOnline(): Boolean { } return result } + +fun Context.createFileInCacheDir(name: String): File { + val file = File(externalCacheDir, name) + if (file.exists()) { + file.delete() + } + file.createNewFile() + return file +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/preference/TrackLoginDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/preference/TrackLoginDialog.kt index e0dfe7ef70..1838e16b4c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/preference/TrackLoginDialog.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/preference/TrackLoginDialog.kt @@ -23,7 +23,8 @@ class TrackLoginDialog(usernameLabel: String? = null, bundle: Bundle? = null) : this(usernameLabel, Bundle().apply { putInt("key", service.id) }) override fun setCredentialsOnView(view: View) = with(view) { - dialog_title.text = context.getString(R.string.log_in_to_, service.name) + val serviceName = context.getString(service.nameRes()) + dialog_title.text = context.getString(R.string.log_in_to_, serviceName) username.setText(service.getUsername()) password.setText(service.getPassword()) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/widget/preference/TrackLogoutDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/widget/preference/TrackLogoutDialog.kt index 5b99884bfa..1d3b8003ae 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/widget/preference/TrackLogoutDialog.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/widget/preference/TrackLogoutDialog.kt @@ -18,8 +18,9 @@ class TrackLogoutDialog(bundle: Bundle? = null) : DialogController(bundle) { constructor(service: TrackService) : this(Bundle().apply { putInt("key", service.id) }) override fun onCreateDialog(savedViewState: Bundle?): Dialog { + val serviceName = activity!!.getString(service.nameRes()) return MaterialDialog(activity!!) - .title(text = activity!!.getString(R.string.logout_from_, service.name)) + .title(text = activity!!.getString(R.string.logout_from_, serviceName)) .negativeButton(R.string.cancel) .positiveButton(R.string.logout) { _ -> service.logout() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3360606caf..d119964c6a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -49,6 +49,7 @@ Removed bookmark Chapters removed. Chapter not found + No chapters found Remove %1$d downloaded chapter? Remove %1$d downloaded chapters? @@ -427,6 +428,11 @@ Add tracking Remove tracking from app Also remove from %1$s + AniList + MyAnimeList + Kitsu + Bangumi + Shikimori Select sources @@ -503,23 +509,42 @@ Backup Create backup Can be used to restore current library + Create legacy backup + Can be used in older versions of Tachiyomi Restore backup Restore library from backup file + Restore already in progress + Backup already in progress Backup location + Automatic backups Service Backup frequency Max automatic backups + Also create legacy backup + Invalid backup file + Invalid backup file type: %1$s\nIt should end with ".proto.gz" or ".json". + File is missing data. + Backup does not contain any manga. Backup failed Backup created Restore completed - Restore error - %1$s Restored. %2$s errors found + Failed to restore backup %1$d skipped Restore uses the network to fetch data, carrier costs may apply.\n\nMake sure you have installed all necessary extensions and are logged in to sources and tracking services before restoring. + Missing sources: + Trackers not logged into: + Restore uses sources to fetch data, carrier costs may apply.\n\nMake sure you have installed all necessary extensions and are logged in to sources and tracking services before restoring. + Data from the backup file will be restored.\n\nYou will need to install any missing extensions and log in to tracking services afterwards to use them. + Canceled restore Creating backup What do you want to backup? Restoring backup Restoring (%1$d/%2$d) + %02d min, %02d sec + + Done in %1$s with %2$s error + Done in %1$s with %2$s errors + %d source missing %d sources missing @@ -682,6 +707,7 @@ Select cover image Select backup file + No file picker app found Failed to bypass Cloudflare @@ -744,6 +770,7 @@ Normal Oldest Open in browser + Open log Open in WebView Options Pause diff --git a/build.gradle.kts b/build.gradle.kts index 824d101989..5bb55f0a50 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,20 +1,9 @@ -// Top-level build file where you can add configuration options common to all sub-projects/modules. -plugins{ - id("com.github.ben-manes.versions") version BuildPluginsVersion.VERSIONS_PLUGIN -} +import Versions.ktlint -buildscript { - dependencies { - classpath(BuildPluginsVersion.AGP) - classpath(BuildPluginsVersion.OSS_LICENSE) - classpath(BuildPluginsVersion.GOOGLE_SERVICES) - classpath(BuildPluginsVersion.ANDROID_EXTENSIONS) - classpath(BuildPluginsVersion.KOTLIN_GRADLE) - classpath(BuildPluginsVersion.KOTLINTER) - } +plugins { + id(Plugins.ktLint.name) version Plugins.ktLint.version + id(Plugins.gradleVersions.name) version Plugins.gradleVersions.version } - -// Top-level build file where you can add configuration options common to all sub-projects/modules. allprojects { repositories { google() @@ -25,7 +14,55 @@ allprojects { } } +subprojects { + apply(plugin = Plugins.ktLint.name) + ktlint { + debug.set(true) + verbose.set(true) + android.set(false) + outputToConsole.set(true) + ignoreFailures.set(false) + ignoreFailures.set(true) + enableExperimentalRules.set(false) + reporters { + reporter(org.jlleitschuh.gradle.ktlint.reporter.ReporterType.CHECKSTYLE) + reporter(org.jlleitschuh.gradle.ktlint.reporter.ReporterType.JSON) + } + filter { + exclude("**/generated/**") + include("**/kotlin/**") + } + } +} + +buildscript { + dependencies { + classpath(LegacyPluginClassPath.fireBaseCrashlytics) + classpath(LegacyPluginClassPath.androidGradlePlugin) + classpath(LegacyPluginClassPath.googleServices) + classpath(LegacyPluginClassPath.kotlinExtensions) + classpath(LegacyPluginClassPath.kotlinPlugin) + classpath(LegacyPluginClassPath.aboutLibraries) + classpath(LegacyPluginClassPath.kotlinSerializations) + } + repositories { + gradlePluginPortal() + google() + jcenter() + } +} + +tasks.named("dependencyUpdates", com.github.benmanes.gradle.versions.updates.DependencyUpdatesTask::class.java).configure { + rejectVersionIf { + isNonStable(candidate.version) + } + // optional parameters + checkForGradleUpdate = true + outputFormatter = "json" + outputDir = "build/dependencyUpdates" + reportfileName = "report" +} tasks.register("clean", Delete::class) { delete(rootProject.buildDir) -} \ No newline at end of file +} diff --git a/buildSrc/src/main/kotlin/Dependencies.kt b/buildSrc/src/main/kotlin/Dependencies.kt index 607d4383be..6509fe4f17 100644 --- a/buildSrc/src/main/kotlin/Dependencies.kt +++ b/buildSrc/src/main/kotlin/Dependencies.kt @@ -2,17 +2,105 @@ object Versions { const val ACRA = "4.9.2" const val CHUCKER = "3.2.0" const val COIL = "0.11.0" - const val COROUTINES = "1.3.9" + const val COROUTINES = "1.4.2" const val FASTADAPTER = "5.0.0" const val HYPERION = "0.9.27" const val NUCLEUS = "3.0.0" const val OKHTTP = "4.8.1" const val OSS_LICENSE = "17.0.0" const val RETROFIT = "2.7.2" + const val KOTLINSERIALIZATION = "1.0.1" const val ROBO_ELECTRIC = "3.1.4" const val RX_BINDING = "1.0.1" const val TIMBER = "4.7.1" - const val WORKMANAGER = "2.3.3" + const val WORKMANAGER = "2.5.0" + const val aboutLibraries = "8.3.0" + const val androidAnnotations = "1.1.0" + const val androidAppCompat = "1.1.0" + const val androidBiometrics = "1.0.1" + const val androidBrowser = "1.2.0" + const val androidCardView = "1.0.0" + const val androidConstraintLayout = "1.1.3" + const val androidCoreKtx = "1.3.1" + const val androidGradlePlugin = "4.1.3" + const val androidLifecycle = "2.2.0" + const val androidMaterial = "1.1.0" + const val androidMultiDex = "2.0.1" + const val androidPalette = "1.0.0" + const val androidPreferences = "1.1.1" + const val androidRecyclerView = "1.1.0" + const val androidSqlite = "2.1.0" + const val androidWorkManager = "2.4.0" + const val assertJ = "3.12.2" + const val changelog = "2.1.0" + const val chucker = "3.2.0" + const val coil = "1.1.1" + const val conductor = "2.1.5" + const val directionalViewPager = "a844dbca0a" + const val diskLruCache = "2.0.2" + const val fastAdapter = "5.0.0" + const val filePicker = "2.5.2" + const val firebase = "17.5.0" + const val firebaseCrashlytics = "17.2.1" + const val flexibleAdapter = "5.1.0" + const val flexibleAdapterUi = "1.0.0" + const val flowPreferences = "1.3.2" + const val googlePlayServices = "17.0.0" + const val googleServices = "4.3.3" + const val gradleVersions = "0.29.0" + const val gson = "2.8.6" + const val hyperion = "0.9.27" + const val injekt = "65b0440" + const val jsoup = "1.13.1" + const val junit = "4.13" + const val kotlin = "1.4.10" + const val kotlinCoroutines = "1.3.9" + const val kotlinSerialization = "1.0.1" + const val kotson = "2.5.0" + const val ktlint = "9.4.0" + const val loadingButton = "2.2.0" + const val materialDesignDimens = "1.4" + const val materialDialogs = "3.1.1" + const val mockito = "1.10.19" + const val moshi = "1.9.3" + const val nucleus = "3.0.0" + const val numberSlidingPicker = "1.0.3" + const val okhttp = "4.8.1" + const val okio = "2.6.0" + const val photoView = "2.3.0" + const val reactiveNetwork = "0.13.0" + const val requerySqlite = "3.31.0" + const val retrofit = "2.7.2" + const val retrofitKotlinSerialization = "0.7.0" + const val roboElectric = "3.1.4" + const val rxAndroid = "1.2.1" + const val rxBinding = "1.0.1" + const val rxJava = "1.3.8" + const val rxPreferences = "1.0.2" + const val rxRelay = "1.2.0" + const val storioCommon = "8be19de@aar" + const val storioSqlite = "8be19de@aar" + const val stringSimilarity = "2.0.0" + const val subsamplingImageScale = "93d74f0" + const val systemUiHelper = "1.0.0" + const val tagGroup = "1.6.0" + const val tapTargetView = "1.13.0" + const val tokenBucket = "1.7" + const val unifile = "e9ee588" + const val versionCompare = "1.3.4" + const val viewStatePagerAdapter = "1.1.0" + const val viewToolTip = "1.2.2" + const val xlog = "1.6.1" +} + +object LegacyPluginClassPath { + const val aboutLibraries = "com.mikepenz.aboutlibraries.plugin:aboutlibraries-plugin:${Versions.aboutLibraries}" + const val androidGradlePlugin = "com.android.tools.build:gradle:${Versions.androidGradlePlugin}" + const val googleServices = "com.google.gms:google-services:${Versions.googleServices}" + const val kotlinExtensions = "org.jetbrains.kotlin:kotlin-android-extensions:${Versions.kotlin}" + const val kotlinPlugin = "org.jetbrains.kotlin:kotlin-gradle-plugin:${Versions.kotlin}" + const val kotlinSerializations = "org.jetbrains.kotlin:kotlin-serialization:${Versions.kotlin}" + const val fireBaseCrashlytics = "com.google.firebase:firebase-crashlytics-gradle:2.3.0" } object AndroidVersions { @@ -22,10 +110,37 @@ object AndroidVersions { const val TARGET_SDK = 29 const val VERSION_CODE = 67 const val VERSION_NAME = "1.0.10" + const val NDK = "22.0.7026061" +} + +object Plugins { + const val aboutLibraries = "com.mikepenz.aboutlibraries.plugin" + const val androidApplication = "com.android.application" + const val firebaseCrashlytics = "com.google.firebase.crashlytics" + const val googleServices = "com.google.gms.google-services" + const val kapt = "kapt" + const val kotlinAndroid = "android" + const val kotlinExtensions = "android.extensions" + const val kotlinSerialization = "org.jetbrains.kotlin.plugin.serialization" + val gradleVersions = PluginClass("com.github.ben-manes.versions", Versions.gradleVersions) + val ktLint = PluginClass("org.jlleitschuh.gradle.ktlint", Versions.ktlint) +} + +data class PluginClass(val name: String, val version: String) + +object Configs { + const val applicationId = "tachiyomi.mangadex" + const val buildToolsVersion = "29.0.3" + const val compileSdkVersion = 29 + const val minSdkVersion = 24 + const val targetSdkVersion = 29 + const val testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + const val versionCode = 110 + const val versionName = "2.2.2.2" } object BuildPluginsVersion { - const val AGP = "com.android.tools.build:gradle:4.0.1" + const val AGP = "com.android.tools.build:gradle:4.1.3" const val KOTLIN = "1.4.10" const val ANDROID_EXTENSIONS = "org.jetbrains.kotlin:kotlin-android-extensions:$KOTLIN" const val KOTLIN_GRADLE = "org.jetbrains.kotlin:kotlin-gradle-plugin:$KOTLIN" @@ -34,3 +149,10 @@ object BuildPluginsVersion { const val OSS_LICENSE = "com.google.android.gms:oss-licenses-plugin:0.10.2" const val VERSIONS_PLUGIN = "0.28.0" } + +fun isNonStable(version: String): Boolean { + val stableKeyword = listOf("RELEASE", "FINAL", "GA").any { version.toUpperCase().contains(it) } + val regex = "^[0-9,.v-]+(-r)?$".toRegex() + val isStable = stableKeyword || regex.matches(version) + return isStable.not() +} diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 4ede356801..11452e886d 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-6.6-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-6.8.2-all.zip