mirror of
https://github.com/tachiyomiorg/tachiyomi.git
synced 2024-12-22 22:01:51 +01:00
Added support for proto.gz backups
Lotta cleanup to do, but that's a later commit Co-Authored-By: Carlos <2092019+CarlosEsco@users.noreply.github.com> Co-Authored-By: arkon <4098258+arkon@users.noreply.github.com>
This commit is contained in:
parent
e5d15894dc
commit
434926351c
@ -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")) {
|
||||
|
@ -78,7 +78,7 @@ object Migrations {
|
||||
UpdaterJob.setupTask()
|
||||
}
|
||||
LibraryUpdateJob.setupTask()
|
||||
BackupCreatorJob.setupTask()
|
||||
BackupCreatorJob.setupTask(context)
|
||||
ExtensionUpdateJob.setupTask()
|
||||
}
|
||||
if (oldVersion < 66) {
|
||||
|
@ -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<Chapter>): Pair<List<Chapter>, List<Chapter>> {
|
||||
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<Manga> =
|
||||
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<Chapter>) {
|
||||
databaseHelper.insertChapters(chapters).executeAsBlocking()
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a list of chapters
|
||||
*/
|
||||
protected fun updateChapters(chapters: List<Chapter>) {
|
||||
databaseHelper.updateChaptersBackup(chapters).executeAsBlocking()
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a list of chapters with known database ids
|
||||
*/
|
||||
protected fun updateKnownChapters(chapters: List<Chapter>) {
|
||||
databaseHelper.updateKnownChaptersBackup(chapters).executeAsBlocking()
|
||||
}
|
||||
|
||||
/**
|
||||
* Return number of backups.
|
||||
*
|
||||
* @return number of backups selected by user
|
||||
*/
|
||||
protected fun numberOfBackups(): Int = preferences.numberOfBackups().get()
|
||||
}
|
@ -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<T : AbstractBackupManager>(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<Long, String> = emptyMap()
|
||||
|
||||
protected val errors = mutableListOf<Pair<Date, String>>()
|
||||
|
||||
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<Chapter>): Pair<List<Chapter>, List<Chapter>> {
|
||||
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<Track>) {
|
||||
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("")
|
||||
}
|
||||
}
|
@ -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<String>, val missingTrackers: List<String>)
|
||||
}
|
@ -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
|
||||
}
|
||||
|
@ -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<Uri>(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)
|
||||
}
|
||||
|
@ -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<PreferencesHelper>()
|
||||
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<PreferencesHelper>()
|
||||
val interval = prefInterval ?: preferences.backupInterval().getOrDefault()
|
||||
val interval = prefInterval ?: preferences.backupInterval().get()
|
||||
if (interval > 0) {
|
||||
val request = PeriodicWorkRequestBuilder<BackupCreatorJob>(
|
||||
interval.toLong(),
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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<String>()
|
||||
|
||||
/**
|
||||
* count of cancelled
|
||||
*/
|
||||
private var cancelled = 0
|
||||
|
||||
/**
|
||||
* List containing distinct errors
|
||||
*/
|
||||
private val trackingErrors = mutableListOf<String>()
|
||||
|
||||
/**
|
||||
* List containing missing sources
|
||||
*/
|
||||
private val sourcesMissing = mutableListOf<String>()
|
||||
|
||||
var extensionsMap: Map<String, String> = 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<Uri>(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<String,
|
||||
String> {
|
||||
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<MangaImpl>(obj.get(MANGA))
|
||||
val chapters = backupManager.parser.fromJson<List<ChapterImpl>>(obj.get(CHAPTERS) ?: JsonArray())
|
||||
val categories = backupManager.parser.fromJson<List<String>>(obj.get(CATEGORIES) ?: JsonArray())
|
||||
val history = backupManager.parser.fromJson<List<DHistory>>(obj.get(HISTORY) ?: JsonArray())
|
||||
val tracks = backupManager.parser.fromJson<List<TrackImpl>>(obj.get(TRACK) ?: JsonArray())
|
||||
val 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<Track>) {
|
||||
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<Uri>(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
|
||||
}
|
||||
}
|
||||
|
@ -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<Manga>, flags: Int): List<BackupManga> {
|
||||
return mangas.map {
|
||||
backupMangaObject(it, flags)
|
||||
}
|
||||
}
|
||||
|
||||
private fun backupExtensionInfo(mangas: List<Manga>): List<BackupSource> {
|
||||
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<BackupCategory> {
|
||||
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<BackupCategory>) {
|
||||
// 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<Int>, backupCategories: List<BackupCategory>) {
|
||||
val dbCategories = databaseHelper.getCategories().executeAsBlocking()
|
||||
val mangaCategoriesToUpdate = ArrayList<MangaCategory>(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<BackupHistory>) {
|
||||
// List containing history to be updated
|
||||
val historyToBeUpdated = ArrayList<History>(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<Track>) {
|
||||
// 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<Track>()
|
||||
|
||||
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<Chapter>) {
|
||||
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) }
|
||||
}
|
||||
}
|
@ -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<FullBackupManager>(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<BackupCategory>) {
|
||||
db.inTransaction {
|
||||
backupManager.restoreCategories(backupCategories)
|
||||
}
|
||||
|
||||
restoreProgress += 1
|
||||
showRestoreProgress(restoreProgress, restoreAmount, context.getString(R.string.categories))
|
||||
}
|
||||
|
||||
private fun restoreManga(backupManga: BackupManga, backupCategories: List<BackupCategory>) {
|
||||
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<Chapter>,
|
||||
categories: List<Int>,
|
||||
history: List<BackupHistory>,
|
||||
tracks: List<Track>,
|
||||
backupCategories: List<BackupCategory>
|
||||
) {
|
||||
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<Chapter>,
|
||||
categories: List<Int>,
|
||||
history: List<BackupHistory>,
|
||||
tracks: List<Track>,
|
||||
backupCategories: List<BackupCategory>
|
||||
) {
|
||||
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<Chapter>,
|
||||
categories: List<Int>,
|
||||
history: List<BackupHistory>,
|
||||
tracks: List<Track>,
|
||||
backupCategories: List<BackupCategory>
|
||||
) {
|
||||
backupManager.restoreChaptersForManga(backupManga, chapters)
|
||||
|
||||
restoreExtraForManga(backupManga, categories, history, tracks, backupCategories)
|
||||
}
|
||||
|
||||
private fun restoreExtraForManga(manga: Manga, categories: List<Int>, history: List<BackupHistory>, tracks: List<Track>, backupCategories: List<BackupCategory>) {
|
||||
// Restore categories
|
||||
backupManager.restoreCategoriesForManga(manga, categories, backupCategories)
|
||||
|
||||
// Restore history
|
||||
backupManager.restoreHistoryForManga(history)
|
||||
|
||||
// Restore tracking
|
||||
backupManager.restoreTrackForManga(manga, tracks)
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
@ -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<BackupManga>,
|
||||
@ProtoNumber(2) var backupCategories: List<BackupCategory> = emptyList(),
|
||||
// Bump by 100 to specify this is a 0.x value
|
||||
@ProtoNumber(100) var backupSources: List<BackupSource> = emptyList(),
|
||||
)
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -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"
|
||||
}
|
||||
}
|
@ -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
|
||||
)
|
@ -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<String> = 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<BackupChapter> = emptyList(),
|
||||
@ProtoNumber(17) var categories: List<Int> = emptyList(),
|
||||
@ProtoNumber(18) var tracking: List<BackupTracking> = 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<BackupHistory> = 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<ChapterImpl> {
|
||||
return chapters.map {
|
||||
it.toChapterImpl()
|
||||
}
|
||||
}
|
||||
|
||||
fun getTrackingImpl(): List<TrackImpl> {
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
package eu.kanade.tachiyomi.data.backup.full.models
|
||||
|
||||
import kotlinx.serialization.Serializer
|
||||
|
||||
@Serializer(forClass = Backup::class)
|
||||
object BackupSerializer
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -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<MangaImpl>(MangaTypeAdapter.build())
|
||||
@ -111,7 +62,7 @@ class BackupManager(val context: Context, version: Int = CURRENT_VERSION) {
|
||||
.registerTypeAdapter<DHistory>(HistoryTypeAdapter.build())
|
||||
.registerTypeHierarchyAdapter<TrackImpl>(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<String> = 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<Chapter>) {
|
||||
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<String, String> {
|
||||
val extensionsList = parser.fromJson<List<String>>(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<String>) {
|
||||
val dbCategories = databaseHelper.getCategories().executeAsBlocking()
|
||||
val mangaCategoriesToUpdate = ArrayList<MangaCategory>()
|
||||
val mangaCategoriesToUpdate = ArrayList<MangaCategory>(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<Manga>()
|
||||
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<DHistory>) {
|
||||
// List containing history to be updated
|
||||
val historyToBeUpdated = ArrayList<History>()
|
||||
val historyToBeUpdated = ArrayList<History>(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<Track>) {
|
||||
// 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<Track>()
|
||||
val trackToUpdate = ArrayList<Track>(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<Manga> =
|
||||
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<Chapter>) {
|
||||
databaseHelper.updateChaptersBackup(chapters).executeAsBlocking()
|
||||
}
|
||||
|
||||
/**
|
||||
* Return number of backups.
|
||||
*
|
||||
* @return number of backups selected by user
|
||||
*/
|
||||
fun numberOfBackups(): Int = preferences.numberOfBackups().getOrDefault()
|
||||
}
|
@ -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<LegacyBackupManager>(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<MangaImpl>(
|
||||
mangaJson.get(
|
||||
Backup.MANGA
|
||||
)
|
||||
)
|
||||
val chapters = backupManager.parser.fromJson<List<ChapterImpl>>(
|
||||
mangaJson.get(Backup.CHAPTERS)
|
||||
?: JsonArray()
|
||||
)
|
||||
val categories = backupManager.parser.fromJson<List<String>>(
|
||||
mangaJson.get(Backup.CATEGORIES)
|
||||
?: JsonArray()
|
||||
)
|
||||
val history = backupManager.parser.fromJson<List<DHistory>>(
|
||||
mangaJson.get(Backup.HISTORY)
|
||||
?: JsonArray()
|
||||
)
|
||||
val tracks = backupManager.parser.fromJson<List<TrackImpl>>(
|
||||
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<Chapter>,
|
||||
categories: List<String>,
|
||||
history: List<DHistory>,
|
||||
tracks: List<Track>
|
||||
) {
|
||||
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<Chapter>,
|
||||
categories: List<String>,
|
||||
history: List<DHistory>,
|
||||
tracks: List<Track>
|
||||
) {
|
||||
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<Chapter>,
|
||||
categories: List<String>,
|
||||
history: List<DHistory>,
|
||||
tracks: List<Track>
|
||||
) {
|
||||
if (!backupManager.restoreChaptersForManga(backupManga, chapters)) {
|
||||
updateChapters(source, backupManga, chapters)
|
||||
}
|
||||
|
||||
restoreExtraForManga(backupManga, categories, history, tracks)
|
||||
|
||||
updateTracking(backupManga, tracks)
|
||||
}
|
||||
|
||||
private fun restoreExtraForManga(manga: Manga, categories: List<String>, history: List<DHistory>, tracks: List<Track>) {
|
||||
// Restore categories
|
||||
backupManager.restoreCategoriesForManga(manga, categories)
|
||||
|
||||
// Restore history
|
||||
backupManager.restoreHistoryForManga(history)
|
||||
|
||||
// Restore tracking
|
||||
backupManager.restoreTrackForManga(manga, tracks)
|
||||
}
|
||||
}
|
@ -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<Long, String> {
|
||||
val extensionsMapping = json.get(Backup.EXTENSIONS) ?: return emptyMap()
|
||||
|
||||
return extensionsMapping.asJsonArray
|
||||
.map {
|
||||
val items = it.asString.split(":")
|
||||
items[0].toLong() to items[1]
|
||||
}
|
||||
.toMap()
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
@ -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)
|
@ -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
|
@ -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
|
@ -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
|
@ -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()
|
||||
}
|
@ -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
|
@ -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<String>? {
|
||||
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
|
||||
)
|
||||
}
|
||||
|
@ -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<Chapter>) = db.put()
|
||||
.objects(chapters)
|
||||
.withPutResolver(ChapterKnownBackupPutResolver())
|
||||
.prepare()
|
||||
|
||||
fun updateChapterProgress(chapter: Chapter) = db.put()
|
||||
.`object`(chapter)
|
||||
.withPutResolver(ChapterProgressPutResolver())
|
||||
|
@ -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<Chapter>() {
|
||||
|
||||
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
|
||||
)
|
||||
}
|
@ -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)
|
||||
}
|
||||
|
@ -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"
|
||||
|
@ -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 <T> Preference<T>.getOrDefault(): T = get() ?: defaultValue()!!
|
||||
|
||||
fun <T> com.tfcporciuncula.flow.Preference<T>.asImmediateFlow(block: (value: T) -> Unit): Flow<T> {
|
||||
block(get())
|
||||
return asFlow()
|
||||
.onEach { block(it) }
|
||||
}
|
||||
fun Preference<Boolean>.invert(): Boolean = getOrDefault().let { set(!it); !it }
|
||||
|
||||
private class DateFormatConverter : Preference.Adapter<DateFormat> {
|
||||
@ -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)
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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()
|
||||
|
||||
|
@ -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()
|
||||
|
||||
|
@ -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()
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -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()
|
||||
|
||||
|
@ -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.
|
||||
|
@ -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<List<Page>>
|
||||
|
||||
/**
|
||||
* [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<ChapterInfo> {
|
||||
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<tachiyomi.source.model.Page> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
fetchPageList(chapter.toSChapter()).toBlocking().single()
|
||||
.map { it.toPageUrl() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun Source.fetchMangaDetailsAsync(manga: SManga): SManga? {
|
||||
|
@ -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
|
||||
)
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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<TrackManager>().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(
|
||||
|
@ -31,7 +31,8 @@ class TrackHolder(view: View, adapter: TrackAdapter) : BaseViewHolder(view) {
|
||||
logo_container.updateLayoutParams<ConstraintLayout.LayoutParams> {
|
||||
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) {
|
||||
|
@ -42,10 +42,11 @@ class TrackRemoveDialog<T> : 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
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
)
|
||||
|
@ -0,0 +1,3 @@
|
||||
package eu.kanade.tachiyomi.util.chapter
|
||||
|
||||
class NoChaptersException : Exception()
|
@ -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
|
||||
}
|
||||
|
@ -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())
|
||||
}
|
||||
|
@ -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()
|
||||
|
@ -49,6 +49,7 @@
|
||||
<string name="removed_bookmark">Removed bookmark</string>
|
||||
<string name="chapters_removed">Chapters removed.</string>
|
||||
<string name="chapter_not_found">Chapter not found</string>
|
||||
<string name="no_chapters_error">No chapters found</string>
|
||||
<plurals name="remove_n_chapters">
|
||||
<item quantity="one">Remove %1$d downloaded chapter?</item>
|
||||
<item quantity="other">Remove %1$d downloaded chapters?</item>
|
||||
@ -427,6 +428,11 @@
|
||||
<string name="add_tracking">Add tracking</string>
|
||||
<string name="remove_tracking">Remove tracking from app</string>
|
||||
<string name="remove_tracking_from_">Also remove from %1$s</string>
|
||||
<string name="anilist" translatable="false">AniList</string>
|
||||
<string name="myanimelist" translatable="false">MyAnimeList</string>
|
||||
<string name="kitsu" translatable="false">Kitsu</string>
|
||||
<string name="bangumi" translatable="false">Bangumi</string>
|
||||
<string name="shikimori" translatable="false">Shikimori</string>
|
||||
|
||||
<!-- Migration -->
|
||||
<string name="select_sources">Select sources</string>
|
||||
@ -503,23 +509,42 @@
|
||||
<string name="backup">Backup</string>
|
||||
<string name="create_backup">Create backup</string>
|
||||
<string name="can_be_used_to_restore">Can be used to restore current library</string>
|
||||
<string name="create_legacy_backup">Create legacy backup</string>
|
||||
<string name="can_be_used_in_older_tachi">Can be used in older versions of Tachiyomi</string>
|
||||
<string name="restore_backup">Restore backup</string>
|
||||
<string name="restore_from_backup_file">Restore library from backup file</string>
|
||||
<string name="restore_in_progress">Restore already in progress</string>
|
||||
<string name="backup_in_progress">Backup already in progress</string>
|
||||
<string name="backup_location">Backup location</string>
|
||||
<string name="automatic_backups">Automatic backups</string>
|
||||
<string name="service">Service</string>
|
||||
<string name="backup_frequency">Backup frequency</string>
|
||||
<string name="max_auto_backups">Max automatic backups</string>
|
||||
<string name="also_create_legacy_backup">Also create legacy backup</string>
|
||||
<string name="invalid_backup_file">Invalid backup file</string>
|
||||
<string name="invalid_backup_file_type">Invalid backup file type: %1$s\nIt should end with ".proto.gz" or ".json".</string>
|
||||
<string name="file_is_missing_data">File is missing data.</string>
|
||||
<string name="backup_has_no_manga">Backup does not contain any manga.</string>
|
||||
<string name="backup_failed">Backup failed</string>
|
||||
<string name="backup_created">Backup created</string>
|
||||
<string name="restore_completed">Restore completed</string>
|
||||
<string name="restore_error">Restore error</string>
|
||||
<string name="restore_completed_content">%1$s Restored. %2$s errors found</string>
|
||||
<string name="restore_error">Failed to restore backup</string>
|
||||
<string name="restore_content_skipped">%1$d skipped</string>
|
||||
<string name="restore_message">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.</string>
|
||||
<string name="restore_missing_sources">Missing sources:</string>
|
||||
<string name="restore_missing_trackers">Trackers not logged into:</string>
|
||||
<string name="restore_content">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.</string>
|
||||
<string name="restore_content_full">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.</string>
|
||||
<string name="restoring_backup_canceled">Canceled restore</string>
|
||||
<string name="creating_backup">Creating backup</string>
|
||||
<string name="what_should_backup">What do you want to backup?</string>
|
||||
<string name="restoring_backup">Restoring backup</string>
|
||||
<string name="restoring_progress">Restoring (%1$d/%2$d)</string>
|
||||
<string name="restore_duration">%02d min, %02d sec</string>
|
||||
<plurals name="restore_completed_message">
|
||||
<item quantity="one">Done in %1$s with %2$s error</item>
|
||||
<item quantity="other">Done in %1$s with %2$s errors</item>
|
||||
</plurals>
|
||||
<plurals name="sources_missing">
|
||||
<item quantity="one">%d source missing</item>
|
||||
<item quantity="other">%d sources missing</item>
|
||||
@ -682,6 +707,7 @@
|
||||
<!-- File Picker Titles -->
|
||||
<string name="select_cover_image">Select cover image</string>
|
||||
<string name="select_backup_file">Select backup file</string>
|
||||
<string name="file_picker_error">No file picker app found</string>
|
||||
|
||||
<!-- Webview -->
|
||||
<string name="failed_to_bypass_cloudflare">Failed to bypass Cloudflare</string>
|
||||
@ -744,6 +770,7 @@
|
||||
<string name="normal">Normal</string>
|
||||
<string name="oldest">Oldest</string>
|
||||
<string name="open_in_browser">Open in browser</string>
|
||||
<string name="open_log">Open log</string>
|
||||
<string name="open_in_webview">Open in WebView</string>
|
||||
<string name="options">Options</string>
|
||||
<string name="pause">Pause</string>
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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()
|
||||
}
|
||||
|
2
gradle/wrapper/gradle-wrapper.properties
vendored
2
gradle/wrapper/gradle-wrapper.properties
vendored
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user