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:
Jays2Kings 2021-03-21 00:00:55 -04:00
parent e5d15894dc
commit 434926351c
64 changed files with 2339 additions and 901 deletions

View File

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

View File

@ -78,7 +78,7 @@ object Migrations {
UpdaterJob.setupTask()
}
LibraryUpdateJob.setupTask()
BackupCreatorJob.setupTask()
BackupCreatorJob.setupTask(context)
ExtensionUpdateJob.setupTask()
}
if (oldVersion < 66) {

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,6 @@
package eu.kanade.tachiyomi.data.backup.full.models
import kotlinx.serialization.Serializer
@Serializer(forClass = Backup::class)
object BackupSerializer

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,3 @@
package eu.kanade.tachiyomi.util.chapter
class NoChaptersException : Exception()

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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