Compare commits
20 Commits
Author | SHA1 | Date | |
---|---|---|---|
91cb892c74 | |||
a26f908370 | |||
4d14f56fa8 | |||
d9a2255be9 | |||
5e3d71c6c5 | |||
619d94bf36 | |||
6069659e0f | |||
f6a79bde6f | |||
bb9e230b35 | |||
bc9417e16b | |||
a4313d388d | |||
4ebb3a894d | |||
0642889b64 | |||
3094d084d6 | |||
f9fec74ffd | |||
8ef3ab0d49 | |||
e9a6f8ef46 | |||
68724752f8 | |||
de8fa09366 | |||
e619870eec |
@ -38,8 +38,8 @@ android {
|
||||
minSdkVersion 16
|
||||
targetSdkVersion 25
|
||||
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
|
||||
versionCode 21
|
||||
versionName "0.5.1"
|
||||
versionCode 22
|
||||
versionName "0.5.2"
|
||||
|
||||
buildConfigField "String", "COMMIT_COUNT", "\"${getCommitCount()}\""
|
||||
buildConfigField "String", "COMMIT_SHA", "\"${getGitSha()}\""
|
||||
@ -105,7 +105,7 @@ dependencies {
|
||||
compile 'com.github.inorichi:junrar-android:634c1f5'
|
||||
|
||||
// Android support library
|
||||
final support_library_version = '25.2.0'
|
||||
final support_library_version = '25.3.1'
|
||||
compile "com.android.support:support-v4:$support_library_version"
|
||||
compile "com.android.support:appcompat-v7:$support_library_version"
|
||||
compile "com.android.support:cardview-v7:$support_library_version"
|
||||
@ -120,7 +120,7 @@ dependencies {
|
||||
|
||||
// ReactiveX
|
||||
compile 'io.reactivex:rxandroid:1.2.1'
|
||||
compile 'io.reactivex:rxjava:1.2.7'
|
||||
compile 'io.reactivex:rxjava:1.2.9'
|
||||
compile 'com.jakewharton.rxrelay:rxrelay:1.2.0'
|
||||
compile 'com.f2prateek.rx.preferences:rx-preferences:1.0.2'
|
||||
compile 'com.github.pwittchen:reactivenetwork:0.7.0'
|
||||
@ -153,7 +153,7 @@ dependencies {
|
||||
compile 'org.jsoup:jsoup:1.10.2'
|
||||
|
||||
// Job scheduling
|
||||
compile 'com.evernote:android-job:1.1.7'
|
||||
compile 'com.evernote:android-job:1.1.8'
|
||||
compile 'com.google.android.gms:play-services-gcm:10.2.0'
|
||||
|
||||
// Changelog
|
||||
@ -175,7 +175,7 @@ dependencies {
|
||||
compile 'com.github.bumptech.glide:glide:3.7.0'
|
||||
compile 'com.github.bumptech.glide:okhttp3-integration:1.4.0@aar'
|
||||
// Transformations
|
||||
compile 'jp.wasabeef:glide-transformations:2.0.1'
|
||||
compile 'jp.wasabeef:glide-transformations:2.0.2'
|
||||
|
||||
// Logging
|
||||
compile 'com.jakewharton.timber:timber:4.5.1'
|
||||
@ -193,7 +193,7 @@ dependencies {
|
||||
compile 'com.github.inorichi:FlexibleAdapter:93985fe' // v4.2.0 to be removed
|
||||
compile 'com.nononsenseapps:filepicker:2.5.2'
|
||||
compile 'com.github.amulyakhare:TextDrawable:558677e'
|
||||
compile 'com.afollestad.material-dialogs:core:0.9.4.1'
|
||||
compile 'com.afollestad.material-dialogs:core:0.9.4.2'
|
||||
compile 'net.xpece.android:support-preference:1.2.5'
|
||||
compile 'me.zhanghai.android.systemuihelper:library:1.0.0'
|
||||
compile 'de.hdodenhof:circleimageview:2.1.0'
|
||||
@ -224,3 +224,50 @@ buildscript {
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
// Workaround to force a support lib version
|
||||
configurations.all {
|
||||
resolutionStrategy.eachDependency { details ->
|
||||
def requested = details.requested
|
||||
if (requested.group == 'com.android.support') {
|
||||
if (!requested.name.startsWith("multidex")) {
|
||||
details.useVersion '25.3.1'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// add support for placeholders in resource files
|
||||
//https://code.google.com/p/android/issues/detail?id=69224
|
||||
def replacePlaceholdersInFile(basePath, fileName, placeholders) {
|
||||
def file = new File(basePath, fileName);
|
||||
|
||||
if (!file.exists()) {
|
||||
logger.quiet("Unable to replace placeholders in " + file.toString() + ". File cannot be found.")
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug("Replacing placeholders in " + file.toString())
|
||||
logger.debug("Placeholders: " + placeholders.toString())
|
||||
|
||||
def content = file.getText('UTF-8')
|
||||
|
||||
placeholders.each { entry ->
|
||||
content = content.replaceAll("\\\$\\{${entry.key}\\}", entry.value)
|
||||
}
|
||||
|
||||
file.write(content, 'UTF-8')
|
||||
}
|
||||
|
||||
afterEvaluate {
|
||||
android.applicationVariants.all { variant ->
|
||||
variant.outputs.each { output ->
|
||||
output.processResources.doFirst {
|
||||
// prepare placeholder map from manifestPlaceholders including applicationId placeholder
|
||||
def placeholders = variant.mergedFlavor.manifestPlaceholders + [applicationId: variant.applicationId]
|
||||
|
||||
replacePlaceholdersInFile(resDir, 'xml-v25/shortcuts.xml', placeholders)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -19,6 +19,7 @@
|
||||
android:allowBackup="true"
|
||||
android:hardwareAccelerated="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:label="@string/app_name"
|
||||
android:largeHeap="true"
|
||||
android:theme="@style/Theme.Tachiyomi">
|
||||
@ -28,6 +29,8 @@
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.app.shortcuts"
|
||||
android:resource="@xml/shortcuts"/>
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".ui.manga.MangaActivity"
|
||||
@ -45,7 +48,7 @@
|
||||
android:label="@string/label_categories"
|
||||
android:parentActivityName=".ui.main.MainActivity" />
|
||||
<activity
|
||||
android:name=".ui.setting.SettingsDownloadsFragment$CustomLayoutPickerActivity"
|
||||
android:name=".widget.CustomLayoutPickerActivity"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/FilePickerTheme" />
|
||||
<activity
|
||||
@ -102,6 +105,14 @@
|
||||
android:name=".data.updater.UpdateDownloaderService"
|
||||
android:exported="false" />
|
||||
|
||||
<service
|
||||
android:name=".data.backup.BackupCreateService"
|
||||
android:exported="false"/>
|
||||
|
||||
<service
|
||||
android:name=".data.backup.BackupRestoreService"
|
||||
android:exported="false"/>
|
||||
|
||||
<meta-data
|
||||
android:name="eu.kanade.tachiyomi.data.glide.AppGlideModule"
|
||||
android:value="GlideModule" />
|
||||
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 22 KiB |
@ -5,6 +5,7 @@ import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.support.multidex.MultiDex
|
||||
import com.evernote.android.job.JobManager
|
||||
import eu.kanade.tachiyomi.data.backup.BackupCreatorJob
|
||||
import eu.kanade.tachiyomi.data.library.LibraryUpdateJob
|
||||
import eu.kanade.tachiyomi.data.updater.UpdateCheckerJob
|
||||
import eu.kanade.tachiyomi.util.LocaleHelper
|
||||
@ -58,6 +59,7 @@ open class App : Application() {
|
||||
when (tag) {
|
||||
LibraryUpdateJob.TAG -> LibraryUpdateJob()
|
||||
UpdateCheckerJob.TAG -> UpdateCheckerJob()
|
||||
BackupCreatorJob.TAG -> BackupCreatorJob()
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
@ -1,10 +1,10 @@
|
||||
package eu.kanade.tachiyomi
|
||||
|
||||
object Constants {
|
||||
const val NOTIFICATION_LIBRARY_ID = 1
|
||||
const val NOTIFICATION_UPDATER_ID = 2
|
||||
const val NOTIFICATION_DOWNLOAD_CHAPTER_ID = 3
|
||||
const val NOTIFICATION_DOWNLOAD_CHAPTER_ERROR_ID = 4
|
||||
const val NOTIFICATION_DOWNLOAD_IMAGE_ID = 5
|
||||
|
||||
const val NOTIFICATION_LIBRARY_PROGRESS_ID = 1
|
||||
const val NOTIFICATION_LIBRARY_RESULT_ID = 2
|
||||
const val NOTIFICATION_UPDATER_ID = 3
|
||||
const val NOTIFICATION_DOWNLOAD_CHAPTER_ID = 4
|
||||
const val NOTIFICATION_DOWNLOAD_CHAPTER_ERROR_ID = 5
|
||||
const val NOTIFICATION_DOWNLOAD_IMAGE_ID = 6
|
||||
}
|
||||
|
@ -0,0 +1,171 @@
|
||||
package eu.kanade.tachiyomi.data.backup
|
||||
|
||||
import android.app.IntentService
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import com.github.salomonbrys.kotson.set
|
||||
import com.google.gson.JsonArray
|
||||
import com.google.gson.JsonObject
|
||||
import com.hippo.unifile.UniFile
|
||||
import eu.kanade.tachiyomi.data.backup.models.Backup
|
||||
import eu.kanade.tachiyomi.data.backup.models.Backup.CATEGORIES
|
||||
import eu.kanade.tachiyomi.data.backup.models.Backup.MANGAS
|
||||
import eu.kanade.tachiyomi.data.backup.models.Backup.VERSION
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.ui.setting.SettingsBackupFragment
|
||||
import eu.kanade.tachiyomi.util.AndroidComponentUtil
|
||||
import eu.kanade.tachiyomi.util.sendLocalBroadcast
|
||||
import timber.log.Timber
|
||||
import eu.kanade.tachiyomi.BuildConfig.APPLICATION_ID as ID
|
||||
|
||||
/**
|
||||
* [IntentService] used to backup [Manga] information to [JsonArray]
|
||||
*/
|
||||
class BackupCreateService : IntentService(NAME) {
|
||||
|
||||
companion object {
|
||||
// Name of class
|
||||
private const val NAME = "BackupCreateService"
|
||||
|
||||
// Uri as string
|
||||
private const val EXTRA_URI = "$ID.$NAME.EXTRA_URI"
|
||||
// Backup called from job
|
||||
private const val EXTRA_IS_JOB = "$ID.$NAME.EXTRA_IS_JOB"
|
||||
// Options for backup
|
||||
private const val EXTRA_FLAGS = "$ID.$NAME.EXTRA_FLAGS"
|
||||
|
||||
// Filter options
|
||||
internal const val BACKUP_CATEGORY = 0x1
|
||||
internal const val BACKUP_CATEGORY_MASK = 0x1
|
||||
internal const val BACKUP_CHAPTER = 0x2
|
||||
internal const val BACKUP_CHAPTER_MASK = 0x2
|
||||
internal const val BACKUP_HISTORY = 0x4
|
||||
internal const val BACKUP_HISTORY_MASK = 0x4
|
||||
internal const val BACKUP_TRACK = 0x8
|
||||
internal const val BACKUP_TRACK_MASK = 0x8
|
||||
internal const val BACKUP_ALL = 0xF
|
||||
|
||||
/**
|
||||
* Make a backup from library
|
||||
*
|
||||
* @param context context of application
|
||||
* @param path path of Uri
|
||||
* @param flags determines what to backup
|
||||
* @param isJob backup called from job
|
||||
*/
|
||||
fun makeBackup(context: Context, path: String, flags: Int, isJob: Boolean = false) {
|
||||
val intent = Intent(context, BackupCreateService::class.java).apply {
|
||||
putExtra(EXTRA_URI, path)
|
||||
putExtra(EXTRA_IS_JOB, isJob)
|
||||
putExtra(EXTRA_FLAGS, flags)
|
||||
}
|
||||
context.startService(intent)
|
||||
}
|
||||
|
||||
fun isRunning(context: Context): Boolean {
|
||||
return AndroidComponentUtil.isServiceRunning(context, BackupCreateService::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
private val backupManager by lazy { BackupManager(this) }
|
||||
|
||||
override fun onHandleIntent(intent: Intent?) {
|
||||
if (intent == null) return
|
||||
|
||||
// Get values
|
||||
val uri = intent.getStringExtra(EXTRA_URI)
|
||||
val isJob = intent.getBooleanExtra(EXTRA_IS_JOB, false)
|
||||
val flags = intent.getIntExtra(EXTRA_FLAGS, 0)
|
||||
// Create backup
|
||||
createBackupFromApp(Uri.parse(uri), flags, isJob)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create backup Json file from database
|
||||
*
|
||||
* @param uri path of Uri
|
||||
* @param isJob backup called from job
|
||||
*/
|
||||
fun createBackupFromApp(uri: Uri, flags: Int, isJob: Boolean) {
|
||||
// Create root object
|
||||
val root = JsonObject()
|
||||
|
||||
// Create information object
|
||||
val information = JsonObject()
|
||||
|
||||
// Create manga array
|
||||
val mangaEntries = JsonArray()
|
||||
|
||||
// Create category array
|
||||
val categoryEntries = JsonArray()
|
||||
|
||||
// Add value's to root
|
||||
root[VERSION] = Backup.CURRENT_VERSION
|
||||
root[MANGAS] = mangaEntries
|
||||
root[CATEGORIES] = categoryEntries
|
||||
|
||||
backupManager.databaseHelper.inTransaction {
|
||||
// Get manga from database
|
||||
val mangas = backupManager.getFavoriteManga()
|
||||
|
||||
// Backup library manga and its dependencies
|
||||
mangas.forEach { manga ->
|
||||
mangaEntries.add(backupManager.backupMangaObject(manga, flags))
|
||||
}
|
||||
|
||||
// Backup categories
|
||||
if ((flags and BACKUP_CATEGORY_MASK) == BACKUP_CATEGORY) {
|
||||
backupManager.backupCategories(categoryEntries)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// When BackupCreatorJob
|
||||
if (isJob) {
|
||||
// Get dir of file
|
||||
val dir = UniFile.fromUri(this, uri)
|
||||
|
||||
// Delete older backups
|
||||
val numberOfBackups = backupManager.numberOfBackups()
|
||||
val backupRegex = Regex("""tachiyomi_\d+-\d+-\d+_\d+-\d+.json""")
|
||||
dir.listFiles { _, filename -> backupRegex.matches(filename) }
|
||||
.orEmpty()
|
||||
.sortedByDescending { it.name }
|
||||
.drop(numberOfBackups - 1)
|
||||
.forEach { it.delete() }
|
||||
|
||||
// Create new file to place backup
|
||||
val newFile = dir.createFile(Backup.getDefaultFilename())
|
||||
?: throw Exception("Couldn't create backup file")
|
||||
|
||||
newFile.openOutputStream().bufferedWriter().use {
|
||||
backupManager.parser.toJson(root, it)
|
||||
}
|
||||
} else {
|
||||
val file = UniFile.fromUri(this, uri)
|
||||
?: throw Exception("Couldn't create backup file")
|
||||
file.openOutputStream().bufferedWriter().use {
|
||||
backupManager.parser.toJson(root, it)
|
||||
}
|
||||
|
||||
// Show completed dialog
|
||||
val intent = Intent(SettingsBackupFragment.INTENT_FILTER).apply {
|
||||
putExtra(SettingsBackupFragment.ACTION, SettingsBackupFragment.ACTION_BACKUP_COMPLETED_DIALOG)
|
||||
putExtra(SettingsBackupFragment.EXTRA_URI, file.uri.toString())
|
||||
}
|
||||
sendLocalBroadcast(intent)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e)
|
||||
if (!isJob) {
|
||||
// Show error dialog
|
||||
val intent = Intent(SettingsBackupFragment.INTENT_FILTER).apply {
|
||||
putExtra(SettingsBackupFragment.ACTION, SettingsBackupFragment.ACTION_ERROR_BACKUP_DIALOG)
|
||||
putExtra(SettingsBackupFragment.EXTRA_ERROR_MESSAGE, e.message)
|
||||
}
|
||||
sendLocalBroadcast(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,41 @@
|
||||
package eu.kanade.tachiyomi.data.backup
|
||||
|
||||
import com.evernote.android.job.Job
|
||||
import com.evernote.android.job.JobManager
|
||||
import com.evernote.android.job.JobRequest
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
|
||||
class BackupCreatorJob : Job() {
|
||||
|
||||
override fun onRunJob(params: Params): Result {
|
||||
val preferences = Injekt.get<PreferencesHelper>()
|
||||
val path = preferences.backupsDirectory().getOrDefault()
|
||||
val flags = BackupCreateService.BACKUP_ALL
|
||||
BackupCreateService.makeBackup(context,path,flags,true)
|
||||
return Result.SUCCESS
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TAG = "BackupCreator"
|
||||
|
||||
fun setupTask(prefInterval: Int? = null) {
|
||||
val preferences = Injekt.get<PreferencesHelper>()
|
||||
val interval = prefInterval ?: preferences.backupInterval().getOrDefault()
|
||||
if (interval > 0) {
|
||||
JobRequest.Builder(TAG)
|
||||
.setPeriodic(interval * 60 * 60 * 1000L, 10 * 60 * 1000)
|
||||
.setPersisted(true)
|
||||
.setUpdateCurrent(true)
|
||||
.build()
|
||||
.schedule()
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelTask() {
|
||||
JobManager.instance().cancelAllForTag(TAG)
|
||||
}
|
||||
}
|
||||
}
|
@ -1,203 +1,213 @@
|
||||
package eu.kanade.tachiyomi.data.backup
|
||||
|
||||
import com.github.salomonbrys.kotson.fromJson
|
||||
import android.content.Context
|
||||
import com.github.salomonbrys.kotson.*
|
||||
import com.google.gson.*
|
||||
import com.google.gson.stream.JsonReader
|
||||
import eu.kanade.tachiyomi.data.backup.serializer.BooleanSerializer
|
||||
import eu.kanade.tachiyomi.data.backup.serializer.IdExclusion
|
||||
import eu.kanade.tachiyomi.data.backup.serializer.IntegerSerializer
|
||||
import eu.kanade.tachiyomi.data.backup.serializer.LongSerializer
|
||||
import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_CATEGORY
|
||||
import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_CATEGORY_MASK
|
||||
import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_CHAPTER
|
||||
import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_CHAPTER_MASK
|
||||
import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_HISTORY
|
||||
import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_HISTORY_MASK
|
||||
import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_TRACK
|
||||
import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_TRACK_MASK
|
||||
import eu.kanade.tachiyomi.data.backup.models.Backup.CATEGORIES
|
||||
import eu.kanade.tachiyomi.data.backup.models.Backup.CHAPTERS
|
||||
import eu.kanade.tachiyomi.data.backup.models.Backup.CURRENT_VERSION
|
||||
import eu.kanade.tachiyomi.data.backup.models.Backup.HISTORY
|
||||
import eu.kanade.tachiyomi.data.backup.models.Backup.MANGA
|
||||
import eu.kanade.tachiyomi.data.backup.models.Backup.TRACK
|
||||
import eu.kanade.tachiyomi.data.backup.models.DHistory
|
||||
import eu.kanade.tachiyomi.data.backup.serializer.*
|
||||
import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
||||
import eu.kanade.tachiyomi.data.database.models.*
|
||||
import java.io.*
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.SourceManager
|
||||
import eu.kanade.tachiyomi.util.syncChaptersWithSource
|
||||
import rx.Observable
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* This class provides the necessary methods to create and restore backups for the data of the
|
||||
* application. The backup follows a JSON structure, with the following scheme:
|
||||
*
|
||||
* {
|
||||
* "mangas": [
|
||||
* {
|
||||
* "manga": {"id": 1, ...},
|
||||
* "chapters": [{"id": 1, ...}, {...}],
|
||||
* "sync": [{"id": 1, ...}, {...}],
|
||||
* "categories": ["cat1", "cat2", ...]
|
||||
* },
|
||||
* { ... }
|
||||
* ],
|
||||
* "categories": [
|
||||
* {"id": 1, ...},
|
||||
* {"id": 2, ...}
|
||||
* ]
|
||||
* }
|
||||
*
|
||||
* @param db the database helper.
|
||||
*/
|
||||
class BackupManager(private val db: DatabaseHelper) {
|
||||
|
||||
private val MANGA = "manga"
|
||||
private val MANGAS = "mangas"
|
||||
private val CHAPTERS = "chapters"
|
||||
private val TRACK = "sync"
|
||||
private val CATEGORIES = "categories"
|
||||
|
||||
@Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN")
|
||||
private val gson = GsonBuilder()
|
||||
.registerTypeAdapter(java.lang.Integer::class.java, IntegerSerializer())
|
||||
.registerTypeAdapter(java.lang.Boolean::class.java, BooleanSerializer())
|
||||
.registerTypeAdapter(java.lang.Long::class.java, LongSerializer())
|
||||
.setExclusionStrategies(IdExclusion())
|
||||
.create()
|
||||
class BackupManager(val context: Context, version: Int = CURRENT_VERSION) {
|
||||
|
||||
/**
|
||||
* Backups the data of the application to a file.
|
||||
*
|
||||
* @param file the file where the backup will be saved.
|
||||
* @throws IOException if there's any IO error.
|
||||
* Database.
|
||||
*/
|
||||
@Throws(IOException::class)
|
||||
fun backupToFile(file: File) {
|
||||
val root = backupToJson()
|
||||
internal val databaseHelper: DatabaseHelper by injectLazy()
|
||||
|
||||
FileWriter(file).use {
|
||||
gson.toJson(root, it)
|
||||
/**
|
||||
* Source manager.
|
||||
*/
|
||||
internal val sourceManager: SourceManager by injectLazy()
|
||||
|
||||
/**
|
||||
* Version of parser
|
||||
*/
|
||||
var version: Int = version
|
||||
private set
|
||||
|
||||
/**
|
||||
* Json Parser
|
||||
*/
|
||||
var parser: Gson = initParser()
|
||||
|
||||
/**
|
||||
* Preferences
|
||||
*/
|
||||
private val preferences: PreferencesHelper by injectLazy()
|
||||
|
||||
/**
|
||||
* Set version of parser
|
||||
*
|
||||
* @param version version of parser
|
||||
*/
|
||||
internal fun setVersion(version: Int) {
|
||||
this.version = version
|
||||
parser = initParser()
|
||||
}
|
||||
|
||||
private fun initParser(): Gson {
|
||||
return when (version) {
|
||||
1 -> GsonBuilder().create()
|
||||
2 -> GsonBuilder()
|
||||
.registerTypeAdapter<MangaImpl>(MangaTypeAdapter.build())
|
||||
.registerTypeHierarchyAdapter<ChapterImpl>(ChapterTypeAdapter.build())
|
||||
.registerTypeAdapter<CategoryImpl>(CategoryTypeAdapter.build())
|
||||
.registerTypeAdapter<DHistory>(HistoryTypeAdapter.build())
|
||||
.registerTypeHierarchyAdapter<TrackImpl>(TrackTypeAdapter.build())
|
||||
.create()
|
||||
else -> throw Exception("Json version unknown")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a JSON object containing the backup of the app's data.
|
||||
* Backup the categories of library
|
||||
*
|
||||
* @return the backup as a JSON object.
|
||||
* @param root root of categories json
|
||||
*/
|
||||
fun backupToJson(): JsonObject {
|
||||
val root = JsonObject()
|
||||
|
||||
// Backup library mangas and its dependencies
|
||||
val mangaEntries = JsonArray()
|
||||
root.add(MANGAS, mangaEntries)
|
||||
for (manga in db.getFavoriteMangas().executeAsBlocking()) {
|
||||
mangaEntries.add(backupManga(manga))
|
||||
}
|
||||
|
||||
// Backup categories
|
||||
val categoryEntries = JsonArray()
|
||||
root.add(CATEGORIES, categoryEntries)
|
||||
for (category in db.getCategories().executeAsBlocking()) {
|
||||
categoryEntries.add(backupCategory(category))
|
||||
}
|
||||
|
||||
return root
|
||||
internal fun backupCategories(root: JsonArray) {
|
||||
val categories = databaseHelper.getCategories().executeAsBlocking()
|
||||
categories.forEach { root.add(parser.toJsonTree(it)) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Backups a manga and its related data (chapters, categories this manga is in, sync...).
|
||||
* Convert a manga to Json
|
||||
*
|
||||
* @param manga the manga to backup.
|
||||
* @return a JSON object containing all the data of the manga.
|
||||
* @param manga manga that gets converted
|
||||
* @return [JsonElement] containing manga information
|
||||
*/
|
||||
private fun backupManga(manga: Manga): JsonObject {
|
||||
internal fun backupMangaObject(manga: Manga, options: Int): JsonElement {
|
||||
// Entry for this manga
|
||||
val entry = JsonObject()
|
||||
|
||||
// Backup manga fields
|
||||
entry.add(MANGA, gson.toJsonTree(manga))
|
||||
entry[MANGA] = parser.toJsonTree(manga)
|
||||
|
||||
// Backup all the chapters
|
||||
val chapters = db.getChapters(manga).executeAsBlocking()
|
||||
if (!chapters.isEmpty()) {
|
||||
entry.add(CHAPTERS, gson.toJsonTree(chapters))
|
||||
}
|
||||
|
||||
// Backup tracks
|
||||
val tracks = db.getTracks(manga).executeAsBlocking()
|
||||
if (!tracks.isEmpty()) {
|
||||
entry.add(TRACK, gson.toJsonTree(tracks))
|
||||
}
|
||||
|
||||
// Backup categories for this manga
|
||||
val categoriesForManga = db.getCategoriesForManga(manga).executeAsBlocking()
|
||||
if (!categoriesForManga.isEmpty()) {
|
||||
val categoriesNames = ArrayList<String>()
|
||||
for (category in categoriesForManga) {
|
||||
categoriesNames.add(category.name)
|
||||
// Check if user wants chapter information in backup
|
||||
if (options and BACKUP_CHAPTER_MASK == BACKUP_CHAPTER) {
|
||||
// Backup all the chapters
|
||||
val chapters = databaseHelper.getChapters(manga).executeAsBlocking()
|
||||
if (!chapters.isEmpty()) {
|
||||
val chaptersJson = parser.toJsonTree(chapters)
|
||||
if (chaptersJson.asJsonArray.size() > 0) {
|
||||
entry[CHAPTERS] = chaptersJson
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if user wants category information in backup
|
||||
if (options and BACKUP_CATEGORY_MASK == BACKUP_CATEGORY) {
|
||||
// Backup categories for this manga
|
||||
val categoriesForManga = databaseHelper.getCategoriesForManga(manga).executeAsBlocking()
|
||||
if (!categoriesForManga.isEmpty()) {
|
||||
val categoriesNames = categoriesForManga.map { it.name }
|
||||
entry[CATEGORIES] = parser.toJsonTree(categoriesNames)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if user wants track information in backup
|
||||
if (options and BACKUP_TRACK_MASK == BACKUP_TRACK) {
|
||||
val tracks = databaseHelper.getTracks(manga).executeAsBlocking()
|
||||
if (!tracks.isEmpty()) {
|
||||
entry[TRACK] = parser.toJsonTree(tracks)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if user wants history information in backup
|
||||
if (options and BACKUP_HISTORY_MASK == BACKUP_HISTORY) {
|
||||
val historyForManga = databaseHelper.getHistoryByMangaId(manga.id!!).executeAsBlocking()
|
||||
if (!historyForManga.isEmpty()) {
|
||||
val historyData = historyForManga.mapNotNull { history ->
|
||||
val url = databaseHelper.getChapter(history.chapter_id).executeAsBlocking()?.url
|
||||
url?.let { DHistory(url, history.last_read) }
|
||||
}
|
||||
val historyJson = parser.toJsonTree(historyData)
|
||||
if (historyJson.asJsonArray.size() > 0) {
|
||||
entry[HISTORY] = historyJson
|
||||
}
|
||||
}
|
||||
entry.add(CATEGORIES, gson.toJsonTree(categoriesNames))
|
||||
}
|
||||
|
||||
return entry
|
||||
}
|
||||
|
||||
/**
|
||||
* Backups a category.
|
||||
*
|
||||
* @param category the category to backup.
|
||||
* @return a JSON object containing the data of the category.
|
||||
*/
|
||||
private fun backupCategory(category: Category): JsonElement {
|
||||
return gson.toJsonTree(category)
|
||||
fun restoreMangaNoFetch(manga: Manga, dbManga: Manga) {
|
||||
manga.id = dbManga.id
|
||||
manga.copyFrom(dbManga)
|
||||
manga.favorite = true
|
||||
insertManga(manga)
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores a backup from a file.
|
||||
* [Observable] that fetches manga information
|
||||
*
|
||||
* @param file the file containing the backup.
|
||||
* @throws IOException if there's any IO error.
|
||||
* @param source source of manga
|
||||
* @param manga manga that needs updating
|
||||
* @return [Observable] that contains manga
|
||||
*/
|
||||
@Throws(IOException::class)
|
||||
fun restoreFromFile(file: File) {
|
||||
JsonReader(FileReader(file)).use {
|
||||
val root = JsonParser().parse(it).asJsonObject
|
||||
restoreFromJson(root)
|
||||
}
|
||||
fun restoreMangaFetchObservable(source: Source, manga: Manga): Observable<Manga> {
|
||||
return source.fetchMangaDetails(manga)
|
||||
.map { networkManga ->
|
||||
manga.copyFrom(networkManga)
|
||||
manga.favorite = true
|
||||
manga.initialized = true
|
||||
manga.id = insertManga(manga)
|
||||
manga
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores a backup from an input stream.
|
||||
* [Observable] that fetches chapter information
|
||||
*
|
||||
* @param stream the stream containing the backup.
|
||||
* @throws IOException if there's any IO error.
|
||||
* @param source source of manga
|
||||
* @param manga manga that needs updating
|
||||
* @return [Observable] that contains manga
|
||||
*/
|
||||
@Throws(IOException::class)
|
||||
fun restoreFromStream(stream: InputStream) {
|
||||
JsonReader(InputStreamReader(stream)).use {
|
||||
val root = JsonParser().parse(it).asJsonObject
|
||||
restoreFromJson(root)
|
||||
}
|
||||
fun restoreChapterFetchObservable(source: Source, manga: Manga, chapters: List<Chapter>): Observable<Pair<List<Chapter>, List<Chapter>>> {
|
||||
return source.fetchChapterList(manga)
|
||||
.map { syncChaptersWithSource(databaseHelper, it, manga, source) }
|
||||
.doOnNext {
|
||||
if (it.first.isNotEmpty()) {
|
||||
chapters.forEach { it.manga_id = manga.id }
|
||||
insertChapters(chapters)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores a backup from a JSON object. Everything executes in a single transaction so that
|
||||
* nothing is modified if there's an error.
|
||||
* Restore the categories from Json
|
||||
*
|
||||
* @param root the root of the JSON.
|
||||
* @param jsonCategories array containing categories
|
||||
*/
|
||||
fun restoreFromJson(root: JsonObject) {
|
||||
db.inTransaction {
|
||||
// Restore categories
|
||||
root.get(CATEGORIES)?.let {
|
||||
restoreCategories(it.asJsonArray)
|
||||
}
|
||||
|
||||
// Restore mangas
|
||||
root.get(MANGAS)?.let {
|
||||
restoreMangas(it.asJsonArray)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores the categories.
|
||||
*
|
||||
* @param jsonCategories the categories of the json.
|
||||
*/
|
||||
private fun restoreCategories(jsonCategories: JsonArray) {
|
||||
internal fun restoreCategories(jsonCategories: JsonArray) {
|
||||
// Get categories from file and from db
|
||||
val dbCategories = db.getCategories().executeAsBlocking()
|
||||
val backupCategories = gson.fromJson<List<CategoryImpl>>(jsonCategories)
|
||||
val dbCategories = databaseHelper.getCategories().executeAsBlocking()
|
||||
val backupCategories = parser.fromJson<List<CategoryImpl>>(jsonCategories)
|
||||
|
||||
// Iterate over them
|
||||
for (category in backupCategories) {
|
||||
backupCategories.forEach { category ->
|
||||
// Used to know if the category is already in the db
|
||||
var found = false
|
||||
for (dbCategory in dbCategories) {
|
||||
@ -214,102 +224,20 @@ class BackupManager(private val db: DatabaseHelper) {
|
||||
if (!found) {
|
||||
// Let the db assign the id
|
||||
category.id = null
|
||||
val result = db.insertCategory(category).executeAsBlocking()
|
||||
val result = databaseHelper.insertCategory(category).executeAsBlocking()
|
||||
category.id = result.insertedId()?.toInt()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores all the mangas and its related data.
|
||||
*
|
||||
* @param jsonMangas the mangas and its related data (chapters, sync, categories) from the json.
|
||||
*/
|
||||
private fun restoreMangas(jsonMangas: JsonArray) {
|
||||
for (backupManga in jsonMangas) {
|
||||
// Map every entry to objects
|
||||
val element = backupManga.asJsonObject
|
||||
val manga = gson.fromJson(element.get(MANGA), MangaImpl::class.java)
|
||||
val chapters = gson.fromJson<List<ChapterImpl>>(element.get(CHAPTERS) ?: JsonArray())
|
||||
val tracks = gson.fromJson<List<TrackImpl>>(element.get(TRACK) ?: JsonArray())
|
||||
val categories = gson.fromJson<List<String>>(element.get(CATEGORIES) ?: JsonArray())
|
||||
|
||||
// Restore everything related to this manga
|
||||
restoreManga(manga)
|
||||
restoreChaptersForManga(manga, chapters)
|
||||
restoreSyncForManga(manga, tracks)
|
||||
restoreCategoriesForManga(manga, categories)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores a manga.
|
||||
*
|
||||
* @param manga the manga to restore.
|
||||
*/
|
||||
private fun restoreManga(manga: Manga) {
|
||||
// Try to find existing manga in db
|
||||
val dbManga = db.getManga(manga.url, manga.source).executeAsBlocking()
|
||||
if (dbManga == null) {
|
||||
// Let the db assign the id
|
||||
manga.id = null
|
||||
val result = db.insertManga(manga).executeAsBlocking()
|
||||
manga.id = result.insertedId()
|
||||
} else {
|
||||
// If it exists already, we copy only the values related to the source from the db
|
||||
// (they can be up to date). Local values (flags) are kept from the backup.
|
||||
manga.id = dbManga.id
|
||||
manga.copyFrom(dbManga)
|
||||
manga.favorite = true
|
||||
db.insertManga(manga).executeAsBlocking()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores the chapters of a manga.
|
||||
*
|
||||
* @param manga the manga whose chapters have to be restored.
|
||||
* @param chapters the chapters to restore.
|
||||
*/
|
||||
private fun restoreChaptersForManga(manga: Manga, chapters: List<Chapter>) {
|
||||
// Fix foreign keys with the current manga id
|
||||
for (chapter in chapters) {
|
||||
chapter.manga_id = manga.id
|
||||
}
|
||||
|
||||
val dbChapters = db.getChapters(manga).executeAsBlocking()
|
||||
val chaptersToUpdate = ArrayList<Chapter>()
|
||||
for (backupChapter in chapters) {
|
||||
// Try to find existing chapter in db
|
||||
val pos = dbChapters.indexOf(backupChapter)
|
||||
if (pos != -1) {
|
||||
// The chapter is already in the db, only update its fields
|
||||
val dbChapter = dbChapters[pos]
|
||||
// If one of them was read, the chapter will be marked as read
|
||||
dbChapter.read = backupChapter.read || dbChapter.read
|
||||
dbChapter.last_page_read = Math.max(backupChapter.last_page_read, dbChapter.last_page_read)
|
||||
chaptersToUpdate.add(dbChapter)
|
||||
} else {
|
||||
// Insert new chapter. Let the db assign the id
|
||||
backupChapter.id = null
|
||||
chaptersToUpdate.add(backupChapter)
|
||||
}
|
||||
}
|
||||
|
||||
// Update database
|
||||
if (!chaptersToUpdate.isEmpty()) {
|
||||
db.insertChapters(chaptersToUpdate).executeAsBlocking()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores the categories a manga is in.
|
||||
*
|
||||
* @param manga the manga whose categories have to be restored.
|
||||
* @param categories the categories to restore.
|
||||
*/
|
||||
private fun restoreCategoriesForManga(manga: Manga, categories: List<String>) {
|
||||
val dbCategories = db.getCategories().executeAsBlocking()
|
||||
internal fun restoreCategoriesForManga(manga: Manga, categories: List<String>) {
|
||||
val dbCategories = databaseHelper.getCategories().executeAsBlocking()
|
||||
val mangaCategoriesToUpdate = ArrayList<MangaCategory>()
|
||||
for (backupCategoryStr in categories) {
|
||||
for (dbCategory in dbCategories) {
|
||||
@ -324,45 +252,151 @@ class BackupManager(private val db: DatabaseHelper) {
|
||||
if (!mangaCategoriesToUpdate.isEmpty()) {
|
||||
val mangaAsList = ArrayList<Manga>()
|
||||
mangaAsList.add(manga)
|
||||
db.deleteOldMangasCategories(mangaAsList).executeAsBlocking()
|
||||
db.insertMangasCategories(mangaCategoriesToUpdate).executeAsBlocking()
|
||||
databaseHelper.deleteOldMangasCategories(mangaAsList).executeAsBlocking()
|
||||
databaseHelper.insertMangasCategories(mangaCategoriesToUpdate).executeAsBlocking()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore history from Json
|
||||
*
|
||||
* @param history list containing history to be restored
|
||||
*/
|
||||
internal fun restoreHistoryForManga(history: List<DHistory>) {
|
||||
// List containing history to be updated
|
||||
val historyToBeUpdated = ArrayList<History>()
|
||||
for ((url, lastRead) in history) {
|
||||
val dbHistory = databaseHelper.getHistoryByChapterUrl(url).executeAsBlocking()
|
||||
// Check if history already in database and update
|
||||
if (dbHistory != null) {
|
||||
dbHistory.apply {
|
||||
last_read = Math.max(lastRead, dbHistory.last_read)
|
||||
}
|
||||
historyToBeUpdated.add(dbHistory)
|
||||
} else {
|
||||
// If not in database create
|
||||
databaseHelper.getChapter(url).executeAsBlocking()?.let {
|
||||
val historyToAdd = History.create(it).apply {
|
||||
last_read = lastRead
|
||||
}
|
||||
historyToBeUpdated.add(historyToAdd)
|
||||
}
|
||||
}
|
||||
}
|
||||
databaseHelper.updateHistoryLastRead(historyToBeUpdated).executeAsBlocking()
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores the sync of a manga.
|
||||
*
|
||||
* @param manga the manga whose sync have to be restored.
|
||||
* @param tracks the track list to restore.
|
||||
*/
|
||||
private fun restoreSyncForManga(manga: Manga, tracks: List<Track>) {
|
||||
internal fun restoreTrackForManga(manga: Manga, tracks: List<Track>) {
|
||||
// Fix foreign keys with the current manga id
|
||||
for (track in tracks) {
|
||||
track.manga_id = manga.id!!
|
||||
}
|
||||
tracks.map { it.manga_id = manga.id!! }
|
||||
|
||||
val dbTracks = db.getTracks(manga).executeAsBlocking()
|
||||
// Get tracks from database
|
||||
val dbTracks = databaseHelper.getTracks(manga).executeAsBlocking()
|
||||
val trackToUpdate = ArrayList<Track>()
|
||||
for (backupTrack in tracks) {
|
||||
// Try to find existing chapter in db
|
||||
val pos = dbTracks.indexOf(backupTrack)
|
||||
if (pos != -1) {
|
||||
// The sync is already in the db, only update its fields
|
||||
val dbSync = dbTracks[pos]
|
||||
// Mark the max chapter as read and nothing else
|
||||
dbSync.last_chapter_read = Math.max(backupTrack.last_chapter_read, dbSync.last_chapter_read)
|
||||
trackToUpdate.add(dbSync)
|
||||
} else {
|
||||
|
||||
for (track in tracks) {
|
||||
var isInDatabase = false
|
||||
for (dbTrack in dbTracks) {
|
||||
if (track.sync_id == dbTrack.sync_id) {
|
||||
// The sync is already in the db, only update its fields
|
||||
if (track.remote_id != dbTrack.remote_id) {
|
||||
dbTrack.remote_id = track.remote_id
|
||||
}
|
||||
dbTrack.last_chapter_read = Math.max(dbTrack.last_chapter_read, track.last_chapter_read)
|
||||
isInDatabase = true
|
||||
trackToUpdate.add(dbTrack)
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!isInDatabase) {
|
||||
// Insert new sync. Let the db assign the id
|
||||
backupTrack.id = null
|
||||
trackToUpdate.add(backupTrack)
|
||||
track.id = null
|
||||
trackToUpdate.add(track)
|
||||
}
|
||||
}
|
||||
|
||||
// Update database
|
||||
if (!trackToUpdate.isEmpty()) {
|
||||
db.insertTracks(trackToUpdate).executeAsBlocking()
|
||||
databaseHelper.insertTracks(trackToUpdate).executeAsBlocking()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore the chapters for manga if chapters already in database
|
||||
*
|
||||
* @param manga manga of chapters
|
||||
* @param chapters list containing chapters that get restored
|
||||
* @return boolean answering if chapter fetch is not needed
|
||||
*/
|
||||
internal fun restoreChaptersForManga(manga: Manga, chapters: List<Chapter>): Boolean {
|
||||
val dbChapters = databaseHelper.getChapters(manga).executeAsBlocking()
|
||||
|
||||
// Return if fetch is needed
|
||||
if (dbChapters.isEmpty() || dbChapters.size < chapters.size)
|
||||
return false
|
||||
|
||||
for (chapter in chapters) {
|
||||
val pos = dbChapters.indexOf(chapter)
|
||||
if (pos != -1) {
|
||||
val dbChapter = dbChapters[pos]
|
||||
chapter.id = dbChapter.id
|
||||
chapter.copyFrom(dbChapter)
|
||||
break
|
||||
}
|
||||
}
|
||||
// Filter the chapters that couldn't be found.
|
||||
chapters.filter { it.id != null }
|
||||
chapters.map { it.manga_id = manga.id }
|
||||
|
||||
insertChapters(chapters)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns manga
|
||||
*
|
||||
* @return [Manga], null if not found
|
||||
*/
|
||||
internal fun getMangaFromDatabase(manga: Manga): Manga? {
|
||||
return databaseHelper.getManga(manga.url, manga.source).executeAsBlocking()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns list containing manga from library
|
||||
*
|
||||
* @return [Manga] from library
|
||||
*/
|
||||
internal fun getFavoriteManga(): List<Manga> {
|
||||
return databaseHelper.getFavoriteMangas().executeAsBlocking()
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts manga and returns id
|
||||
*
|
||||
* @return id of [Manga], null if not found
|
||||
*/
|
||||
internal fun insertManga(manga: Manga): Long? {
|
||||
return databaseHelper.insertManga(manga).executeAsBlocking().insertedId()
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts list of chapters
|
||||
*/
|
||||
internal fun insertChapters(chapters: List<Chapter>) {
|
||||
databaseHelper.updateChaptersBackup(chapters).executeAsBlocking()
|
||||
}
|
||||
|
||||
/**
|
||||
* Return number of backups.
|
||||
*
|
||||
* @return number of backups selected by user
|
||||
*/
|
||||
fun numberOfBackups(): Int {
|
||||
return preferences.numberOfBackups().getOrDefault()
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,414 @@
|
||||
package eu.kanade.tachiyomi.data.backup
|
||||
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.IBinder
|
||||
import android.os.PowerManager
|
||||
import com.github.salomonbrys.kotson.fromJson
|
||||
import com.google.gson.JsonArray
|
||||
import com.google.gson.JsonParser
|
||||
import com.google.gson.stream.JsonReader
|
||||
import com.hippo.unifile.UniFile
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.backup.models.Backup.CATEGORIES
|
||||
import eu.kanade.tachiyomi.data.backup.models.Backup.CHAPTERS
|
||||
import eu.kanade.tachiyomi.data.backup.models.Backup.HISTORY
|
||||
import eu.kanade.tachiyomi.data.backup.models.Backup.MANGA
|
||||
import eu.kanade.tachiyomi.data.backup.models.Backup.MANGAS
|
||||
import eu.kanade.tachiyomi.data.backup.models.Backup.TRACK
|
||||
import eu.kanade.tachiyomi.data.backup.models.Backup.VERSION
|
||||
import eu.kanade.tachiyomi.data.backup.models.DHistory
|
||||
import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
||||
import eu.kanade.tachiyomi.data.database.models.*
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.ui.setting.SettingsBackupFragment
|
||||
import eu.kanade.tachiyomi.util.AndroidComponentUtil
|
||||
import eu.kanade.tachiyomi.util.chop
|
||||
import eu.kanade.tachiyomi.util.sendLocalBroadcast
|
||||
import rx.Observable
|
||||
import rx.Subscription
|
||||
import rx.schedulers.Schedulers
|
||||
import timber.log.Timber
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.Executors
|
||||
import eu.kanade.tachiyomi.BuildConfig.APPLICATION_ID as ID
|
||||
|
||||
/**
|
||||
* Restores backup from json file
|
||||
*/
|
||||
class BackupRestoreService : Service() {
|
||||
|
||||
companion object {
|
||||
// Name of service
|
||||
private const val NAME = "BackupRestoreService"
|
||||
|
||||
// Uri as string
|
||||
private const val EXTRA_URI = "$ID.$NAME.EXTRA_URI"
|
||||
|
||||
/**
|
||||
* Returns the status of the service.
|
||||
*
|
||||
* @param context the application context.
|
||||
* @return true if the service is running, false otherwise.
|
||||
*/
|
||||
fun isRunning(context: Context): Boolean {
|
||||
return AndroidComponentUtil.isServiceRunning(context, BackupRestoreService::class.java)
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a service to restore a backup from Json
|
||||
*
|
||||
* @param context context of application
|
||||
* @param uri path of Uri
|
||||
*/
|
||||
fun start(context: Context, uri: String) {
|
||||
if (!isRunning(context)) {
|
||||
val intent = Intent(context, BackupRestoreService::class.java).apply {
|
||||
putExtra(EXTRA_URI, uri)
|
||||
}
|
||||
context.startService(intent)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the service.
|
||||
*
|
||||
* @param context the application context.
|
||||
*/
|
||||
fun stop(context: Context) {
|
||||
context.stopService(Intent(context, BackupRestoreService::class.java))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wake lock that will be held until the service is destroyed.
|
||||
*/
|
||||
private lateinit var wakeLock: PowerManager.WakeLock
|
||||
|
||||
/**
|
||||
* Subscription where the update is done.
|
||||
*/
|
||||
private var subscription: Subscription? = null
|
||||
|
||||
/**
|
||||
* The progress of a backup restore
|
||||
*/
|
||||
private var restoreProgress = 0
|
||||
|
||||
/**
|
||||
* Amount of manga in Json file (needed for restore)
|
||||
*/
|
||||
private var restoreAmount = 0
|
||||
|
||||
/**
|
||||
* List containing errors
|
||||
*/
|
||||
private val errors = mutableListOf<Pair<Date, String>>()
|
||||
|
||||
/**
|
||||
* Backup manager
|
||||
*/
|
||||
private lateinit var backupManager: BackupManager
|
||||
|
||||
/**
|
||||
* Database
|
||||
*/
|
||||
private val db: DatabaseHelper by injectLazy()
|
||||
|
||||
lateinit var executor: ExecutorService
|
||||
|
||||
/**
|
||||
* Method called when the service is created. It injects dependencies and acquire the wake lock.
|
||||
*/
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).newWakeLock(
|
||||
PowerManager.PARTIAL_WAKE_LOCK, "BackupRestoreService:WakeLock")
|
||||
wakeLock.acquire()
|
||||
executor = Executors.newSingleThreadExecutor()
|
||||
}
|
||||
|
||||
/**
|
||||
* Method called when the service is destroyed. It destroys the running subscription and
|
||||
* releases the wake lock.
|
||||
*/
|
||||
override fun onDestroy() {
|
||||
subscription?.unsubscribe()
|
||||
executor.shutdown() // must be called after unsubscribe
|
||||
if (wakeLock.isHeld) {
|
||||
wakeLock.release()
|
||||
}
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
/**
|
||||
* This method needs to be implemented, but it's not used/needed.
|
||||
*/
|
||||
override fun onBind(intent: Intent): IBinder? {
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Method called when the service receives an intent.
|
||||
*
|
||||
* @param intent the start intent from.
|
||||
* @param flags the flags of the command.
|
||||
* @param startId the start id of this command.
|
||||
* @return the start value of the command.
|
||||
*/
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (intent == null) return Service.START_NOT_STICKY
|
||||
|
||||
val file = UniFile.fromUri(this, Uri.parse(intent.getStringExtra(EXTRA_URI)))
|
||||
|
||||
// Unsubscribe from any previous subscription if needed.
|
||||
subscription?.unsubscribe()
|
||||
|
||||
subscription = Observable.using(
|
||||
{ db.lowLevel().beginTransaction() },
|
||||
{ getRestoreObservable(file).doOnNext{ db.lowLevel().setTransactionSuccessful() } },
|
||||
{ executor.execute { db.lowLevel().endTransaction() } })
|
||||
.doAfterTerminate { stopSelf(startId) }
|
||||
.subscribeOn(Schedulers.from(executor))
|
||||
.subscribe()
|
||||
|
||||
return Service.START_NOT_STICKY
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an [Observable] containing restore process.
|
||||
*
|
||||
* @param file restore file
|
||||
* @return [Observable<Manga>]
|
||||
*/
|
||||
private fun getRestoreObservable(file: UniFile): Observable<List<Manga>> {
|
||||
val startTime = System.currentTimeMillis()
|
||||
|
||||
val reader = JsonReader(file.openInputStream().bufferedReader())
|
||||
val json = JsonParser().parse(reader).asJsonObject
|
||||
|
||||
// Get parser version
|
||||
val version = json.get(VERSION)?.asInt ?: 1
|
||||
|
||||
// Initialize manager
|
||||
backupManager = BackupManager(this, version)
|
||||
|
||||
val mangasJson = json.get(MANGAS).asJsonArray
|
||||
|
||||
restoreAmount = mangasJson.size() + 1 // +1 for categories
|
||||
restoreProgress = 0
|
||||
errors.clear()
|
||||
|
||||
// Restore categories
|
||||
json.get(CATEGORIES)?.let {
|
||||
backupManager.restoreCategories(it.asJsonArray)
|
||||
restoreProgress += 1
|
||||
showRestoreProgress(restoreProgress, restoreAmount, "Categories added", errors.size)
|
||||
}
|
||||
|
||||
return Observable.from(mangasJson)
|
||||
.concatMap {
|
||||
val obj = it.asJsonObject
|
||||
val manga = backupManager.parser.fromJson<MangaImpl>(obj.get(MANGA))
|
||||
val chapters = backupManager.parser.fromJson<List<ChapterImpl>>(obj.get(CHAPTERS) ?: JsonArray())
|
||||
val categories = backupManager.parser.fromJson<List<String>>(obj.get(CATEGORIES) ?: JsonArray())
|
||||
val history = backupManager.parser.fromJson<List<DHistory>>(obj.get(HISTORY) ?: JsonArray())
|
||||
val tracks = backupManager.parser.fromJson<List<TrackImpl>>(obj.get(TRACK) ?: JsonArray())
|
||||
|
||||
val observable = getMangaRestoreObservable(manga, chapters, categories, history, tracks)
|
||||
if (observable != null) {
|
||||
observable
|
||||
} else {
|
||||
errors.add(Date() to "${manga.title} - ${getString(R.string.source_not_found)}")
|
||||
restoreProgress += 1
|
||||
val content = getString(R.string.dialog_restoring_source_not_found, manga.title.chop(15))
|
||||
showRestoreProgress(restoreProgress, restoreAmount, manga.title, errors.size, content)
|
||||
Observable.just(manga)
|
||||
}
|
||||
}
|
||||
.toList()
|
||||
.doOnNext {
|
||||
val endTime = System.currentTimeMillis()
|
||||
val time = endTime - startTime
|
||||
val logFile = writeErrorLog()
|
||||
val completeIntent = Intent(SettingsBackupFragment.INTENT_FILTER).apply {
|
||||
putExtra(SettingsBackupFragment.EXTRA_TIME, time)
|
||||
putExtra(SettingsBackupFragment.EXTRA_ERRORS, errors.size)
|
||||
putExtra(SettingsBackupFragment.EXTRA_ERROR_FILE_PATH, logFile.parent)
|
||||
putExtra(SettingsBackupFragment.EXTRA_ERROR_FILE, logFile.name)
|
||||
putExtra(SettingsBackupFragment.ACTION, SettingsBackupFragment.ACTION_RESTORE_COMPLETED_DIALOG)
|
||||
}
|
||||
sendLocalBroadcast(completeIntent)
|
||||
|
||||
}
|
||||
.doOnError { error ->
|
||||
Timber.e(error)
|
||||
writeErrorLog()
|
||||
val errorIntent = Intent(SettingsBackupFragment.INTENT_FILTER).apply {
|
||||
putExtra(SettingsBackupFragment.ACTION, SettingsBackupFragment.ACTION_ERROR_RESTORE_DIALOG)
|
||||
putExtra(SettingsBackupFragment.EXTRA_ERROR_MESSAGE, error.message)
|
||||
}
|
||||
sendLocalBroadcast(errorIntent)
|
||||
}
|
||||
.onErrorReturn { emptyList() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Write errors to error log
|
||||
*/
|
||||
private fun writeErrorLog(): File {
|
||||
try {
|
||||
if (errors.isNotEmpty()) {
|
||||
val destFile = File(externalCacheDir, "tachiyomi_restore.log")
|
||||
val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.getDefault())
|
||||
|
||||
destFile.bufferedWriter().use { out ->
|
||||
errors.forEach { (date, message) ->
|
||||
out.write("[${sdf.format(date)}] $message\n")
|
||||
}
|
||||
}
|
||||
return destFile
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Empty
|
||||
}
|
||||
return File("")
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a manga restore observable
|
||||
*
|
||||
* @param manga manga data from json
|
||||
* @param chapters chapters data from json
|
||||
* @param categories categories data from json
|
||||
* @param history history data from json
|
||||
* @param tracks tracking data from json
|
||||
* @return [Observable] containing manga restore information
|
||||
*/
|
||||
private fun getMangaRestoreObservable(manga: Manga, chapters: List<Chapter>,
|
||||
categories: List<String>, history: List<DHistory>,
|
||||
tracks: List<Track>): Observable<Manga>? {
|
||||
// Get source
|
||||
val source = backupManager.sourceManager.get(manga.source) ?: return null
|
||||
val dbManga = backupManager.getMangaFromDatabase(manga)
|
||||
|
||||
if (dbManga == null) {
|
||||
// Manga not in database
|
||||
return mangaFetchObservable(source, manga, chapters, categories, history, tracks)
|
||||
} else { // Manga in database
|
||||
// Copy information from manga already in database
|
||||
backupManager.restoreMangaNoFetch(manga, dbManga)
|
||||
// Fetch rest of manga information
|
||||
return mangaNoFetchObservable(source, manga, chapters, categories, history, tracks)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [Observable] that fetches manga information
|
||||
*
|
||||
* @param manga manga that needs updating
|
||||
* @param chapters chapters of manga that needs updating
|
||||
* @param categories categories that need updating
|
||||
*/
|
||||
private fun mangaFetchObservable(source: Source, manga: Manga, chapters: List<Chapter>,
|
||||
categories: List<String>, history: List<DHistory>,
|
||||
tracks: List<Track>): Observable<Manga> {
|
||||
return backupManager.restoreMangaFetchObservable(source, manga)
|
||||
.onErrorReturn {
|
||||
errors.add(Date() to "${manga.title} - ${it.message}")
|
||||
manga
|
||||
}
|
||||
.filter { it.id != null }
|
||||
.flatMap { manga ->
|
||||
chapterFetchObservable(source, manga, chapters)
|
||||
// Convert to the manga that contains new chapters.
|
||||
.map { manga }
|
||||
}
|
||||
.doOnNext {
|
||||
// Restore categories
|
||||
backupManager.restoreCategoriesForManga(it, categories)
|
||||
|
||||
// Restore history
|
||||
backupManager.restoreHistoryForManga(history)
|
||||
|
||||
// Restore tracking
|
||||
backupManager.restoreTrackForManga(it, tracks)
|
||||
}
|
||||
.doOnCompleted {
|
||||
restoreProgress += 1
|
||||
showRestoreProgress(restoreProgress, restoreAmount, manga.title, errors.size)
|
||||
}
|
||||
}
|
||||
|
||||
private fun mangaNoFetchObservable(source: Source, backupManga: Manga, chapters: List<Chapter>,
|
||||
categories: List<String>, history: List<DHistory>,
|
||||
tracks: List<Track>): Observable<Manga> {
|
||||
|
||||
return Observable.just(backupManga)
|
||||
.flatMap { manga ->
|
||||
if (!backupManager.restoreChaptersForManga(manga, chapters)) {
|
||||
chapterFetchObservable(source, manga, chapters)
|
||||
.map { manga }
|
||||
} else {
|
||||
Observable.just(manga)
|
||||
}
|
||||
}
|
||||
.doOnNext {
|
||||
// Restore categories
|
||||
backupManager.restoreCategoriesForManga(it, categories)
|
||||
|
||||
// Restore history
|
||||
backupManager.restoreHistoryForManga(history)
|
||||
|
||||
// Restore tracking
|
||||
backupManager.restoreTrackForManga(it, tracks)
|
||||
}
|
||||
.doOnCompleted {
|
||||
restoreProgress += 1
|
||||
showRestoreProgress(restoreProgress, restoreAmount, backupManga.title, errors.size)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [Observable] that fetches chapter information
|
||||
*
|
||||
* @param source source of manga
|
||||
* @param manga manga that needs updating
|
||||
* @return [Observable] that contains manga
|
||||
*/
|
||||
private fun chapterFetchObservable(source: Source, manga: Manga, chapters: List<Chapter>): Observable<Pair<List<Chapter>, List<Chapter>>> {
|
||||
return backupManager.restoreChapterFetchObservable(source, manga, chapters)
|
||||
// If there's any error, return empty update and continue.
|
||||
.onErrorReturn {
|
||||
errors.add(Date() to "${manga.title} - ${it.message}")
|
||||
Pair(emptyList<Chapter>(), emptyList<Chapter>())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Called to update dialog in [SettingsBackupFragment]
|
||||
*
|
||||
* @param progress restore progress
|
||||
* @param amount total restoreAmount of manga
|
||||
* @param title title of restored manga
|
||||
*/
|
||||
private fun showRestoreProgress(progress: Int, amount: Int, title: String, errors: Int,
|
||||
content: String = getString(R.string.dialog_restoring_backup, title.chop(15))) {
|
||||
val intent = Intent(SettingsBackupFragment.INTENT_FILTER).apply {
|
||||
putExtra(SettingsBackupFragment.EXTRA_PROGRESS, progress)
|
||||
putExtra(SettingsBackupFragment.EXTRA_AMOUNT, amount)
|
||||
putExtra(SettingsBackupFragment.EXTRA_CONTENT, content)
|
||||
putExtra(SettingsBackupFragment.EXTRA_ERRORS, errors)
|
||||
putExtra(SettingsBackupFragment.ACTION, SettingsBackupFragment.ACTION_SET_PROGRESS_DIALOG)
|
||||
}
|
||||
sendLocalBroadcast(intent)
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,23 @@
|
||||
package eu.kanade.tachiyomi.data.backup.models
|
||||
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* Json values
|
||||
*/
|
||||
object Backup {
|
||||
const val CURRENT_VERSION = 2
|
||||
const val MANGA = "manga"
|
||||
const val MANGAS = "mangas"
|
||||
const val TRACK = "track"
|
||||
const val CHAPTERS = "chapters"
|
||||
const val CATEGORIES = "categories"
|
||||
const val HISTORY = "history"
|
||||
const val VERSION = "version"
|
||||
|
||||
fun getDefaultFilename(): String {
|
||||
val date = SimpleDateFormat("yyyy-MM-dd_HH-mm", Locale.getDefault()).format(Date())
|
||||
return "tachiyomi_$date.json"
|
||||
}
|
||||
}
|
@ -0,0 +1,3 @@
|
||||
package eu.kanade.tachiyomi.data.backup.models
|
||||
|
||||
data class DHistory(val url: String,val lastRead: Long)
|
@ -1,16 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.backup.serializer
|
||||
|
||||
import com.google.gson.JsonElement
|
||||
import com.google.gson.JsonPrimitive
|
||||
import com.google.gson.JsonSerializationContext
|
||||
import com.google.gson.JsonSerializer
|
||||
import java.lang.reflect.Type
|
||||
|
||||
class BooleanSerializer : JsonSerializer<Boolean> {
|
||||
|
||||
override fun serialize(value: Boolean?, type: Type, context: JsonSerializationContext): JsonElement? {
|
||||
if (value != null && value != false)
|
||||
return JsonPrimitive(value)
|
||||
return null
|
||||
}
|
||||
}
|
@ -0,0 +1,31 @@
|
||||
package eu.kanade.tachiyomi.data.backup.serializer
|
||||
|
||||
import com.github.salomonbrys.kotson.typeAdapter
|
||||
import com.google.gson.TypeAdapter
|
||||
import eu.kanade.tachiyomi.data.database.models.CategoryImpl
|
||||
|
||||
/**
|
||||
* JSON Serializer used to write / read [CategoryImpl] to / from json
|
||||
*/
|
||||
object CategoryTypeAdapter {
|
||||
|
||||
fun build(): TypeAdapter<CategoryImpl> {
|
||||
return typeAdapter {
|
||||
write {
|
||||
beginArray()
|
||||
value(it.name)
|
||||
value(it.order)
|
||||
endArray()
|
||||
}
|
||||
|
||||
read {
|
||||
beginArray()
|
||||
val category = CategoryImpl()
|
||||
category.name = nextString()
|
||||
category.order = nextInt()
|
||||
endArray()
|
||||
category
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,61 @@
|
||||
package eu.kanade.tachiyomi.data.backup.serializer
|
||||
|
||||
import com.github.salomonbrys.kotson.typeAdapter
|
||||
import com.google.gson.TypeAdapter
|
||||
import com.google.gson.stream.JsonToken
|
||||
import eu.kanade.tachiyomi.data.database.models.ChapterImpl
|
||||
|
||||
/**
|
||||
* JSON Serializer used to write / read [ChapterImpl] to / from json
|
||||
*/
|
||||
object ChapterTypeAdapter {
|
||||
|
||||
private const val URL = "u"
|
||||
private const val READ = "r"
|
||||
private const val BOOKMARK = "b"
|
||||
private const val LAST_READ = "l"
|
||||
|
||||
fun build(): TypeAdapter<ChapterImpl> {
|
||||
return typeAdapter {
|
||||
write {
|
||||
if (it.read || it.bookmark || it.last_page_read != 0) {
|
||||
beginObject()
|
||||
name(URL)
|
||||
value(it.url)
|
||||
if (it.read) {
|
||||
name(READ)
|
||||
value(1)
|
||||
}
|
||||
if (it.bookmark) {
|
||||
name(BOOKMARK)
|
||||
value(1)
|
||||
}
|
||||
if (it.last_page_read != 0) {
|
||||
name(LAST_READ)
|
||||
value(it.last_page_read)
|
||||
}
|
||||
endObject()
|
||||
}
|
||||
}
|
||||
|
||||
read {
|
||||
val chapter = ChapterImpl()
|
||||
beginObject()
|
||||
while (hasNext()) {
|
||||
if (peek() == JsonToken.NAME) {
|
||||
val name = nextName()
|
||||
|
||||
when (name) {
|
||||
URL -> chapter.url = nextString()
|
||||
READ -> chapter.read = nextInt() == 1
|
||||
BOOKMARK -> chapter.bookmark = nextInt() == 1
|
||||
LAST_READ -> chapter.last_page_read = nextInt()
|
||||
}
|
||||
}
|
||||
}
|
||||
endObject()
|
||||
chapter
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,32 @@
|
||||
package eu.kanade.tachiyomi.data.backup.serializer
|
||||
|
||||
import com.github.salomonbrys.kotson.typeAdapter
|
||||
import com.google.gson.TypeAdapter
|
||||
import eu.kanade.tachiyomi.data.backup.models.DHistory
|
||||
|
||||
/**
|
||||
* JSON Serializer used to write / read [DHistory] to / from json
|
||||
*/
|
||||
object HistoryTypeAdapter {
|
||||
|
||||
fun build(): TypeAdapter<DHistory> {
|
||||
return typeAdapter {
|
||||
write {
|
||||
if (it.lastRead != 0L) {
|
||||
beginArray()
|
||||
value(it.url)
|
||||
value(it.lastRead)
|
||||
endArray()
|
||||
}
|
||||
}
|
||||
|
||||
read {
|
||||
beginArray()
|
||||
val url = nextString()
|
||||
val lastRead = nextLong()
|
||||
endArray()
|
||||
DHistory(url, lastRead)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,27 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.backup.serializer
|
||||
|
||||
import com.google.gson.ExclusionStrategy
|
||||
import com.google.gson.FieldAttributes
|
||||
import eu.kanade.tachiyomi.data.database.models.CategoryImpl
|
||||
import eu.kanade.tachiyomi.data.database.models.ChapterImpl
|
||||
import eu.kanade.tachiyomi.data.database.models.MangaImpl
|
||||
import eu.kanade.tachiyomi.data.database.models.TrackImpl
|
||||
|
||||
class IdExclusion : ExclusionStrategy {
|
||||
|
||||
private val categoryExclusions = listOf("id")
|
||||
private val mangaExclusions = listOf("id")
|
||||
private val chapterExclusions = listOf("id", "manga_id")
|
||||
private val syncExclusions = listOf("id", "manga_id", "update")
|
||||
|
||||
override fun shouldSkipField(f: FieldAttributes) = when (f.declaringClass) {
|
||||
MangaImpl::class.java -> mangaExclusions.contains(f.name)
|
||||
ChapterImpl::class.java -> chapterExclusions.contains(f.name)
|
||||
TrackImpl::class.java -> syncExclusions.contains(f.name)
|
||||
CategoryImpl::class.java -> categoryExclusions.contains(f.name)
|
||||
else -> false
|
||||
}
|
||||
|
||||
override fun shouldSkipClass(clazz: Class<*>) = false
|
||||
|
||||
}
|
@ -1,17 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.backup.serializer
|
||||
|
||||
import com.google.gson.JsonElement
|
||||
import com.google.gson.JsonPrimitive
|
||||
import com.google.gson.JsonSerializationContext
|
||||
import com.google.gson.JsonSerializer
|
||||
|
||||
import java.lang.reflect.Type
|
||||
|
||||
class IntegerSerializer : JsonSerializer<Int> {
|
||||
|
||||
override fun serialize(value: Int?, type: Type, context: JsonSerializationContext): JsonElement? {
|
||||
if (value != null && value !== 0)
|
||||
return JsonPrimitive(value)
|
||||
return null
|
||||
}
|
||||
}
|
@ -1,16 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.backup.serializer
|
||||
|
||||
import com.google.gson.JsonElement
|
||||
import com.google.gson.JsonPrimitive
|
||||
import com.google.gson.JsonSerializationContext
|
||||
import com.google.gson.JsonSerializer
|
||||
import java.lang.reflect.Type
|
||||
|
||||
class LongSerializer : JsonSerializer<Long> {
|
||||
|
||||
override fun serialize(value: Long?, type: Type, context: JsonSerializationContext): JsonElement? {
|
||||
if (value != null && value !== 0L)
|
||||
return JsonPrimitive(value)
|
||||
return null
|
||||
}
|
||||
}
|
@ -0,0 +1,37 @@
|
||||
package eu.kanade.tachiyomi.data.backup.serializer
|
||||
|
||||
import com.github.salomonbrys.kotson.typeAdapter
|
||||
import com.google.gson.TypeAdapter
|
||||
import eu.kanade.tachiyomi.data.database.models.MangaImpl
|
||||
|
||||
/**
|
||||
* JSON Serializer used to write / read [MangaImpl] to / from json
|
||||
*/
|
||||
object MangaTypeAdapter {
|
||||
|
||||
fun build(): TypeAdapter<MangaImpl> {
|
||||
return typeAdapter {
|
||||
write {
|
||||
beginArray()
|
||||
value(it.url)
|
||||
value(it.title)
|
||||
value(it.source)
|
||||
value(it.viewer)
|
||||
value(it.chapter_flags)
|
||||
endArray()
|
||||
}
|
||||
|
||||
read {
|
||||
beginArray()
|
||||
val manga = MangaImpl()
|
||||
manga.url = nextString()
|
||||
manga.title = nextString()
|
||||
manga.source = nextLong()
|
||||
manga.viewer = nextInt()
|
||||
manga.chapter_flags = nextInt()
|
||||
endArray()
|
||||
manga
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,53 @@
|
||||
package eu.kanade.tachiyomi.data.backup.serializer
|
||||
|
||||
import com.github.salomonbrys.kotson.typeAdapter
|
||||
import com.google.gson.TypeAdapter
|
||||
import com.google.gson.stream.JsonToken
|
||||
import eu.kanade.tachiyomi.data.database.models.TrackImpl
|
||||
|
||||
/**
|
||||
* JSON Serializer used to write / read [TrackImpl] to / from json
|
||||
*/
|
||||
object TrackTypeAdapter {
|
||||
|
||||
private const val SYNC = "s"
|
||||
private const val REMOTE = "r"
|
||||
private const val TITLE = "t"
|
||||
private const val LAST_READ = "l"
|
||||
|
||||
fun build(): TypeAdapter<TrackImpl> {
|
||||
return typeAdapter {
|
||||
write {
|
||||
beginObject()
|
||||
name(TITLE)
|
||||
value(it.title)
|
||||
name(SYNC)
|
||||
value(it.sync_id)
|
||||
name(REMOTE)
|
||||
value(it.remote_id)
|
||||
name(LAST_READ)
|
||||
value(it.last_chapter_read)
|
||||
endObject()
|
||||
}
|
||||
|
||||
read {
|
||||
val track = TrackImpl()
|
||||
beginObject()
|
||||
while (hasNext()) {
|
||||
if (peek() == JsonToken.NAME) {
|
||||
val name = nextName()
|
||||
|
||||
when (name) {
|
||||
TITLE -> track.title = nextString()
|
||||
SYNC -> track.sync_id = nextInt()
|
||||
REMOTE -> track.remote_id = nextInt()
|
||||
LAST_READ -> track.last_chapter_read = nextInt()
|
||||
}
|
||||
}
|
||||
}
|
||||
endObject()
|
||||
track
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -24,4 +24,6 @@ open class DatabaseHelper(context: Context)
|
||||
|
||||
inline fun inTransaction(block: () -> Unit) = db.inTransaction(block)
|
||||
|
||||
fun lowLevel() = db.lowLevel()
|
||||
|
||||
}
|
||||
|
@ -10,6 +10,7 @@ import com.pushtorefresh.storio.sqlite.queries.DeleteQuery
|
||||
import com.pushtorefresh.storio.sqlite.queries.InsertQuery
|
||||
import com.pushtorefresh.storio.sqlite.queries.UpdateQuery
|
||||
import eu.kanade.tachiyomi.data.database.models.History
|
||||
import eu.kanade.tachiyomi.data.database.models.HistoryImpl
|
||||
import eu.kanade.tachiyomi.data.database.tables.HistoryTable.COL_CHAPTER_ID
|
||||
import eu.kanade.tachiyomi.data.database.tables.HistoryTable.COL_ID
|
||||
import eu.kanade.tachiyomi.data.database.tables.HistoryTable.COL_LAST_READ
|
||||
@ -44,7 +45,7 @@ open class HistoryPutResolver : DefaultPutResolver<History>() {
|
||||
|
||||
class HistoryGetResolver : DefaultGetResolver<History>() {
|
||||
|
||||
override fun mapFromCursor(cursor: Cursor): History = History().apply {
|
||||
override fun mapFromCursor(cursor: Cursor): History = HistoryImpl().apply {
|
||||
id = cursor.getLong(cursor.getColumnIndex(COL_ID))
|
||||
chapter_id = cursor.getLong(cursor.getColumnIndex(COL_CHAPTER_ID))
|
||||
last_read = cursor.getLong(cursor.getColumnIndex(COL_LAST_READ))
|
||||
|
@ -5,27 +5,27 @@ import java.io.Serializable
|
||||
/**
|
||||
* Object containing the history statistics of a chapter
|
||||
*/
|
||||
class History : Serializable {
|
||||
interface History : Serializable {
|
||||
|
||||
/**
|
||||
* Id of history object.
|
||||
*/
|
||||
var id: Long? = null
|
||||
var id: Long?
|
||||
|
||||
/**
|
||||
* Chapter id of history object.
|
||||
*/
|
||||
var chapter_id: Long = 0
|
||||
var chapter_id: Long
|
||||
|
||||
/**
|
||||
* Last time chapter was read in time long format
|
||||
*/
|
||||
var last_read: Long = 0
|
||||
var last_read: Long
|
||||
|
||||
/**
|
||||
* Total time chapter was read - todo not yet implemented
|
||||
*/
|
||||
var time_read: Long = 0
|
||||
var time_read: Long
|
||||
|
||||
companion object {
|
||||
|
||||
@ -35,10 +35,8 @@ class History : Serializable {
|
||||
* @param chapter chapter object
|
||||
* @return history object
|
||||
*/
|
||||
fun create(chapter: Chapter): History {
|
||||
val history = History()
|
||||
history.chapter_id = chapter.id!!
|
||||
return history
|
||||
fun create(chapter: Chapter): History = HistoryImpl().apply {
|
||||
this.chapter_id = chapter.id!!
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,27 @@
|
||||
package eu.kanade.tachiyomi.data.database.models
|
||||
|
||||
/**
|
||||
* Object containing the history statistics of a chapter
|
||||
*/
|
||||
class HistoryImpl : History {
|
||||
|
||||
/**
|
||||
* Id of history object.
|
||||
*/
|
||||
override var id: Long? = null
|
||||
|
||||
/**
|
||||
* Chapter id of history object.
|
||||
*/
|
||||
override var chapter_id: Long = 0
|
||||
|
||||
/**
|
||||
* Last time chapter was read in time long format
|
||||
*/
|
||||
override var last_read: Long = 0
|
||||
|
||||
/**
|
||||
* Total time chapter was read - todo not yet implemented
|
||||
*/
|
||||
override var time_read: Long = 0
|
||||
}
|
@ -6,6 +6,7 @@ import eu.kanade.tachiyomi.data.database.DbProvider
|
||||
import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.database.models.MangaChapter
|
||||
import eu.kanade.tachiyomi.data.database.resolvers.ChapterBackupPutResolver
|
||||
import eu.kanade.tachiyomi.data.database.resolvers.ChapterProgressPutResolver
|
||||
import eu.kanade.tachiyomi.data.database.resolvers.ChapterSourceOrderPutResolver
|
||||
import eu.kanade.tachiyomi.data.database.resolvers.MangaChapterGetResolver
|
||||
@ -42,6 +43,16 @@ interface ChapterQueries : DbProvider {
|
||||
.build())
|
||||
.prepare()
|
||||
|
||||
fun getChapter(url: String) = db.get()
|
||||
.`object`(Chapter::class.java)
|
||||
.withQuery(Query.builder()
|
||||
.table(ChapterTable.TABLE)
|
||||
.where("${ChapterTable.COL_URL} = ?")
|
||||
.whereArgs(url)
|
||||
.build())
|
||||
.prepare()
|
||||
|
||||
|
||||
fun insertChapter(chapter: Chapter) = db.put().`object`(chapter).prepare()
|
||||
|
||||
fun insertChapters(chapters: List<Chapter>) = db.put().objects(chapters).prepare()
|
||||
@ -50,6 +61,11 @@ interface ChapterQueries : DbProvider {
|
||||
|
||||
fun deleteChapters(chapters: List<Chapter>) = db.delete().objects(chapters).prepare()
|
||||
|
||||
fun updateChaptersBackup(chapters: List<Chapter>) = db.put()
|
||||
.objects(chapters)
|
||||
.withPutResolver(ChapterBackupPutResolver())
|
||||
.prepare()
|
||||
|
||||
fun updateChapterProgress(chapter: Chapter) = db.put()
|
||||
.`object`(chapter)
|
||||
.withPutResolver(ChapterProgressPutResolver())
|
||||
|
@ -1,5 +1,6 @@
|
||||
package eu.kanade.tachiyomi.data.database.queries
|
||||
|
||||
import com.pushtorefresh.storio.sqlite.queries.DeleteQuery
|
||||
import com.pushtorefresh.storio.sqlite.queries.RawQuery
|
||||
import eu.kanade.tachiyomi.data.database.DbProvider
|
||||
import eu.kanade.tachiyomi.data.database.models.History
|
||||
@ -40,6 +41,15 @@ interface HistoryQueries : DbProvider {
|
||||
.build())
|
||||
.prepare()
|
||||
|
||||
fun getHistoryByChapterUrl(chapterUrl: String) = db.get()
|
||||
.`object`(History::class.java)
|
||||
.withQuery(RawQuery.builder()
|
||||
.query(getHistoryByChapterUrl())
|
||||
.args(chapterUrl)
|
||||
.observesTables(HistoryTable.TABLE)
|
||||
.build())
|
||||
.prepare()
|
||||
|
||||
/**
|
||||
* Updates the history last read.
|
||||
* Inserts history object if not yet in database
|
||||
@ -59,4 +69,18 @@ interface HistoryQueries : DbProvider {
|
||||
.objects(historyList)
|
||||
.withPutResolver(HistoryLastReadPutResolver())
|
||||
.prepare()
|
||||
|
||||
fun deleteHistory() = db.delete()
|
||||
.byQuery(DeleteQuery.builder()
|
||||
.table(HistoryTable.TABLE)
|
||||
.build())
|
||||
.prepare()
|
||||
|
||||
fun deleteHistoryNoLastRead() = db.delete()
|
||||
.byQuery(DeleteQuery.builder()
|
||||
.table(HistoryTable.TABLE)
|
||||
.where("${HistoryTable.COL_LAST_READ} = ?")
|
||||
.whereArgs(0)
|
||||
.build())
|
||||
.prepare()
|
||||
}
|
||||
|
@ -84,6 +84,12 @@ interface MangaQueries : DbProvider {
|
||||
.build())
|
||||
.prepare()
|
||||
|
||||
fun deleteMangas() = db.delete()
|
||||
.byQuery(DeleteQuery.builder()
|
||||
.table(MangaTable.TABLE)
|
||||
.build())
|
||||
.prepare()
|
||||
|
||||
fun getLastReadManga() = db.get()
|
||||
.listOfObjects(Manga::class.java)
|
||||
.withQuery(RawQuery.builder()
|
||||
|
@ -73,6 +73,14 @@ fun getHistoryByMangaId() = """
|
||||
WHERE ${Chapter.TABLE}.${Chapter.COL_MANGA_ID} = ? AND ${History.TABLE}.${History.COL_CHAPTER_ID} = ${Chapter.TABLE}.${Chapter.COL_ID}
|
||||
"""
|
||||
|
||||
fun getHistoryByChapterUrl() = """
|
||||
SELECT ${History.TABLE}.*
|
||||
FROM ${History.TABLE}
|
||||
JOIN ${Chapter.TABLE}
|
||||
ON ${History.TABLE}.${History.COL_CHAPTER_ID} = ${Chapter.TABLE}.${Chapter.COL_ID}
|
||||
WHERE ${Chapter.TABLE}.${Chapter.COL_URL} = ? AND ${History.TABLE}.${History.COL_CHAPTER_ID} = ${Chapter.TABLE}.${Chapter.COL_ID}
|
||||
"""
|
||||
|
||||
fun getLastReadMangaQuery() = """
|
||||
SELECT ${Manga.TABLE}.*, MAX(${History.TABLE}.${History.COL_LAST_READ}) AS max
|
||||
FROM ${Manga.TABLE}
|
||||
|
@ -0,0 +1,35 @@
|
||||
package eu.kanade.tachiyomi.data.database.resolvers
|
||||
|
||||
import android.content.ContentValues
|
||||
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 ChapterBackupPutResolver : 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_URL} = ?")
|
||||
.whereArgs(chapter.url)
|
||||
.build()
|
||||
|
||||
fun mapToContentValues(chapter: Chapter) = ContentValues(3).apply {
|
||||
put(ChapterTable.COL_READ, chapter.read)
|
||||
put(ChapterTable.COL_BOOKMARK, chapter.bookmark)
|
||||
put(ChapterTable.COL_LAST_PAGE_READ, chapter.last_page_read)
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -1,10 +1,12 @@
|
||||
package eu.kanade.tachiyomi.data.library
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.BitmapFactory
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.os.PowerManager
|
||||
import android.support.v4.app.NotificationCompat
|
||||
@ -28,7 +30,9 @@ import eu.kanade.tachiyomi.util.*
|
||||
import rx.Observable
|
||||
import rx.Subscription
|
||||
import rx.schedulers.Schedulers
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import timber.log.Timber
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.util.*
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
@ -40,27 +44,12 @@ import java.util.concurrent.atomic.AtomicInteger
|
||||
* progress of the update, and if case of an unexpected error, this service will be silently
|
||||
* destroyed.
|
||||
*/
|
||||
class LibraryUpdateService : Service() {
|
||||
|
||||
/**
|
||||
* Database helper.
|
||||
*/
|
||||
val db: DatabaseHelper by injectLazy()
|
||||
|
||||
/**
|
||||
* Source manager.
|
||||
*/
|
||||
val sourceManager: SourceManager by injectLazy()
|
||||
|
||||
/**
|
||||
* Preferences.
|
||||
*/
|
||||
val preferences: PreferencesHelper by injectLazy()
|
||||
|
||||
/**
|
||||
* Download Manager
|
||||
*/
|
||||
val downloadManager: DownloadManager by injectLazy()
|
||||
class LibraryUpdateService(
|
||||
val db: DatabaseHelper = Injekt.get(),
|
||||
val sourceManager: SourceManager = Injekt.get(),
|
||||
val preferences: PreferencesHelper = Injekt.get(),
|
||||
val downloadManager: DownloadManager = Injekt.get()
|
||||
) : Service() {
|
||||
|
||||
/**
|
||||
* Wake lock that will be held until the service is destroyed.
|
||||
@ -75,18 +64,27 @@ class LibraryUpdateService : Service() {
|
||||
/**
|
||||
* Pending intent of action that cancels the library update
|
||||
*/
|
||||
private val cancelPendingIntent by lazy {NotificationReceiver.cancelLibraryUpdatePendingBroadcast(this)}
|
||||
private val cancelIntent by lazy {
|
||||
NotificationReceiver.cancelLibraryUpdatePendingBroadcast(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Id of the library update notification.
|
||||
* Bitmap of the app for notifications.
|
||||
*/
|
||||
private val notificationId: Int
|
||||
get() = Constants.NOTIFICATION_LIBRARY_ID
|
||||
|
||||
private val notificationBitmap by lazy {
|
||||
BitmapFactory.decodeResource(resources, R.mipmap.ic_launcher)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cached progress notification to avoid creating a lot.
|
||||
*/
|
||||
private val progressNotification by lazy { NotificationCompat.Builder(this)
|
||||
.setSmallIcon(R.drawable.ic_refresh_white_24dp_img)
|
||||
.setLargeIcon(notificationBitmap)
|
||||
.setOngoing(true)
|
||||
.addAction(R.drawable.ic_clear_grey_24dp_img, getString(android.R.string.cancel), cancelIntent)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
/**
|
||||
@ -144,16 +142,20 @@ class LibraryUpdateService : Service() {
|
||||
*/
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
createAndAcquireWakeLock()
|
||||
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).newWakeLock(
|
||||
PowerManager.PARTIAL_WAKE_LOCK, "LibraryUpdateService:WakeLock")
|
||||
wakeLock.acquire()
|
||||
}
|
||||
|
||||
/**
|
||||
* Method called when the service is destroyed. It destroys the running subscription, resets
|
||||
* the alarm and release the wake lock.
|
||||
* Method called when the service is destroyed. It destroys subscriptions and releases the wake
|
||||
* lock.
|
||||
*/
|
||||
override fun onDestroy() {
|
||||
subscription?.unsubscribe()
|
||||
destroyWakeLock()
|
||||
if (wakeLock.isHeld) {
|
||||
wakeLock.release()
|
||||
}
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
@ -192,7 +194,7 @@ class LibraryUpdateService : Service() {
|
||||
.subscribeOn(Schedulers.io())
|
||||
.subscribe({
|
||||
}, {
|
||||
showNotification(getString(R.string.notification_update_error), "")
|
||||
Timber.e(it)
|
||||
stopSelf(startId)
|
||||
}, {
|
||||
stopSelf(startId)
|
||||
@ -213,7 +215,7 @@ class LibraryUpdateService : Service() {
|
||||
var listToUpdate = if (categoryId != -1)
|
||||
db.getLibraryMangas().executeAsBlocking().filter { it.category == categoryId }
|
||||
else {
|
||||
val categoriesToUpdate = preferences.libraryUpdateCategories().getOrDefault().map { it.toInt() }
|
||||
val categoriesToUpdate = preferences.libraryUpdateCategories().getOrDefault().map(String::toInt)
|
||||
if (categoriesToUpdate.isNotEmpty())
|
||||
db.getLibraryMangas().executeAsBlocking()
|
||||
.filter { it.category in categoriesToUpdate }
|
||||
@ -255,7 +257,7 @@ class LibraryUpdateService : Service() {
|
||||
// Emit each manga and update it sequentially.
|
||||
return Observable.from(mangaToUpdate)
|
||||
// Notify manga that will update.
|
||||
.doOnNext { showProgressNotification(it, count.andIncrement, mangaToUpdate.size, cancelPendingIntent) }
|
||||
.doOnNext { showProgressNotification(it, count.andIncrement, mangaToUpdate.size) }
|
||||
// Update the chapters of the manga.
|
||||
.concatMap { manga ->
|
||||
updateManga(manga)
|
||||
@ -267,11 +269,11 @@ class LibraryUpdateService : Service() {
|
||||
// Filter out mangas without new chapters (or failed).
|
||||
.filter { pair -> pair.first.isNotEmpty() }
|
||||
.doOnNext {
|
||||
if (downloadNew) {
|
||||
if (categoriesToDownload.isEmpty() || manga.category in categoriesToDownload) {
|
||||
downloadChapters(manga, it.first)
|
||||
hasDownloads = true
|
||||
}
|
||||
if (downloadNew && (categoriesToDownload.isEmpty() ||
|
||||
manga.category in categoriesToDownload)) {
|
||||
|
||||
downloadChapters(manga, it.first)
|
||||
hasDownloads = true
|
||||
}
|
||||
}
|
||||
// Convert to the manga that contains new chapters.
|
||||
@ -287,16 +289,18 @@ class LibraryUpdateService : Service() {
|
||||
}
|
||||
// Notify result of the overall update.
|
||||
.doOnCompleted {
|
||||
if (newUpdates.isEmpty()) {
|
||||
cancelNotification()
|
||||
} else {
|
||||
showResultNotification(newUpdates, failedUpdates)
|
||||
if (downloadNew) {
|
||||
if (hasDownloads) {
|
||||
DownloadService.start(this)
|
||||
}
|
||||
if (newUpdates.isNotEmpty()) {
|
||||
showResultNotification(newUpdates)
|
||||
if (downloadNew && hasDownloads) {
|
||||
DownloadService.start(this)
|
||||
}
|
||||
}
|
||||
|
||||
if (failedUpdates.isNotEmpty()) {
|
||||
Timber.e("Failed updating: ${failedUpdates.map { it.title }}")
|
||||
}
|
||||
|
||||
cancelProgressNotification()
|
||||
}
|
||||
}
|
||||
|
||||
@ -337,7 +341,7 @@ class LibraryUpdateService : Service() {
|
||||
// Emit each manga and update it sequentially.
|
||||
return Observable.from(mangaToUpdate)
|
||||
// Notify manga that will update.
|
||||
.doOnNext { showProgressNotification(it, count.andIncrement, mangaToUpdate.size, cancelPendingIntent) }
|
||||
.doOnNext { showProgressNotification(it, count.andIncrement, mangaToUpdate.size) }
|
||||
// Update the details of the manga.
|
||||
.concatMap { manga ->
|
||||
val source = sourceManager.get(manga.source) as? HttpSource
|
||||
@ -352,73 +356,10 @@ class LibraryUpdateService : Service() {
|
||||
.onErrorReturn { manga }
|
||||
}
|
||||
.doOnCompleted {
|
||||
cancelNotification()
|
||||
cancelProgressNotification()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the text that will be displayed in the notification when there are new chapters.
|
||||
*
|
||||
* @param updates a list of manga that contains new chapters.
|
||||
* @param failedUpdates a list of manga that failed to update.
|
||||
* @return the body of the notification to display.
|
||||
*/
|
||||
private fun getUpdatedMangasBody(updates: List<Manga>, failedUpdates: List<Manga>): String {
|
||||
return buildString {
|
||||
if (updates.isEmpty()) {
|
||||
append(getString(R.string.notification_no_new_chapters))
|
||||
append("\n")
|
||||
} else {
|
||||
append(getString(R.string.notification_new_chapters))
|
||||
for (manga in updates) {
|
||||
append("\n")
|
||||
append(manga.title.chop(45))
|
||||
}
|
||||
}
|
||||
if (!failedUpdates.isEmpty()) {
|
||||
append("\n\n")
|
||||
append(getString(R.string.notification_manga_update_failed))
|
||||
for (manga in failedUpdates) {
|
||||
append("\n")
|
||||
append(manga.title.chop(45))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates and acquires a wake lock until the library is updated.
|
||||
*/
|
||||
private fun createAndAcquireWakeLock() {
|
||||
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).newWakeLock(
|
||||
PowerManager.PARTIAL_WAKE_LOCK, "LibraryUpdateService:WakeLock")
|
||||
wakeLock.acquire()
|
||||
}
|
||||
|
||||
/**
|
||||
* Releases the wake lock if it's held.
|
||||
*/
|
||||
private fun destroyWakeLock() {
|
||||
if (wakeLock.isHeld) {
|
||||
wakeLock.release()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the notification with the given title and body.
|
||||
*
|
||||
* @param title the title of the notification.
|
||||
* @param body the body of the notification.
|
||||
*/
|
||||
private fun showNotification(title: String, body: String) {
|
||||
notificationManager.notify(notificationId, notification {
|
||||
setSmallIcon(R.drawable.ic_refresh_white_24dp_img)
|
||||
setLargeIcon(notificationBitmap)
|
||||
setContentTitle(title)
|
||||
setContentText(body)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the notification containing the currently updating manga and the progress.
|
||||
*
|
||||
@ -426,52 +367,66 @@ class LibraryUpdateService : Service() {
|
||||
* @param current the current progress.
|
||||
* @param total the total progress.
|
||||
*/
|
||||
private fun showProgressNotification(manga: Manga, current: Int, total: Int, cancelIntent: PendingIntent) {
|
||||
notificationManager.notify(notificationId, notification {
|
||||
setSmallIcon(R.drawable.ic_refresh_white_24dp_img)
|
||||
setLargeIcon(notificationBitmap)
|
||||
setContentTitle(manga.title)
|
||||
setProgress(total, current, false)
|
||||
setOngoing(true)
|
||||
addAction(R.drawable.ic_clear_grey_24dp_img, getString(android.R.string.cancel), cancelIntent)
|
||||
})
|
||||
private fun showProgressNotification(manga: Manga, current: Int, total: Int) {
|
||||
notificationManager.notify(Constants.NOTIFICATION_LIBRARY_PROGRESS_ID, progressNotification
|
||||
.setContentTitle(manga.title)
|
||||
.setProgress(total, current, false)
|
||||
.build())
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Shows the notification containing the result of the update done by the service.
|
||||
*
|
||||
* @param updates a list of manga with new updates.
|
||||
* @param failed a list of manga that failed to update.
|
||||
*/
|
||||
private fun showResultNotification(updates: List<Manga>, failed: List<Manga>) {
|
||||
val title = getString(R.string.notification_update_completed)
|
||||
val body = getUpdatedMangasBody(updates, failed)
|
||||
private fun showResultNotification(updates: List<Manga>) {
|
||||
val newUpdates = updates.map { it.title.chop(45) }.toMutableSet()
|
||||
|
||||
notificationManager.notify(notificationId, notification {
|
||||
setSmallIcon(R.drawable.ic_refresh_white_24dp_img)
|
||||
// Append new chapters from a previous, existing notification
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
val previousNotification = notificationManager.activeNotifications
|
||||
.find { it.id == Constants.NOTIFICATION_LIBRARY_RESULT_ID }
|
||||
|
||||
if (previousNotification != null) {
|
||||
val oldUpdates = previousNotification.notification.extras
|
||||
.getString(Notification.EXTRA_BIG_TEXT)
|
||||
|
||||
if (!oldUpdates.isNullOrEmpty()) {
|
||||
newUpdates += oldUpdates.split("\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
notificationManager.notify(Constants.NOTIFICATION_LIBRARY_RESULT_ID, notification {
|
||||
setSmallIcon(R.drawable.ic_book_white_24dp)
|
||||
setLargeIcon(notificationBitmap)
|
||||
setContentTitle(title)
|
||||
setStyle(NotificationCompat.BigTextStyle().bigText(body))
|
||||
setContentIntent(notificationIntent)
|
||||
setContentTitle(getString(R.string.notification_new_chapters))
|
||||
if (newUpdates.size > 1) {
|
||||
setContentText(getString(R.string.notification_new_chapters_text, newUpdates.size))
|
||||
setStyle(NotificationCompat.BigTextStyle().bigText(newUpdates.joinToString("\n")))
|
||||
} else {
|
||||
setContentText(newUpdates.first())
|
||||
}
|
||||
priority = NotificationCompat.PRIORITY_HIGH
|
||||
setContentIntent(getNotificationIntent())
|
||||
setAutoCancel(true)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels the notification.
|
||||
* Cancels the progress notification.
|
||||
*/
|
||||
private fun cancelNotification() {
|
||||
notificationManager.cancel(notificationId)
|
||||
private fun cancelProgressNotification() {
|
||||
notificationManager.cancel(Constants.NOTIFICATION_LIBRARY_PROGRESS_ID)
|
||||
}
|
||||
|
||||
/**
|
||||
* Property that returns an intent to open the main activity.
|
||||
* Returns an intent to open the main activity.
|
||||
*/
|
||||
private val notificationIntent: PendingIntent
|
||||
get() {
|
||||
val intent = Intent(this, MainActivity::class.java)
|
||||
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
return PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
|
||||
}
|
||||
private fun getNotificationIntent(): PendingIntent {
|
||||
val intent = Intent(this, MainActivity::class.java)
|
||||
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
return PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -3,8 +3,6 @@ package eu.kanade.tachiyomi.data.notification
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.support.v4.content.FileProvider
|
||||
import eu.kanade.tachiyomi.BuildConfig
|
||||
import eu.kanade.tachiyomi.ui.download.DownloadActivity
|
||||
import eu.kanade.tachiyomi.util.getUriCompat
|
||||
import java.io.File
|
||||
@ -33,7 +31,7 @@ object NotificationHandler {
|
||||
*/
|
||||
internal fun openImagePendingActivity(context: Context, file: File): PendingIntent {
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
val uri = FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".provider", file)
|
||||
val uri = file.getUriCompat(context)
|
||||
setDataAndType(uri, "image/*")
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
}
|
||||
|
@ -14,7 +14,10 @@ import eu.kanade.tachiyomi.data.download.DownloadManager
|
||||
import eu.kanade.tachiyomi.data.download.DownloadService
|
||||
import eu.kanade.tachiyomi.data.library.LibraryUpdateService
|
||||
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
|
||||
import eu.kanade.tachiyomi.util.*
|
||||
import eu.kanade.tachiyomi.util.DiskUtil
|
||||
import eu.kanade.tachiyomi.util.getUriCompat
|
||||
import eu.kanade.tachiyomi.util.notificationManager
|
||||
import eu.kanade.tachiyomi.util.toast
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.io.File
|
||||
import eu.kanade.tachiyomi.BuildConfig.APPLICATION_ID as ID
|
||||
@ -45,7 +48,7 @@ class NotificationReceiver : BroadcastReceiver() {
|
||||
ACTION_DELETE_IMAGE -> deleteImage(context, intent.getStringExtra(EXTRA_FILE_LOCATION),
|
||||
intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1))
|
||||
// Cancel library update and dismiss notification
|
||||
ACTION_CANCEL_LIBRARY_UPDATE -> cancelLibraryUpdate(context, Constants.NOTIFICATION_LIBRARY_ID)
|
||||
ACTION_CANCEL_LIBRARY_UPDATE -> cancelLibraryUpdate(context, Constants.NOTIFICATION_LIBRARY_PROGRESS_ID)
|
||||
// Open reader activity
|
||||
ACTION_OPEN_CHAPTER -> {
|
||||
openChapter(context, intent.getLongExtra(EXTRA_MANGA_ID, -1),
|
||||
@ -118,7 +121,7 @@ class NotificationReceiver : BroadcastReceiver() {
|
||||
|
||||
// Delete file
|
||||
val file = File(path)
|
||||
file.deleteIfExists()
|
||||
file.delete()
|
||||
|
||||
DiskUtil.scanMedia(context, file)
|
||||
}
|
||||
@ -274,4 +277,4 @@ class NotificationReceiver : BroadcastReceiver() {
|
||||
return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -65,12 +65,18 @@ class PreferenceKeys(context: Context) {
|
||||
|
||||
val enabledLanguages = context.getString(R.string.pref_source_languages)
|
||||
|
||||
val backupDirectory = context.getString(R.string.pref_backup_directory_key)
|
||||
|
||||
val downloadsDirectory = context.getString(R.string.pref_download_directory_key)
|
||||
|
||||
val downloadThreads = context.getString(R.string.pref_download_slots_key)
|
||||
|
||||
val downloadOnlyOverWifi = context.getString(R.string.pref_download_only_over_wifi_key)
|
||||
|
||||
val numberOfBackups = context.getString(R.string.pref_backup_slots_key)
|
||||
|
||||
val backupInterval = context.getString(R.string.pref_backup_interval_key)
|
||||
|
||||
val removeAfterReadSlots = context.getString(R.string.pref_remove_after_read_slots_key)
|
||||
|
||||
val removeAfterMarkedAsRead = context.getString(R.string.pref_remove_after_marked_as_read_key)
|
||||
@ -109,4 +115,6 @@ class PreferenceKeys(context: Context) {
|
||||
|
||||
val lang = context.getString(R.string.pref_language_key)
|
||||
|
||||
val defaultCategory = context.getString(R.string.default_category_key)
|
||||
|
||||
}
|
||||
|
@ -26,6 +26,10 @@ class PreferencesHelper(val context: Context) {
|
||||
File(Environment.getExternalStorageDirectory().absolutePath + File.separator +
|
||||
context.getString(R.string.app_name), "downloads"))
|
||||
|
||||
private val defaultBackupDir = Uri.fromFile(
|
||||
File(Environment.getExternalStorageDirectory().absolutePath + File.separator +
|
||||
context.getString(R.string.app_name), "backup"))
|
||||
|
||||
fun startScreen() = prefs.getInt(keys.startScreen, 1)
|
||||
|
||||
fun clear() = prefs.edit().clear().apply()
|
||||
@ -112,12 +116,18 @@ class PreferencesHelper(val context: Context) {
|
||||
|
||||
fun anilistScoreType() = rxPrefs.getInteger("anilist_score_type", 0)
|
||||
|
||||
fun backupsDirectory() = rxPrefs.getString(keys.backupDirectory, defaultBackupDir.toString())
|
||||
|
||||
fun downloadsDirectory() = rxPrefs.getString(keys.downloadsDirectory, defaultDownloadsDir.toString())
|
||||
|
||||
fun downloadThreads() = rxPrefs.getInteger(keys.downloadThreads, 1)
|
||||
|
||||
fun downloadOnlyOverWifi() = prefs.getBoolean(keys.downloadOnlyOverWifi, true)
|
||||
|
||||
fun numberOfBackups() = rxPrefs.getInteger(keys.numberOfBackups, 1)
|
||||
|
||||
fun backupInterval() = rxPrefs.getInteger(keys.backupInterval, 0)
|
||||
|
||||
fun removeAfterReadSlots() = prefs.getInt(keys.removeAfterReadSlots, -1)
|
||||
|
||||
fun removeAfterMarkedAsRead() = prefs.getBoolean(keys.removeAfterMarkedAsRead, false)
|
||||
@ -148,4 +158,6 @@ class PreferencesHelper(val context: Context) {
|
||||
|
||||
fun lang() = prefs.getString(keys.lang, "")
|
||||
|
||||
fun defaultCategory() = prefs.getInt(keys.defaultCategory, -1)
|
||||
|
||||
}
|
||||
|
@ -9,6 +9,7 @@ import eu.kanade.tachiyomi.data.track.TrackService
|
||||
import rx.Completable
|
||||
import rx.Observable
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.text.DecimalFormat
|
||||
|
||||
class Kitsu(private val context: Context, id: Int) : TrackService(id) {
|
||||
|
||||
@ -55,11 +56,17 @@ class Kitsu(private val context: Context, id: Int) : TrackService(id) {
|
||||
}
|
||||
|
||||
override fun getScoreList(): List<String> {
|
||||
return IntRange(0, 10).map { (it.toFloat() / 2).toString() }
|
||||
val df = DecimalFormat("0.#")
|
||||
return listOf("0") + IntRange(2, 20).map { df.format(it / 2f) }
|
||||
}
|
||||
|
||||
override fun indexToScore(index: Int): Float {
|
||||
return if (index > 0) (index + 1) / 2f else 0f
|
||||
}
|
||||
|
||||
override fun displayScore(track: Track): String {
|
||||
return track.toKitsuScore()
|
||||
val df = DecimalFormat("0.#")
|
||||
return df.format(track.score)
|
||||
}
|
||||
|
||||
override fun add(track: Track): Observable<Track> {
|
||||
|
@ -1,6 +1,7 @@
|
||||
package eu.kanade.tachiyomi.data.track.kitsu
|
||||
|
||||
import com.github.salomonbrys.kotson.*
|
||||
import com.google.gson.GsonBuilder
|
||||
import com.google.gson.JsonObject
|
||||
import eu.kanade.tachiyomi.data.database.models.Track
|
||||
import eu.kanade.tachiyomi.network.POST
|
||||
@ -17,7 +18,7 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
|
||||
private val rest = Retrofit.Builder()
|
||||
.baseUrl(baseUrl)
|
||||
.client(client.newBuilder().addInterceptor(interceptor).build())
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.addConverterFactory(GsonConverterFactory.create(GsonBuilder().serializeNulls().create()))
|
||||
.addCallAdapterFactory(RxJavaCallAdapterFactory.create())
|
||||
.build()
|
||||
.create(KitsuApi.Rest::class.java)
|
||||
@ -65,7 +66,7 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
|
||||
"attributes" to jsonObject(
|
||||
"status" to track.toKitsuStatus(),
|
||||
"progress" to track.last_chapter_read,
|
||||
"rating" to track.toKitsuScore()
|
||||
"ratingTwenty" to track.toKitsuScore()
|
||||
)
|
||||
)
|
||||
// @formatter:on
|
||||
|
@ -23,13 +23,13 @@ open class KitsuManga(obj: JsonObject) {
|
||||
class KitsuLibManga(obj: JsonObject, manga: JsonObject) : KitsuManga(manga) {
|
||||
val remoteId by obj.byInt("id")
|
||||
val status by obj["attributes"].byString
|
||||
val rating = obj["attributes"].obj.get("rating").nullString
|
||||
val ratingTwenty = obj["attributes"].obj.get("ratingTwenty").nullString
|
||||
val progress by obj["attributes"].byInt
|
||||
|
||||
override fun toTrack() = super.toTrack().apply {
|
||||
remote_id = remoteId
|
||||
status = toTrackStatus()
|
||||
score = rating?.let { it.toFloat() * 2 } ?: 0f
|
||||
score = ratingTwenty?.let { it.toInt() / 2f } ?: 0f
|
||||
last_chapter_read = progress
|
||||
}
|
||||
|
||||
@ -53,6 +53,6 @@ fun Track.toKitsuStatus() = when (status) {
|
||||
else -> throw Exception("Unknown status")
|
||||
}
|
||||
|
||||
fun Track.toKitsuScore(): String {
|
||||
return if (score > 0) (score / 2).toString() else ""
|
||||
fun Track.toKitsuScore(): String? {
|
||||
return if (score > 0) (score * 2).toInt().toString() else null
|
||||
}
|
||||
|
@ -152,6 +152,11 @@ class Mangahere : ParsedHttpSource() {
|
||||
}
|
||||
|
||||
override fun pageListParse(document: Document): List<Page> {
|
||||
val licensedError = document.select(".mangaread_error > .mt10").first()
|
||||
if (licensedError != null) {
|
||||
throw Exception(licensedError.text())
|
||||
}
|
||||
|
||||
val pages = mutableListOf<Page>()
|
||||
document.select("select.wid60").first()?.getElementsByTag("option")?.forEach {
|
||||
pages.add(Page(pages.size, it.attr("value")))
|
||||
|
@ -1,163 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.backup
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.Dialog
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import com.afollestad.materialdialogs.MaterialDialog
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.ui.base.activity.ActivityMixin
|
||||
import eu.kanade.tachiyomi.ui.base.fragment.BaseRxFragment
|
||||
import eu.kanade.tachiyomi.util.toast
|
||||
import kotlinx.android.synthetic.main.fragment_backup.*
|
||||
import nucleus.factory.RequiresPresenter
|
||||
import rx.Observable
|
||||
import rx.android.schedulers.AndroidSchedulers
|
||||
import rx.internal.util.SubscriptionList
|
||||
import rx.schedulers.Schedulers
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* Fragment to create and restore backups of the application's data.
|
||||
* Uses R.layout.fragment_backup.
|
||||
*/
|
||||
@RequiresPresenter(BackupPresenter::class)
|
||||
class BackupFragment : BaseRxFragment<BackupPresenter>() {
|
||||
|
||||
private var backupDialog: Dialog? = null
|
||||
private var restoreDialog: Dialog? = null
|
||||
|
||||
private lateinit var subscriptions: SubscriptionList
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedState: Bundle?): View {
|
||||
return inflater.inflate(R.layout.fragment_backup, container, false)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedState: Bundle?) {
|
||||
setToolbarTitle(getString(R.string.label_backup))
|
||||
|
||||
(activity as ActivityMixin).requestPermissionsOnMarshmallow()
|
||||
subscriptions = SubscriptionList()
|
||||
|
||||
backup_button.setOnClickListener {
|
||||
val today = SimpleDateFormat("yyyy-MM-dd").format(Date())
|
||||
val file = File(activity.externalCacheDir, "tachiyomi-$today.json")
|
||||
presenter.createBackup(file)
|
||||
|
||||
backupDialog = MaterialDialog.Builder(activity)
|
||||
.content(R.string.backup_please_wait)
|
||||
.progress(true, 0)
|
||||
.show()
|
||||
}
|
||||
|
||||
restore_button.setOnClickListener {
|
||||
val intent = Intent(Intent.ACTION_GET_CONTENT)
|
||||
intent.addCategory(Intent.CATEGORY_OPENABLE)
|
||||
intent.type = "application/*"
|
||||
val chooser = Intent.createChooser(intent, getString(R.string.file_select_backup))
|
||||
startActivityForResult(chooser, REQUEST_BACKUP_OPEN)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
subscriptions.unsubscribe()
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
/**
|
||||
* Called from the presenter when the backup is completed.
|
||||
*
|
||||
* @param file the file where the backup is saved.
|
||||
*/
|
||||
fun onBackupCompleted(file: File) {
|
||||
dismissBackupDialog()
|
||||
val intent = Intent(Intent.ACTION_SEND)
|
||||
intent.type = "application/json"
|
||||
intent.putExtra(Intent.EXTRA_STREAM, Uri.parse("file://" + file))
|
||||
startActivity(Intent.createChooser(intent, ""))
|
||||
}
|
||||
|
||||
/**
|
||||
* Called from the presenter when the restore is completed.
|
||||
*/
|
||||
fun onRestoreCompleted() {
|
||||
dismissRestoreDialog()
|
||||
context.toast(R.string.backup_completed)
|
||||
}
|
||||
|
||||
/**
|
||||
* Called from the presenter when there's an error doing the backup.
|
||||
* @param error the exception thrown.
|
||||
*/
|
||||
fun onBackupError(error: Throwable) {
|
||||
dismissBackupDialog()
|
||||
context.toast(error.message)
|
||||
}
|
||||
|
||||
/**
|
||||
* Called from the presenter when there's an error restoring the backup.
|
||||
* @param error the exception thrown.
|
||||
*/
|
||||
fun onRestoreError(error: Throwable) {
|
||||
dismissRestoreDialog()
|
||||
context.toast(error.message)
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
if (data != null && resultCode == Activity.RESULT_OK && requestCode == REQUEST_BACKUP_OPEN) {
|
||||
restoreDialog = MaterialDialog.Builder(activity)
|
||||
.content(R.string.restore_please_wait)
|
||||
.progress(true, 0)
|
||||
.show()
|
||||
|
||||
// When using cloud services, we have to open the input stream in a background thread.
|
||||
Observable.fromCallable { context.contentResolver.openInputStream(data.data) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe({
|
||||
presenter.restoreBackup(it)
|
||||
}, { error ->
|
||||
context.toast(error.message)
|
||||
Timber.e(error)
|
||||
})
|
||||
.apply { subscriptions.add(this) }
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismisses the backup dialog.
|
||||
*/
|
||||
fun dismissBackupDialog() {
|
||||
backupDialog?.let {
|
||||
it.dismiss()
|
||||
backupDialog = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismisses the restore dialog.
|
||||
*/
|
||||
fun dismissRestoreDialog() {
|
||||
restoreDialog?.let {
|
||||
it.dismiss()
|
||||
restoreDialog = null
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private val REQUEST_BACKUP_OPEN = 102
|
||||
|
||||
fun newInstance(): BackupFragment {
|
||||
return BackupFragment()
|
||||
}
|
||||
}
|
||||
}
|
@ -1,94 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.backup
|
||||
|
||||
import android.os.Bundle
|
||||
import eu.kanade.tachiyomi.data.backup.BackupManager
|
||||
import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
||||
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
|
||||
import eu.kanade.tachiyomi.util.isNullOrUnsubscribed
|
||||
import rx.Observable
|
||||
import rx.Subscription
|
||||
import rx.android.schedulers.AndroidSchedulers
|
||||
import rx.schedulers.Schedulers
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
|
||||
/**
|
||||
* Presenter of [BackupFragment].
|
||||
*/
|
||||
class BackupPresenter : BasePresenter<BackupFragment>() {
|
||||
|
||||
/**
|
||||
* Database.
|
||||
*/
|
||||
val db: DatabaseHelper by injectLazy()
|
||||
|
||||
/**
|
||||
* Backup manager.
|
||||
*/
|
||||
private lateinit var backupManager: BackupManager
|
||||
|
||||
/**
|
||||
* Subscription where the backup is restored.
|
||||
*/
|
||||
private var restoreSubscription: Subscription? = null
|
||||
|
||||
/**
|
||||
* Subscription where the backup is created.
|
||||
*/
|
||||
private var backupSubscription: Subscription? = null
|
||||
|
||||
override fun onCreate(savedState: Bundle?) {
|
||||
super.onCreate(savedState)
|
||||
backupManager = BackupManager(db)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a backup and saves it to a file.
|
||||
*
|
||||
* @param file the path where the file will be saved.
|
||||
*/
|
||||
fun createBackup(file: File) {
|
||||
if (backupSubscription.isNullOrUnsubscribed()) {
|
||||
backupSubscription = getBackupObservable(file)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribeFirst(
|
||||
{ view, result -> view.onBackupCompleted(file) },
|
||||
BackupFragment::onBackupError)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores a backup from a stream.
|
||||
*
|
||||
* @param stream the input stream of the backup file.
|
||||
*/
|
||||
fun restoreBackup(stream: InputStream) {
|
||||
if (restoreSubscription.isNullOrUnsubscribed()) {
|
||||
restoreSubscription = getRestoreObservable(stream)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribeFirst(
|
||||
{ view, result -> view.onRestoreCompleted() },
|
||||
BackupFragment::onRestoreError)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the observable to save a backup.
|
||||
*/
|
||||
private fun getBackupObservable(file: File) = Observable.fromCallable {
|
||||
backupManager.backupToFile(file)
|
||||
true
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the observable to restore a backup.
|
||||
*/
|
||||
private fun getRestoreObservable(stream: InputStream) = Observable.fromCallable {
|
||||
backupManager.restoreFromStream(stream)
|
||||
true
|
||||
}
|
||||
|
||||
}
|
@ -15,7 +15,9 @@ import com.f2prateek.rx.preferences.Preference
|
||||
import eu.davidea.flexibleadapter.FlexibleAdapter
|
||||
import eu.davidea.flexibleadapter.items.IFlexible
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.database.models.Category
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
import eu.kanade.tachiyomi.ui.base.fragment.BaseRxFragment
|
||||
import eu.kanade.tachiyomi.ui.main.MainActivity
|
||||
@ -33,6 +35,7 @@ import nucleus.factory.RequiresPresenter
|
||||
import rx.Subscription
|
||||
import rx.android.schedulers.AndroidSchedulers
|
||||
import rx.subjects.PublishSubject
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.util.concurrent.TimeUnit.MILLISECONDS
|
||||
|
||||
/**
|
||||
@ -45,6 +48,11 @@ open class CatalogueFragment : BaseRxFragment<CataloguePresenter>(),
|
||||
FlexibleAdapter.OnItemLongClickListener,
|
||||
FlexibleAdapter.EndlessScrollListener<ProgressItem> {
|
||||
|
||||
/**
|
||||
* Preferences helper.
|
||||
*/
|
||||
private val preferences: PreferencesHelper by injectLazy()
|
||||
|
||||
/**
|
||||
* Spinner shown in the toolbar to change the selected source.
|
||||
*/
|
||||
@ -530,23 +538,72 @@ open class CatalogueFragment : BaseRxFragment<CataloguePresenter>(),
|
||||
/**
|
||||
* Called when a manga is long clicked.
|
||||
*
|
||||
* Adds the manga to the default category if none is set it shows a list of categories for the user to put the manga
|
||||
* in, the list consists of the default category plus the user's categories. The default category is preselected on
|
||||
* new manga, and on already favorited manga the manga's categories are preselected.
|
||||
*
|
||||
* @param position the position of the element clicked.
|
||||
*/
|
||||
override fun onItemLongClick(position: Int) {
|
||||
// Get manga
|
||||
val manga = (adapter.getItem(position) as? CatalogueItem?)?.manga ?: return
|
||||
// Fetch categories
|
||||
val categories = presenter.getCategories()
|
||||
|
||||
val textRes = if (manga.favorite) R.string.remove_from_library else R.string.add_to_library
|
||||
|
||||
MaterialDialog.Builder(activity)
|
||||
.items(getString(textRes))
|
||||
.itemsCallback { dialog, itemView, which, text ->
|
||||
when (which) {
|
||||
0 -> {
|
||||
presenter.changeMangaFavorite(manga)
|
||||
adapter.notifyItemChanged(position)
|
||||
if (manga.favorite){
|
||||
MaterialDialog.Builder(activity)
|
||||
.items(getString(R.string.remove_from_library ))
|
||||
.itemsCallback { _, _, which, _ ->
|
||||
when (which) {
|
||||
0 -> {
|
||||
presenter.changeMangaFavorite(manga)
|
||||
adapter.notifyItemChanged(position)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.show()
|
||||
}.show()
|
||||
}else{
|
||||
val defaultCategory = categories.find { it.id == preferences.defaultCategory()}
|
||||
if(defaultCategory != null) {
|
||||
presenter.changeMangaFavorite(manga)
|
||||
presenter.moveMangaToCategory(defaultCategory, manga)
|
||||
// Show manga has been added
|
||||
context.toast(R.string.added_to_library)
|
||||
adapter.notifyItemChanged(position)
|
||||
} else {
|
||||
MaterialDialog.Builder(activity)
|
||||
.title(R.string.action_move_category)
|
||||
.items(categories.map { it.name })
|
||||
.itemsCallbackMultiChoice(presenter.getMangaCategoryIds(manga)) { dialog, position, _ ->
|
||||
if (position.contains(0) && position.count() > 1) {
|
||||
// Deselect default category
|
||||
dialog.setSelectedIndices(position.filter {it > 0}.toTypedArray())
|
||||
dialog.context.toast(R.string.invalid_combination)
|
||||
}
|
||||
true
|
||||
}
|
||||
.alwaysCallMultiChoiceCallback()
|
||||
.positiveText(android.R.string.ok)
|
||||
.negativeText(android.R.string.cancel)
|
||||
.onPositive { dialog, _ ->
|
||||
val selectedCategories = dialog.selectedIndices?.map { categories[it] } ?: emptyList()
|
||||
updateMangaCategories(manga, selectedCategories, position)
|
||||
}
|
||||
.build()
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update manga to use selected categories.
|
||||
*
|
||||
* @param manga needed to change
|
||||
* @param selectedCategories selected categories
|
||||
* @param position position of adapter
|
||||
*/
|
||||
private fun updateMangaCategories(manga: Manga, selectedCategories: List<Category>, position: Int) {
|
||||
presenter.updateMangaCategories(manga,selectedCategories)
|
||||
adapter.notifyItemChanged(position)
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -5,7 +5,9 @@ import eu.davidea.flexibleadapter.items.IFlexible
|
||||
import eu.davidea.flexibleadapter.items.ISectionable
|
||||
import eu.kanade.tachiyomi.data.cache.CoverCache
|
||||
import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
||||
import eu.kanade.tachiyomi.data.database.models.Category
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.database.models.MangaCategory
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||
import eu.kanade.tachiyomi.source.CatalogueSource
|
||||
@ -396,4 +398,66 @@ open class CataloguePresenter : BasePresenter<CatalogueFragment>() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default, and user categories.
|
||||
*
|
||||
* @return List of categories, default plus user categories
|
||||
*/
|
||||
fun getCategories(): List<Category> {
|
||||
return arrayListOf(Category.createDefault()) + db.getCategories().executeAsBlocking()
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the category id's the manga is in, if the manga is not in a category, returns the default id.
|
||||
*
|
||||
* @param manga the manga to get categories from.
|
||||
* @return Array of category ids the manga is in, if none returns default id
|
||||
*/
|
||||
fun getMangaCategoryIds(manga: Manga): Array<Int?> {
|
||||
val categories = db.getCategoriesForManga(manga).executeAsBlocking()
|
||||
if (categories.isEmpty()) {
|
||||
return arrayListOf(Category.createDefault().id).toTypedArray()
|
||||
}
|
||||
return categories.map { it.id }.toTypedArray()
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the given manga to categories.
|
||||
*
|
||||
* @param categories the selected categories.
|
||||
* @param manga the manga to move.
|
||||
*/
|
||||
fun moveMangaToCategories(categories: List<Category>, manga: Manga) {
|
||||
val mc = categories.map { MangaCategory.create(manga, it) }
|
||||
|
||||
db.setMangaCategories(mc, arrayListOf(manga))
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the given manga to the category.
|
||||
*
|
||||
* @param category the selected category.
|
||||
* @param manga the manga to move.
|
||||
*/
|
||||
fun moveMangaToCategory(category: Category, manga: Manga) {
|
||||
moveMangaToCategories(arrayListOf(category), manga)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update manga to use selected categories.
|
||||
*
|
||||
* @param manga needed to change
|
||||
* @param selectedCategories selected categories
|
||||
*/
|
||||
fun updateMangaCategories(manga: Manga, selectedCategories: List<Category>) {
|
||||
if (!selectedCategories.isEmpty()) {
|
||||
if (!manga.favorite)
|
||||
changeMangaFavorite(manga)
|
||||
|
||||
moveMangaToCategories(selectedCategories.filter { it.id != 0 }, manga)
|
||||
} else {
|
||||
changeMangaFavorite(manga)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -441,11 +441,11 @@ class LibraryFragment : BaseRxFragment<LibraryPresenter>(), ActionMode.Callback
|
||||
if (presenter.editCoverWithStream(it, manga)) {
|
||||
// TODO refresh cover
|
||||
} else {
|
||||
context.toast(R.string.notification_manga_update_failed)
|
||||
context.toast(R.string.notification_cover_update_failed)
|
||||
}
|
||||
}
|
||||
} catch (error: IOException) {
|
||||
context.toast(R.string.notification_manga_update_failed)
|
||||
context.toast(R.string.notification_cover_update_failed)
|
||||
Timber.e(error)
|
||||
}
|
||||
}
|
||||
|
@ -8,7 +8,6 @@ import android.support.v4.view.GravityCompat
|
||||
import android.view.MenuItem
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.ui.backup.BackupFragment
|
||||
import eu.kanade.tachiyomi.ui.base.activity.BaseActivity
|
||||
import eu.kanade.tachiyomi.ui.catalogue.CatalogueFragment
|
||||
import eu.kanade.tachiyomi.ui.download.DownloadActivity
|
||||
@ -38,8 +37,8 @@ class MainActivity : BaseActivity() {
|
||||
setAppTheme()
|
||||
super.onCreate(savedState)
|
||||
|
||||
// Do not let the launcher create a new activity
|
||||
if (intent.flags and Intent.FLAG_ACTIVITY_BROUGHT_TO_FRONT != 0) {
|
||||
// Do not let the launcher create a new activity http://stackoverflow.com/questions/16283079
|
||||
if (!isTaskRoot) {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
@ -71,7 +70,6 @@ class MainActivity : BaseActivity() {
|
||||
val intent = Intent(this, SettingsActivity::class.java)
|
||||
startActivityForResult(intent, REQUEST_OPEN_SETTINGS)
|
||||
}
|
||||
R.id.nav_drawer_backup -> setFragment(BackupFragment.newInstance(), id)
|
||||
}
|
||||
}
|
||||
drawer.closeDrawer(GravityCompat.START)
|
||||
@ -80,11 +78,19 @@ class MainActivity : BaseActivity() {
|
||||
|
||||
if (savedState == null) {
|
||||
// Set start screen
|
||||
setSelectedDrawerItem(startScreenId)
|
||||
when (intent.action) {
|
||||
SHORTCUT_LIBRARY -> setSelectedDrawerItem(R.id.nav_drawer_library)
|
||||
SHORTCUT_RECENTLY_UPDATED -> setSelectedDrawerItem(R.id.nav_drawer_recent_updates)
|
||||
SHORTCUT_RECENTLY_READ -> setSelectedDrawerItem(R.id.nav_drawer_recently_read)
|
||||
SHORTCUT_CATALOGUES -> setSelectedDrawerItem(R.id.nav_drawer_catalogues)
|
||||
else -> setSelectedDrawerItem(startScreenId)
|
||||
}
|
||||
|
||||
// Show changelog if needed
|
||||
ChangelogDialogFragment.show(this, preferences, supportFragmentManager)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
@ -145,5 +151,10 @@ class MainActivity : BaseActivity() {
|
||||
|
||||
companion object {
|
||||
private const val REQUEST_OPEN_SETTINGS = 200
|
||||
// Shortcut actions
|
||||
private const val SHORTCUT_LIBRARY = "eu.kanade.tachiyomi.SHOW_LIBRARY"
|
||||
private const val SHORTCUT_RECENTLY_UPDATED = "eu.kanade.tachiyomi.SHOW_RECENTLY_UPDATED"
|
||||
private const val SHORTCUT_RECENTLY_READ = "eu.kanade.tachiyomi.SHOW_RECENTLY_READ"
|
||||
private const val SHORTCUT_CATALOGUES = "eu.kanade.tachiyomi.SHOW_CATALOGUES"
|
||||
}
|
||||
}
|
||||
|
@ -6,6 +6,7 @@ import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.support.customtabs.CustomTabsIntent
|
||||
import android.view.*
|
||||
import android.widget.Toast
|
||||
import com.afollestad.materialdialogs.MaterialDialog
|
||||
import com.bumptech.glide.BitmapRequestBuilder
|
||||
import com.bumptech.glide.BitmapTypeRequest
|
||||
@ -14,6 +15,7 @@ import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||
import com.bumptech.glide.load.resource.bitmap.CenterCrop
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
@ -31,6 +33,7 @@ import nucleus.factory.RequiresPresenter
|
||||
import rx.Observable
|
||||
import rx.android.schedulers.AndroidSchedulers
|
||||
import rx.schedulers.Schedulers
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
/**
|
||||
* Fragment that shows manga information.
|
||||
@ -52,6 +55,11 @@ class MangaInfoFragment : BaseRxFragment<MangaInfoPresenter>() {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Preferences helper.
|
||||
*/
|
||||
private val preferences: PreferencesHelper by injectLazy()
|
||||
|
||||
override fun onCreate(savedState: Bundle?) {
|
||||
super.onCreate(savedState)
|
||||
setHasOptionsMenu(true)
|
||||
@ -63,7 +71,19 @@ class MangaInfoFragment : BaseRxFragment<MangaInfoPresenter>() {
|
||||
|
||||
override fun onViewCreated(view: View?, savedState: Bundle?) {
|
||||
// Set onclickListener to toggle favorite when FAB clicked.
|
||||
fab_favorite.setOnClickListener { toggleFavorite() }
|
||||
fab_favorite.setOnClickListener {
|
||||
if(!presenter.manga.favorite) {
|
||||
val defaultCategory = presenter.getCategories().find { it.id == preferences.defaultCategory()}
|
||||
if(defaultCategory == null) {
|
||||
onFabClick()
|
||||
} else {
|
||||
toggleFavorite()
|
||||
presenter.moveMangaToCategory(defaultCategory, presenter.manga)
|
||||
}
|
||||
} else {
|
||||
toggleFavorite()
|
||||
}
|
||||
}
|
||||
|
||||
// Set SwipeRefresh to refresh manga data.
|
||||
swipe_refresh.setOnRefreshListener { fetchMangaFromSource() }
|
||||
@ -334,4 +354,40 @@ class MangaInfoFragment : BaseRxFragment<MangaInfoPresenter>() {
|
||||
swipe_refresh.isRefreshing = value
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the fab is clicked.
|
||||
*/
|
||||
private fun onFabClick() {
|
||||
val categories = presenter.getCategories()
|
||||
|
||||
MaterialDialog.Builder(activity)
|
||||
.title(R.string.action_move_category)
|
||||
.items(categories.map { it.name })
|
||||
.itemsCallbackMultiChoice(presenter.getMangaCategoryIds(presenter.manga)) { dialog, position, text ->
|
||||
if (position.contains(0) && position.count() > 1) {
|
||||
dialog.setSelectedIndices(position.filter {it > 0}.toTypedArray())
|
||||
Toast.makeText(dialog.context, R.string.invalid_combination, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
.alwaysCallMultiChoiceCallback()
|
||||
.positiveText(android.R.string.ok)
|
||||
.negativeText(android.R.string.cancel)
|
||||
.onPositive { dialog, _ ->
|
||||
val selectedCategories = dialog.selectedIndices?.map { categories[it] } ?: emptyList()
|
||||
|
||||
if(!selectedCategories.isEmpty()) {
|
||||
if(!presenter.manga.favorite) {
|
||||
toggleFavorite()
|
||||
}
|
||||
presenter.moveMangaToCategories(selectedCategories.filter { it.id != 0}, presenter.manga)
|
||||
} else {
|
||||
toggleFavorite()
|
||||
}
|
||||
}
|
||||
.build()
|
||||
.show()
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -3,7 +3,9 @@ package eu.kanade.tachiyomi.ui.manga.info
|
||||
import android.os.Bundle
|
||||
import eu.kanade.tachiyomi.data.cache.CoverCache
|
||||
import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
||||
import eu.kanade.tachiyomi.data.database.models.Category
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.database.models.MangaCategory
|
||||
import eu.kanade.tachiyomi.data.download.DownloadManager
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.SourceManager
|
||||
@ -76,9 +78,12 @@ class MangaInfoPresenter : BasePresenter<MangaInfoFragment>() {
|
||||
?.subscribeLatestCache(MangaInfoFragment::setChapterCount)
|
||||
|
||||
// Update favorite status
|
||||
SharedData.get(MangaFavoriteEvent::class.java)?.observable
|
||||
?.observeOn(AndroidSchedulers.mainThread())
|
||||
?.subscribe { setFavorite(it) }
|
||||
SharedData.get(MangaFavoriteEvent::class.java)?.let {
|
||||
it.observable
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe { setFavorite(it) }
|
||||
.apply { add(this) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -148,4 +153,49 @@ class MangaInfoPresenter : BasePresenter<MangaInfoFragment>() {
|
||||
downloadManager.findMangaDir(source, manga)?.delete()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default, and user categories.
|
||||
*
|
||||
* @return List of categories, default plus user categories
|
||||
*/
|
||||
fun getCategories(): List<Category> {
|
||||
return arrayListOf(Category.createDefault()) + db.getCategories().executeAsBlocking()
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the category id's the manga is in, if the manga is not in a category, returns the default id.
|
||||
*
|
||||
* @param manga the manga to get categories from.
|
||||
* @return Array of category ids the manga is in, if none returns default id
|
||||
*/
|
||||
fun getMangaCategoryIds(manga: Manga): Array<Int?> {
|
||||
val categories = db.getCategoriesForManga(manga).executeAsBlocking()
|
||||
if(categories.isEmpty()) {
|
||||
return arrayListOf(Category.createDefault().id).toTypedArray()
|
||||
}
|
||||
return categories.map { it.id }.toTypedArray()
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the given manga to categories.
|
||||
*
|
||||
* @param categories the selected categories.
|
||||
* @param manga the manga to move.
|
||||
*/
|
||||
fun moveMangaToCategories(categories: List<Category>, manga: Manga) {
|
||||
val mc = categories.map { MangaCategory.create(manga, it) }
|
||||
|
||||
db.setMangaCategories(mc, arrayListOf(manga))
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the given manga to the category.
|
||||
*
|
||||
* @param category the selected category.
|
||||
* @param manga the manga to move.
|
||||
*/
|
||||
fun moveMangaToCategory(category: Category, manga: Manga) {
|
||||
moveMangaToCategories(arrayListOf(category), manga)
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -76,10 +76,10 @@ class WebtoonHolder(private val view: View, private val adapter: WebtoonAdapter)
|
||||
})
|
||||
}
|
||||
|
||||
view.progress_container.minimumHeight = view.resources.displayMetrics.heightPixels * 2
|
||||
view.progress_container.minimumHeight = webtoonReader.screenHeight
|
||||
|
||||
view.setOnTouchListener(adapter.touchListener)
|
||||
view.retry_button.setOnTouchListener { v, event ->
|
||||
view.retry_button.setOnTouchListener { _, event ->
|
||||
if (event.action == MotionEvent.ACTION_UP) {
|
||||
readerActivity.presenter.retryPage(page)
|
||||
}
|
||||
|
@ -1,7 +1,9 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer.webtoon
|
||||
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.support.v7.widget.RecyclerView
|
||||
import android.util.DisplayMetrics
|
||||
import android.view.*
|
||||
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
|
||||
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
@ -64,6 +66,19 @@ class WebtoonReader : BaseReader() {
|
||||
|
||||
private var scrollDistance: Int = 0
|
||||
|
||||
val screenHeight by lazy {
|
||||
val display = activity.windowManager.defaultDisplay
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
|
||||
val metrics = DisplayMetrics()
|
||||
display.getRealMetrics(metrics)
|
||||
metrics.heightPixels
|
||||
} else {
|
||||
val field = Display::class.java.getMethod("getRawHeight")
|
||||
field.invoke(display) as Int
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedState: Bundle?): View? {
|
||||
adapter = WebtoonAdapter(this)
|
||||
|
||||
@ -72,9 +87,6 @@ class WebtoonReader : BaseReader() {
|
||||
|
||||
layoutManager = PreCachingLayoutManager(activity)
|
||||
layoutManager.extraLayoutSpace = screenHeight / 2
|
||||
if (savedState != null) {
|
||||
layoutManager.scrollToPositionWithOffset(savedState.getInt(SAVED_POSITION), 0)
|
||||
}
|
||||
|
||||
recycler = RecyclerView(activity).apply {
|
||||
layoutParams = ViewGroup.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
|
||||
@ -107,6 +119,26 @@ class WebtoonReader : BaseReader() {
|
||||
return recycler
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Uses two ways to scroll to the last page read.
|
||||
*/
|
||||
private fun scrollToLastPageRead(page: Int) {
|
||||
// Scrolls to the correct page initially, but isn't reliable beyond that.
|
||||
recycler.addOnLayoutChangeListener(object: View.OnLayoutChangeListener {
|
||||
override fun onLayoutChange(p0: View?, p1: Int, p2: Int, p3: Int, p4: Int, p5: Int, p6: Int, p7: Int, p8: Int) {
|
||||
if(pages.isEmpty()) {
|
||||
setActivePage(page)
|
||||
} else {
|
||||
recycler.removeOnLayoutChangeListener(this)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Scrolls to the correct page after app has been in use, but can't do it the very first time.
|
||||
recycler.post { setActivePage(page) }
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
subscriptions.unsubscribe()
|
||||
super.onDestroyView()
|
||||
@ -150,6 +182,7 @@ class WebtoonReader : BaseReader() {
|
||||
// Make sure the view is already initialized.
|
||||
if (view != null) {
|
||||
setPagesOnAdapter()
|
||||
scrollToLastPageRead(this.currentPage)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -65,6 +65,7 @@ class SettingsActivity : BaseActivity(),
|
||||
"downloads_screen" -> SettingsDownloadsFragment.newInstance(key)
|
||||
"sources_screen" -> SettingsSourcesFragment.newInstance(key)
|
||||
"tracking_screen" -> SettingsTrackingFragment.newInstance(key)
|
||||
"backup_screen" -> SettingsBackupFragment.newInstance(key)
|
||||
"advanced_screen" -> SettingsAdvancedFragment.newInstance(key)
|
||||
"about_screen" -> SettingsAboutFragment.newInstance(key)
|
||||
else -> SettingsFragment.newInstance(key)
|
||||
|
@ -108,6 +108,7 @@ class SettingsAdvancedFragment : SettingsFragment() {
|
||||
.onPositive { dialog, which ->
|
||||
(activity as SettingsActivity).parentFlags = SettingsActivity.FLAG_DATABASE_CLEARED
|
||||
db.deleteMangasNotInLibrary().executeAsBlocking()
|
||||
db.deleteHistoryNoLastRead().executeAsBlocking()
|
||||
activity.toast(R.string.clear_database_completed)
|
||||
}
|
||||
.show()
|
||||
|
@ -0,0 +1,422 @@
|
||||
package eu.kanade.tachiyomi.ui.setting
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.Dialog
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.support.v7.preference.XpPreferenceFragment
|
||||
import android.view.View
|
||||
import com.afollestad.materialdialogs.MaterialDialog
|
||||
import com.hippo.unifile.UniFile
|
||||
import com.nononsenseapps.filepicker.FilePickerActivity
|
||||
import eu.kanade.tachiyomi.R
|
||||
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.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||
import eu.kanade.tachiyomi.ui.base.activity.BaseActivity
|
||||
import eu.kanade.tachiyomi.util.*
|
||||
import eu.kanade.tachiyomi.widget.CustomLayoutPickerActivity
|
||||
import eu.kanade.tachiyomi.widget.preference.IntListPreference
|
||||
import net.xpece.android.support.preference.Preference
|
||||
import rx.subscriptions.Subscriptions
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.io.File
|
||||
import java.util.concurrent.TimeUnit
|
||||
import eu.kanade.tachiyomi.BuildConfig.APPLICATION_ID as ID
|
||||
|
||||
/**
|
||||
* Settings for [BackupCreateService] and [BackupRestoreService]
|
||||
*/
|
||||
class SettingsBackupFragment : SettingsFragment() {
|
||||
|
||||
companion object {
|
||||
const val INTENT_FILTER = "SettingsBackupFragment"
|
||||
const val ACTION_BACKUP_COMPLETED_DIALOG = "$ID.$INTENT_FILTER.ACTION_BACKUP_COMPLETED_DIALOG"
|
||||
const val ACTION_SET_PROGRESS_DIALOG = "$ID.$INTENT_FILTER.ACTION_SET_PROGRESS_DIALOG"
|
||||
const val ACTION_ERROR_BACKUP_DIALOG = "$ID.$INTENT_FILTER.ACTION_ERROR_BACKUP_DIALOG"
|
||||
const val ACTION_ERROR_RESTORE_DIALOG = "$ID.$INTENT_FILTER.ACTION_ERROR_RESTORE_DIALOG"
|
||||
const val ACTION_RESTORE_COMPLETED_DIALOG = "$ID.$INTENT_FILTER.ACTION_RESTORE_COMPLETED_DIALOG"
|
||||
const val ACTION = "$ID.$INTENT_FILTER.ACTION"
|
||||
const val EXTRA_PROGRESS = "$ID.$INTENT_FILTER.EXTRA_PROGRESS"
|
||||
const val EXTRA_AMOUNT = "$ID.$INTENT_FILTER.EXTRA_AMOUNT"
|
||||
const val EXTRA_ERRORS = "$ID.$INTENT_FILTER.EXTRA_ERRORS"
|
||||
const val EXTRA_CONTENT = "$ID.$INTENT_FILTER.EXTRA_CONTENT"
|
||||
const val EXTRA_ERROR_MESSAGE = "$ID.$INTENT_FILTER.EXTRA_ERROR_MESSAGE"
|
||||
const val EXTRA_URI = "$ID.$INTENT_FILTER.EXTRA_URI"
|
||||
const val EXTRA_TIME = "$ID.$INTENT_FILTER.EXTRA_TIME"
|
||||
const val EXTRA_ERROR_FILE_PATH = "$ID.$INTENT_FILTER.EXTRA_ERROR_FILE_PATH"
|
||||
const val EXTRA_ERROR_FILE = "$ID.$INTENT_FILTER.EXTRA_ERROR_FILE"
|
||||
|
||||
private const val BACKUP_CREATE = 201
|
||||
private const val BACKUP_RESTORE = 202
|
||||
private const val BACKUP_DIR = 203
|
||||
|
||||
fun newInstance(rootKey: String): SettingsBackupFragment {
|
||||
val args = Bundle()
|
||||
args.putString(XpPreferenceFragment.ARG_PREFERENCE_ROOT, rootKey)
|
||||
return SettingsBackupFragment().apply { arguments = args }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Preference selected to create backup
|
||||
*/
|
||||
private val createBackup: Preference by bindPref(R.string.pref_create_local_backup_key)
|
||||
|
||||
/**
|
||||
* Preference selected to restore backup
|
||||
*/
|
||||
private val restoreBackup: Preference by bindPref(R.string.pref_restore_local_backup_key)
|
||||
|
||||
/**
|
||||
* Preference which determines the frequency of automatic backups.
|
||||
*/
|
||||
private val automaticBackup: IntListPreference by bindPref(R.string.pref_backup_interval_key)
|
||||
|
||||
/**
|
||||
* Preference containing number of automatic backups
|
||||
*/
|
||||
private val backupSlots: IntListPreference by bindPref(R.string.pref_backup_slots_key)
|
||||
|
||||
/**
|
||||
* Preference containing interval of automatic backups
|
||||
*/
|
||||
private val backupDirPref: Preference by bindPref(R.string.pref_backup_directory_key)
|
||||
|
||||
/**
|
||||
* Preferences
|
||||
*/
|
||||
private val preferences: PreferencesHelper by injectLazy()
|
||||
|
||||
/**
|
||||
* Value containing information on what to backup
|
||||
*/
|
||||
private var backup_flags = 0
|
||||
|
||||
/**
|
||||
* The root directory for backups..
|
||||
*/
|
||||
private var backupDir = preferences.backupsDirectory().getOrDefault().let {
|
||||
UniFile.fromUri(context, Uri.parse(it))
|
||||
}
|
||||
|
||||
val restoreDialog: MaterialDialog by lazy {
|
||||
MaterialDialog.Builder(context)
|
||||
.title(R.string.backup)
|
||||
.content(R.string.restoring_backup)
|
||||
.progress(false, 100, true)
|
||||
.cancelable(false)
|
||||
.negativeText(R.string.action_stop)
|
||||
.onNegative { materialDialog, _ ->
|
||||
BackupRestoreService.stop(context)
|
||||
materialDialog.dismiss()
|
||||
}
|
||||
.build()
|
||||
}
|
||||
|
||||
val backupDialog: MaterialDialog by lazy {
|
||||
MaterialDialog.Builder(context)
|
||||
.title(R.string.backup)
|
||||
.content(R.string.creating_backup)
|
||||
.progress(true, 0)
|
||||
.cancelable(false)
|
||||
.build()
|
||||
}
|
||||
|
||||
private val receiver = object : BroadcastReceiver() {
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
when (intent.getStringExtra(ACTION)) {
|
||||
ACTION_BACKUP_COMPLETED_DIALOG -> {
|
||||
backupDialog.dismiss()
|
||||
val uri = Uri.parse(intent.getStringExtra(EXTRA_URI))
|
||||
val file = UniFile.fromUri(context, uri)
|
||||
MaterialDialog.Builder(this@SettingsBackupFragment.context)
|
||||
.title(getString(R.string.backup_created))
|
||||
.content(getString(R.string.file_saved, file.filePath))
|
||||
.positiveText(getString(R.string.action_close))
|
||||
.negativeText(getString(R.string.action_export))
|
||||
.onPositive { materialDialog, _ -> materialDialog.dismiss() }
|
||||
.onNegative { _, _ ->
|
||||
val sendIntent = Intent(Intent.ACTION_SEND)
|
||||
sendIntent.type = "application/json"
|
||||
sendIntent.putExtra(Intent.EXTRA_STREAM, file.uri)
|
||||
startActivity(Intent.createChooser(sendIntent, ""))
|
||||
}
|
||||
.safeShow()
|
||||
|
||||
}
|
||||
ACTION_SET_PROGRESS_DIALOG -> {
|
||||
val progress = intent.getIntExtra(EXTRA_PROGRESS, 0)
|
||||
val amount = intent.getIntExtra(EXTRA_AMOUNT, 0)
|
||||
val content = intent.getStringExtra(EXTRA_CONTENT)
|
||||
restoreDialog.setContent(content)
|
||||
restoreDialog.setProgress(progress)
|
||||
restoreDialog.maxProgress = amount
|
||||
}
|
||||
ACTION_RESTORE_COMPLETED_DIALOG -> {
|
||||
restoreDialog.dismiss()
|
||||
val time = intent.getLongExtra(EXTRA_TIME, 0)
|
||||
val errors = intent.getIntExtra(EXTRA_ERRORS, 0)
|
||||
val path = intent.getStringExtra(EXTRA_ERROR_FILE_PATH)
|
||||
val file = intent.getStringExtra(EXTRA_ERROR_FILE)
|
||||
val timeString = String.format("%02d min, %02d sec",
|
||||
TimeUnit.MILLISECONDS.toMinutes(time),
|
||||
TimeUnit.MILLISECONDS.toSeconds(time) -
|
||||
TimeUnit.MINUTES.toSeconds(TimeUnit.MILLISECONDS.toMinutes(time))
|
||||
)
|
||||
|
||||
if (errors > 0) {
|
||||
MaterialDialog.Builder(this@SettingsBackupFragment.context)
|
||||
.title(getString(R.string.restore_completed))
|
||||
.content(getString(R.string.restore_completed_content, timeString,
|
||||
if (errors > 0) "$errors" else getString(android.R.string.no)))
|
||||
.positiveText(getString(R.string.action_close))
|
||||
.negativeText(getString(R.string.action_open_log))
|
||||
.onPositive { materialDialog, _ -> materialDialog.dismiss() }
|
||||
.onNegative { materialDialog, _ ->
|
||||
if (!path.isEmpty()) {
|
||||
val destFile = File(path, file)
|
||||
val uri = destFile.getUriCompat(context)
|
||||
val sendIntent = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, "text/plain")
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
}
|
||||
startActivity(sendIntent)
|
||||
} else {
|
||||
context.toast(getString(R.string.error_opening_log))
|
||||
}
|
||||
materialDialog.dismiss()
|
||||
}
|
||||
.safeShow()
|
||||
}
|
||||
}
|
||||
ACTION_ERROR_BACKUP_DIALOG -> {
|
||||
context.toast(intent.getStringExtra(EXTRA_ERROR_MESSAGE))
|
||||
backupDialog.dismiss()
|
||||
}
|
||||
ACTION_ERROR_RESTORE_DIALOG -> {
|
||||
context.toast(intent.getStringExtra(EXTRA_ERROR_MESSAGE))
|
||||
restoreDialog.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
context.registerLocalReceiver(receiver, IntentFilter(INTENT_FILTER))
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
context.unregisterLocalReceiver(receiver)
|
||||
super.onPause()
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedState: Bundle?) {
|
||||
super.onViewCreated(view, savedState)
|
||||
|
||||
if (savedState != null) {
|
||||
if (BackupRestoreService.isRunning(context)) {
|
||||
restoreDialog.safeShow()
|
||||
}
|
||||
else if (BackupCreateService.isRunning(context)) {
|
||||
backupDialog.safeShow()
|
||||
}
|
||||
}
|
||||
|
||||
(activity as BaseActivity).requestPermissionsOnMarshmallow()
|
||||
|
||||
// Set onClickListeners
|
||||
createBackup.setOnPreferenceClickListener {
|
||||
MaterialDialog.Builder(context)
|
||||
.title(R.string.pref_create_backup)
|
||||
.content(R.string.backup_choice)
|
||||
.items(R.array.backup_options)
|
||||
.itemsCallbackMultiChoice(arrayOf(0, 1, 2, 3, 4 /*todo not hard code*/)) { _, positions, _ ->
|
||||
// TODO not very happy with global value, but putExtra doesn't work
|
||||
backup_flags = 0
|
||||
for (i in 1..positions.size - 1) {
|
||||
when (positions[i]) {
|
||||
1 -> backup_flags = backup_flags or BackupCreateService.BACKUP_CATEGORY
|
||||
2 -> backup_flags = backup_flags or BackupCreateService.BACKUP_CHAPTER
|
||||
3 -> backup_flags = backup_flags or BackupCreateService.BACKUP_TRACK
|
||||
4 -> backup_flags = backup_flags or BackupCreateService.BACKUP_HISTORY
|
||||
}
|
||||
}
|
||||
// If API lower as KitKat use custom dir picker
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) {
|
||||
// Get dirs
|
||||
val currentDir = preferences.backupsDirectory().getOrDefault()
|
||||
|
||||
val i = Intent(activity, CustomLayoutPickerActivity::class.java)
|
||||
i.putExtra(FilePickerActivity.EXTRA_ALLOW_MULTIPLE, false)
|
||||
i.putExtra(FilePickerActivity.EXTRA_ALLOW_CREATE_DIR, true)
|
||||
i.putExtra(FilePickerActivity.EXTRA_MODE, FilePickerActivity.MODE_DIR)
|
||||
i.putExtra(FilePickerActivity.EXTRA_START_PATH, currentDir)
|
||||
startActivityForResult(i, BACKUP_CREATE)
|
||||
} else {
|
||||
// Use Androids build in file creator
|
||||
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT)
|
||||
intent.addCategory(Intent.CATEGORY_OPENABLE)
|
||||
|
||||
// TODO create custom MIME data type? Will make older backups deprecated
|
||||
intent.type = "application/*"
|
||||
intent.putExtra(Intent.EXTRA_TITLE, Backup.getDefaultFilename())
|
||||
startActivityForResult(intent, BACKUP_CREATE)
|
||||
}
|
||||
true
|
||||
}
|
||||
.itemsDisabledIndices(0)
|
||||
.positiveText(getString(R.string.action_create))
|
||||
.negativeText(android.R.string.cancel)
|
||||
.safeShow()
|
||||
true
|
||||
}
|
||||
|
||||
restoreBackup.setOnPreferenceClickListener {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) {
|
||||
val intent = Intent()
|
||||
intent.type = "application/*"
|
||||
intent.action = Intent.ACTION_GET_CONTENT
|
||||
startActivityForResult(Intent.createChooser(intent, getString(R.string.file_select_backup)), BACKUP_RESTORE)
|
||||
} else {
|
||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
|
||||
intent.addCategory(Intent.CATEGORY_OPENABLE)
|
||||
intent.type = "application/*"
|
||||
startActivityForResult(intent, BACKUP_RESTORE)
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
automaticBackup.setOnPreferenceChangeListener { _, newValue ->
|
||||
// Always cancel the previous task, it seems that sometimes they are not updated.
|
||||
BackupCreatorJob.cancelTask()
|
||||
|
||||
val interval = (newValue as String).toInt()
|
||||
if (interval > 0) {
|
||||
BackupCreatorJob.setupTask(interval)
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
backupSlots.setOnPreferenceChangeListener { preference, newValue ->
|
||||
preferences.numberOfBackups().set((newValue as String).toInt())
|
||||
preference.summary = newValue
|
||||
true
|
||||
}
|
||||
|
||||
backupDirPref.setOnPreferenceClickListener {
|
||||
val currentDir = preferences.backupsDirectory().getOrDefault()
|
||||
|
||||
if (Build.VERSION.SDK_INT < 21) {
|
||||
// Custom dir selected, open directory selector
|
||||
val i = Intent(activity, CustomLayoutPickerActivity::class.java)
|
||||
i.putExtra(FilePickerActivity.EXTRA_ALLOW_MULTIPLE, false)
|
||||
i.putExtra(FilePickerActivity.EXTRA_ALLOW_CREATE_DIR, true)
|
||||
i.putExtra(FilePickerActivity.EXTRA_MODE, FilePickerActivity.MODE_DIR)
|
||||
i.putExtra(FilePickerActivity.EXTRA_START_PATH, currentDir)
|
||||
|
||||
startActivityForResult(i, BACKUP_DIR)
|
||||
} else {
|
||||
val i = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
|
||||
startActivityForResult(i, BACKUP_DIR)
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
subscriptions += preferences.backupsDirectory().asObservable()
|
||||
.subscribe { path ->
|
||||
backupDir = UniFile.fromUri(context, Uri.parse(path))
|
||||
backupDirPref.summary = backupDir.filePath ?: path
|
||||
}
|
||||
|
||||
subscriptions += preferences.backupInterval().asObservable()
|
||||
.subscribe {
|
||||
backupDirPref.isVisible = it > 0
|
||||
backupSlots.isVisible = it > 0
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
when (requestCode) {
|
||||
BACKUP_DIR -> if (data != null && resultCode == Activity.RESULT_OK) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
|
||||
val uri = Uri.fromFile(File(data.data.path))
|
||||
preferences.backupsDirectory().set(uri.toString())
|
||||
} else {
|
||||
val uri = data.data
|
||||
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or
|
||||
Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
|
||||
context.contentResolver.takePersistableUriPermission(uri, flags)
|
||||
|
||||
val file = UniFile.fromUri(context, uri)
|
||||
preferences.backupsDirectory().set(file.uri.toString())
|
||||
}
|
||||
}
|
||||
BACKUP_CREATE -> if (data != null && resultCode == Activity.RESULT_OK) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) {
|
||||
val dir = data.data.path
|
||||
val file = File(dir, Backup.getDefaultFilename())
|
||||
|
||||
backupDialog.safeShow()
|
||||
BackupCreateService.makeBackup(context, file.toURI().toString(), backup_flags)
|
||||
} else {
|
||||
val uri = data.data
|
||||
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or
|
||||
Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
|
||||
context.contentResolver.takePersistableUriPermission(uri, flags)
|
||||
val file = UniFile.fromUri(context, uri)
|
||||
|
||||
backupDialog.safeShow()
|
||||
BackupCreateService.makeBackup(context, file.uri.toString(), backup_flags)
|
||||
}
|
||||
}
|
||||
BACKUP_RESTORE -> if (data != null && resultCode == Activity.RESULT_OK) {
|
||||
val uri = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) {
|
||||
Uri.fromFile(File(data.data.path))
|
||||
} else {
|
||||
val uri = data.data
|
||||
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or
|
||||
Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
|
||||
context.contentResolver.takePersistableUriPermission(uri, flags)
|
||||
UniFile.fromUri(context, uri).uri
|
||||
}
|
||||
|
||||
MaterialDialog.Builder(context)
|
||||
.title(getString(R.string.pref_restore_backup))
|
||||
.content(getString(R.string.backup_restore_content))
|
||||
.positiveText(getString(R.string.action_restore))
|
||||
.onPositive { _, _ ->
|
||||
restoreDialog.safeShow()
|
||||
BackupRestoreService.start(context, uri.toString())
|
||||
}
|
||||
.safeShow()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun MaterialDialog.Builder.safeShow(): Dialog {
|
||||
return build().safeShow()
|
||||
}
|
||||
|
||||
fun Dialog.safeShow(): Dialog {
|
||||
subscriptions += Subscriptions.create { dismiss() }
|
||||
show()
|
||||
return this
|
||||
}
|
||||
|
||||
}
|
@ -9,21 +9,16 @@ import android.os.Environment
|
||||
import android.support.v4.content.ContextCompat
|
||||
import android.support.v7.preference.Preference
|
||||
import android.support.v7.preference.XpPreferenceFragment
|
||||
import android.support.v7.widget.RecyclerView
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import com.afollestad.materialdialogs.MaterialDialog
|
||||
import com.hippo.unifile.UniFile
|
||||
import com.nononsenseapps.filepicker.AbstractFilePickerFragment
|
||||
import com.nononsenseapps.filepicker.FilePickerActivity
|
||||
import com.nononsenseapps.filepicker.FilePickerFragment
|
||||
import com.nononsenseapps.filepicker.LogicHandler
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||
import eu.kanade.tachiyomi.util.inflate
|
||||
import eu.kanade.tachiyomi.util.plusAssign
|
||||
import eu.kanade.tachiyomi.widget.CustomLayoutPickerActivity
|
||||
import net.xpece.android.support.preference.MultiSelectListPreference
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.io.File
|
||||
@ -151,27 +146,4 @@ class SettingsDownloadsFragment : SettingsFragment() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class CustomLayoutPickerActivity : FilePickerActivity() {
|
||||
|
||||
override fun getFragment(startPath: String?, mode: Int, allowMultiple: Boolean, allowCreateDir: Boolean):
|
||||
AbstractFilePickerFragment<File> {
|
||||
val fragment = CustomLayoutFilePickerFragment()
|
||||
fragment.setArgs(startPath, mode, allowMultiple, allowCreateDir)
|
||||
return fragment
|
||||
}
|
||||
}
|
||||
|
||||
class CustomLayoutFilePickerFragment : FilePickerFragment() {
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||
when (viewType) {
|
||||
LogicHandler.VIEWTYPE_DIR -> {
|
||||
val view = parent.inflate(R.layout.listitem_dir)
|
||||
return DirViewHolder(view)
|
||||
}
|
||||
else -> return super.onCreateViewHolder(parent, viewType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -29,6 +29,7 @@ open class SettingsFragment : XpPreferenceFragment() {
|
||||
addPreferencesFromResource(R.xml.pref_downloads)
|
||||
addPreferencesFromResource(R.xml.pref_sources)
|
||||
addPreferencesFromResource(R.xml.pref_tracking)
|
||||
addPreferencesFromResource(R.xml.pref_backup)
|
||||
addPreferencesFromResource(R.xml.pref_advanced)
|
||||
addPreferencesFromResource(R.xml.pref_about)
|
||||
|
||||
|
@ -7,6 +7,7 @@ import android.support.v7.preference.XpPreferenceFragment
|
||||
import android.view.View
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
||||
import eu.kanade.tachiyomi.data.database.models.Category
|
||||
import eu.kanade.tachiyomi.data.library.LibraryUpdateJob
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.util.LocaleHelper
|
||||
@ -46,6 +47,8 @@ class SettingsGeneralFragment : SettingsFragment(),
|
||||
|
||||
val categoryUpdate: MultiSelectListPreference by bindPref(R.string.pref_library_update_categories_key)
|
||||
|
||||
val defaultCategory: IntListPreference by bindPref(R.string.default_category_key)
|
||||
|
||||
val langPreference: ListPreference by bindPref(R.string.pref_language_key)
|
||||
|
||||
override fun onViewCreated(view: View, savedState: Bundle?) {
|
||||
@ -100,6 +103,22 @@ class SettingsGeneralFragment : SettingsFragment(),
|
||||
categoryUpdate.summary = summary
|
||||
}
|
||||
|
||||
defaultCategory.apply {
|
||||
val selectedCategory = dbCategories.find { it.id == preferences.defaultCategory()}
|
||||
value = selectedCategory?.id?.toString() ?: value
|
||||
entries += dbCategories.map { it.name }.toTypedArray()
|
||||
entryValues += dbCategories.map { it.id.toString() }.toTypedArray()
|
||||
summary = selectedCategory?.name ?: summary
|
||||
}
|
||||
|
||||
defaultCategory.setOnPreferenceChangeListener { _, newValue ->
|
||||
defaultCategory.summary = dbCategories.find {
|
||||
it.id == (newValue as String).toInt()
|
||||
}?.name ?: getString(R.string.default_category_summary)
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
themePreference.setOnPreferenceChangeListener { preference, newValue ->
|
||||
(activity as SettingsActivity).parentFlags = SettingsActivity.FLAG_THEME_CHANGED
|
||||
activity.recreate()
|
||||
|
@ -122,7 +122,7 @@ fun Context.sendLocalBroadcastSync(intent: Intent) {
|
||||
*
|
||||
* @param receiver receiver that gets registered.
|
||||
*/
|
||||
fun Context.registerLocalReceiver(receiver: BroadcastReceiver, filter: IntentFilter ){
|
||||
fun Context.registerLocalReceiver(receiver: BroadcastReceiver, filter: IntentFilter) {
|
||||
LocalBroadcastManager.getInstance(this).registerReceiver(receiver, filter)
|
||||
}
|
||||
|
||||
@ -131,7 +131,7 @@ fun Context.registerLocalReceiver(receiver: BroadcastReceiver, filter: IntentFil
|
||||
*
|
||||
* @param receiver receiver that gets unregistered.
|
||||
*/
|
||||
fun Context.unregisterLocalReceiver(receiver: BroadcastReceiver){
|
||||
fun Context.unregisterLocalReceiver(receiver: BroadcastReceiver) {
|
||||
LocalBroadcastManager.getInstance(this).unregisterReceiver(receiver)
|
||||
}
|
||||
|
||||
|
@ -16,8 +16,11 @@ import java.security.NoSuchAlgorithmException
|
||||
object DiskUtil {
|
||||
|
||||
fun isImage(name: String, openStream: (() -> InputStream)? = null): Boolean {
|
||||
val contentType = URLConnection.guessContentTypeFromName(name)
|
||||
?: openStream?.let { findImageMime(it) }
|
||||
val contentType = try {
|
||||
URLConnection.guessContentTypeFromName(name)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
} ?: openStream?.let { findImageMime(it) }
|
||||
|
||||
return contentType?.startsWith("image/") ?: false
|
||||
}
|
||||
|
@ -19,15 +19,3 @@ fun File.getUriCompat(context: Context): Uri {
|
||||
return uri
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes file if exists
|
||||
*
|
||||
* @return success of file deletion
|
||||
*/
|
||||
fun File.deleteIfExists(): Boolean {
|
||||
if (this.exists()) {
|
||||
this.delete()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
@ -0,0 +1,33 @@
|
||||
package eu.kanade.tachiyomi.widget
|
||||
|
||||
import android.support.v7.widget.RecyclerView
|
||||
import android.view.ViewGroup
|
||||
import com.nononsenseapps.filepicker.AbstractFilePickerFragment
|
||||
import com.nononsenseapps.filepicker.FilePickerActivity
|
||||
import com.nononsenseapps.filepicker.FilePickerFragment
|
||||
import com.nononsenseapps.filepicker.LogicHandler
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.util.inflate
|
||||
import java.io.File
|
||||
|
||||
class CustomLayoutPickerActivity : FilePickerActivity() {
|
||||
|
||||
override fun getFragment(startPath: String?, mode: Int, allowMultiple: Boolean, allowCreateDir: Boolean):
|
||||
AbstractFilePickerFragment<File> {
|
||||
val fragment = CustomLayoutFilePickerFragment()
|
||||
fragment.setArgs(startPath, mode, allowMultiple, allowCreateDir)
|
||||
return fragment
|
||||
}
|
||||
}
|
||||
|
||||
class CustomLayoutFilePickerFragment : FilePickerFragment() {
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||
when (viewType) {
|
||||
LogicHandler.VIEWTYPE_DIR -> {
|
||||
val view = parent.inflate(R.layout.listitem_dir)
|
||||
return DirViewHolder(view)
|
||||
}
|
||||
else -> return super.onCreateViewHolder(parent, viewType)
|
||||
}
|
||||
}
|
||||
}
|
Before Width: | Height: | Size: 7.8 KiB |
BIN
app/src/main/res/drawable-hdpi/ic_book_white_24dp.png
Normal file
After Width: | Height: | Size: 180 B |
BIN
app/src/main/res/drawable-hdpi/tachiyomi_circle.png
Normal file
After Width: | Height: | Size: 5.1 KiB |
Before Width: | Height: | Size: 4.5 KiB |
BIN
app/src/main/res/drawable-mdpi/ic_book_white_24dp.png
Normal file
After Width: | Height: | Size: 136 B |
BIN
app/src/main/res/drawable-mdpi/tachiyomi_circle.png
Normal file
After Width: | Height: | Size: 3.1 KiB |
Before Width: | Height: | Size: 12 KiB |
BIN
app/src/main/res/drawable-xhdpi/ic_book_white_24dp.png
Normal file
After Width: | Height: | Size: 197 B |
BIN
app/src/main/res/drawable-xhdpi/tachiyomi_circle.png
Normal file
After Width: | Height: | Size: 7.0 KiB |
Before Width: | Height: | Size: 22 KiB |
BIN
app/src/main/res/drawable-xxhdpi/ic_book_white_24dp.png
Normal file
After Width: | Height: | Size: 283 B |
BIN
app/src/main/res/drawable-xxhdpi/tachiyomi_circle.png
Normal file
After Width: | Height: | Size: 11 KiB |
Before Width: | Height: | Size: 25 KiB |
BIN
app/src/main/res/drawable-xxxhdpi/ic_book_white_24dp.png
Normal file
After Width: | Height: | Size: 350 B |
BIN
app/src/main/res/drawable-xxxhdpi/tachiyomi_circle.png
Normal file
After Width: | Height: | Size: 16 KiB |
@ -1,10 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:opacity="opaque">
|
||||
<item android:drawable="@color/background_material_light"/>
|
||||
<item android:drawable="@color/material_grey_50"/>
|
||||
<item>
|
||||
<bitmap
|
||||
android:src="@drawable/application_logo_144dp"
|
||||
android:src="@drawable/branded_logo_icon"
|
||||
android:gravity="center"/>
|
||||
</item>
|
||||
</layer-list>
|
BIN
app/src/main/res/drawable/branded_logo_icon.png
Normal file
After Width: | Height: | Size: 22 KiB |
Before Width: | Height: | Size: 16 KiB |
19
app/src/main/res/drawable/sc_book_48dp.xml
Normal file
@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="48dp"
|
||||
android:height="48dp"
|
||||
android:viewportHeight="48"
|
||||
android:viewportWidth="48">
|
||||
|
||||
<path
|
||||
android:fillColor="@color/backgroundLight"
|
||||
android:pathData="M24,24m-22,0a22,22 0,1 1,44 0a22,22 0,1 1,-44 0" />
|
||||
|
||||
<group
|
||||
android:translateX="12"
|
||||
android:translateY="12">
|
||||
<path
|
||||
android:fillColor="@color/colorPrimary"
|
||||
android:pathData="M18,2H6c-1.1,0 -2,0.9 -2,2v16c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2V4c0,-1.1 -0.9,-2 -2,-2zM6,4h5v8l-2.5,-1.5L6,12V4z" />
|
||||
</group>
|
||||
</vector>
|
19
app/src/main/res/drawable/sc_explore_48dp.xml
Normal file
@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="48dp"
|
||||
android:height="48dp"
|
||||
android:viewportHeight="48"
|
||||
android:viewportWidth="48">
|
||||
|
||||
<path
|
||||
android:fillColor="@color/backgroundLight"
|
||||
android:pathData="M24,24m-22,0a22,22 0,1 1,44 0a22,22 0,1 1,-44 0" />
|
||||
|
||||
<group
|
||||
android:translateX="12"
|
||||
android:translateY="12">
|
||||
<path
|
||||
android:fillColor="@color/colorPrimary"
|
||||
android:pathData="M12,10.9c-0.61,0 -1.1,0.49 -1.1,1.1s0.49,1.1 1.1,1.1c0.61,0 1.1,-0.49 1.1,-1.1s-0.49,-1.1 -1.1,-1.1zM12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM14.19,14.19L6,18l3.81,-8.19L18,6l-3.81,8.19z" />
|
||||
</group>
|
||||
</vector>
|
19
app/src/main/res/drawable/sc_glasses_48dp.xml
Normal file
@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="48dp"
|
||||
android:height="48dp"
|
||||
android:viewportHeight="48"
|
||||
android:viewportWidth="48">
|
||||
|
||||
<path
|
||||
android:fillColor="@color/backgroundLight"
|
||||
android:pathData="M24,24m-22,0a22,22 0,1 1,44 0a22,22 0,1 1,-44 0" />
|
||||
|
||||
<group
|
||||
android:translateX="12"
|
||||
android:translateY="12">
|
||||
<path
|
||||
android:fillColor="@color/colorPrimary"
|
||||
android:pathData="M3,10C2.76,10 2.55,10.09 2.41,10.25C2.27,10.4 2.21,10.62 2.24,10.86L2.74,13.85C2.82,14.5 3.4,15 4,15H7C7.64,15 8.36,14.44 8.5,13.82L9.56,10.63C9.6,10.5 9.57,10.31 9.5,10.19C9.39,10.07 9.22,10 9,10H3M7,17H4C2.38,17 0.96,15.74 0.76,14.14L0.26,11.15C0.15,10.3 0.39,9.5 0.91,8.92C1.43,8.34 2.19,8 3,8H9C9.83,8 10.58,8.35 11.06,8.96C11.17,9.11 11.27,9.27 11.35,9.45C11.78,9.36 12.22,9.36 12.64,9.45C12.72,9.27 12.82,9.11 12.94,8.96C13.41,8.35 14.16,8 15,8H21C21.81,8 22.57,8.34 23.09,8.92C23.6,9.5 23.84,10.3 23.74,11.11L23.23,14.18C23.04,15.74 21.61,17 20,17H17C15.44,17 13.92,15.81 13.54,14.3L12.64,11.59C12.26,11.31 11.73,11.31 11.35,11.59L10.43,14.37C10.07,15.82 8.56,17 7,17M15,10C14.78,10 14.61,10.07 14.5,10.19C14.42,10.31 14.4,10.5 14.45,10.7L15.46,13.75C15.64,14.44 16.36,15 17,15H20C20.59,15 21.18,14.5 21.25,13.89L21.76,10.82C21.79,10.62 21.73,10.4 21.59,10.25C21.45,10.09 21.24,10 21,10H15Z" />
|
||||
</group>
|
||||
</vector>
|
19
app/src/main/res/drawable/sc_update_48dp.xml
Normal file
@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="48dp"
|
||||
android:height="48dp"
|
||||
android:viewportHeight="48"
|
||||
android:viewportWidth="48">
|
||||
|
||||
<path
|
||||
android:fillColor="@color/backgroundLight"
|
||||
android:pathData="M24,24m-22,0a22,22 0,1 1,44 0a22,22 0,1 1,-44 0" />
|
||||
|
||||
<group
|
||||
android:translateX="12"
|
||||
android:translateY="12">
|
||||
<path
|
||||
android:fillColor="@color/colorPrimary"
|
||||
android:pathData="M21,10.12h-6.78l2.74,-2.82c-2.73,-2.7 -7.15,-2.8 -9.88,-0.1 -2.73,2.71 -2.73,7.08 0,9.79 2.73,2.71 7.15,2.71 9.88,0C18.32,15.65 19,14.08 19,12.1h2c0,1.98 -0.88,4.55 -2.64,6.29 -3.51,3.48 -9.21,3.48 -12.72,0 -3.5,-3.47 -3.53,-9.11 -0.02,-12.58 3.51,-3.47 9.14,-3.47 12.65,0L21,3v7.12zM12.5,8v4.25l3.5,2.08 -0.72,1.21L11,13V8h1.5z" />
|
||||
</group>
|
||||
</vector>
|
@ -247,7 +247,6 @@
|
||||
style="@style/TextAppearance.Medium.Body2"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:maxLines="1"
|
||||
android:text="@string/description"
|
||||
android:textIsSelectable="false"/>
|
||||
|
||||
@ -256,8 +255,6 @@
|
||||
style="@style/TextAppearance.Regular.Body1.Secondary"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:textIsSelectable="false"/>
|
||||
|
||||
</LinearLayout>
|
||||
|
@ -17,6 +17,6 @@
|
||||
android:layout_marginLeft="16dp"
|
||||
android:layout_marginTop="@dimen/navigation_drawer_header_margin"
|
||||
android:scaleType="centerCrop"
|
||||
android:src="@drawable/icon"/>
|
||||
android:src="@drawable/tachiyomi_circle"/>
|
||||
|
||||
</FrameLayout>
|
@ -36,9 +36,5 @@
|
||||
android:icon="@drawable/ic_settings_black_24dp"
|
||||
android:title="@string/label_settings"
|
||||
android:checkable="false" />
|
||||
<item
|
||||
android:id="@+id/nav_drawer_backup"
|
||||
android:icon="@drawable/ic_backup_black_24dp"
|
||||
android:title="@string/label_backup" />
|
||||
</group>
|
||||
</menu>
|
||||
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 3.0 KiB |
BIN
app/src/main/res/mipmap-hdpi/ic_launcher_round.png
Normal file
After Width: | Height: | Size: 5.1 KiB |
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.9 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher_round.png
Normal file
After Width: | Height: | Size: 3.1 KiB |
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 4.1 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
Normal file
After Width: | Height: | Size: 7.0 KiB |
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 6.5 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
Normal file
After Width: | Height: | Size: 11 KiB |
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 8.9 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
After Width: | Height: | Size: 16 KiB |
@ -1,6 +1,23 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<changelog bulletedList="true">
|
||||
|
||||
<changelogversion versionName="v0.5.2" changeDate="">
|
||||
<changelogtext>New backup system. Smaller file size but requires a network connection to restore.</changelogtext>
|
||||
|
||||
<changelogtext>Fixed descriptions showing a single line.</changelogtext>
|
||||
|
||||
<changelogtext>Added Nougat shortcuts and round icon.</changelogtext>
|
||||
|
||||
<changelogtext>Added an option to add a manga to a specific category.</changelogtext>
|
||||
|
||||
<changelogtext>Improved new chapters notification.</changelogtext>
|
||||
|
||||
<changelogtext>Support Kitsu new rating system.</changelogtext>
|
||||
|
||||
<changelogtext>Last read page is now retained in webtoon reader.</changelogtext>
|
||||
|
||||
</changelogversion>
|
||||
|
||||
<changelogversion versionName="v0.5.1" changeDate="">
|
||||
<changelogtext>Added an option to auto download from selected categories.</changelogtext>
|
||||
|
||||
|
@ -331,11 +331,7 @@
|
||||
|
||||
<!-- Library update service notifications -->
|
||||
<string name="notification_update_progress">Напредък: %1$d/%2$d</string>
|
||||
<string name="notification_update_completed">Обновяването завършено.</string>
|
||||
<string name="notification_update_error">Възникна неочаквана грешка при обновяването на библиотеката.</string>
|
||||
<string name="notification_no_new_chapters">Не бяха открити нови глави.</string>
|
||||
<string name="notification_new_chapters">Открити нови глави за:</string>
|
||||
<string name="notification_manga_update_failed">Не успяха да се обновят:</string>
|
||||
<string name="notification_cover_update_failed">Грешка при обновяването на корицата.</string>
|
||||
<string name="notification_first_add_to_library">Моля, добавете мангата в библиотеката си, преди да направите това.</string>
|
||||
<string name="notification_not_connected_to_ac_title">Синхронизиране отказано</string>
|
||||
|
@ -265,11 +265,7 @@
|
||||
|
||||
<!-- Library update service notifications -->
|
||||
<string name="notification_update_progress">Progreso de actualización: %1$d/%2$d</string>
|
||||
<string name="notification_update_completed">Actualización completa</string>
|
||||
<string name="notification_update_error">Se ha producido un error inesperado al actualizar la librería</string>
|
||||
<string name="notification_no_new_chapters">No se encontraron nuevos capítulos</string>
|
||||
<string name="notification_new_chapters">Capítulos nuevos encontrados para:</string>
|
||||
<string name="notification_manga_update_failed">Fallaron al actualizar:</string>
|
||||
<string name="notification_new_chapters">Nuevos capítulos encontrados</string>
|
||||
<string name="notification_first_add_to_library">Por favor añada el manga a la librería antes de hacer esto</string>
|
||||
<string name="notification_not_connected_to_ac_title">Sincronización cancelada</string>
|
||||
<string name="notification_not_connected_to_ac_body">El dispositivo no está cargando</string>
|
||||
|
@ -313,9 +313,7 @@
|
||||
|
||||
<!-- Library update service notifications -->
|
||||
<string name="notification_update_progress">Progression mise à jour: %1$d/%2$d</string>
|
||||
<string name="notification_update_completed">Mise à jour terminée</string>
|
||||
<string name="notification_no_new_chapters">Aucun nouveau chapitre trouvé</string>
|
||||
<string name="notification_new_chapters">Des nouveaux chapitres ont été trouvés pour :</string>
|
||||
<string name="notification_new_chapters">Des nouveaux chapitres ont été trouvés</string>
|
||||
<string name="notification_cover_update_failed">La mise à jour de la couverture a échoué</string>
|
||||
<string name="notification_first_add_to_library">Veuillez ajouter le manga dans votre bibliothèque avant de faire cela</string>
|
||||
<string name="notification_not_connected_to_ac_body">Non branché</string>
|
||||
@ -368,9 +366,7 @@
|
||||
<string name="fifth_to_last">Du cinquième au dernier chapitre</string>
|
||||
<string name="login_success">Connexion réussie</string>
|
||||
<string name="manga_chapter_no_title">Pas de titre</string>
|
||||
<string name="notification_manga_update_failed">Impossible de mettre à jour le manga:</string>
|
||||
<string name="notification_not_connected_to_ac_title">Synchronisation annulée</string>
|
||||
<string name="notification_update_error">Une erreur inattendue s\'est produite lors de la mise à jour de la bibliothèque</string>
|
||||
<string name="pref_clear_chapter_cache">Effacer le cache des chapitres</string>
|
||||
<string name="pref_remove_after_marked_as_read">Supprimer quand marqué comme lu</string>
|
||||
<string name="restore_please_wait">Restauration de la sauvegarde. Veuillez patienter…</string>
|
||||
|
@ -322,11 +322,7 @@
|
||||
|
||||
<!-- Library update service notifications -->
|
||||
<string name="notification_update_progress">Progressione aggiornamento: %1$d/%2$d</string>
|
||||
<string name="notification_update_completed">Aggiornamento completato</string>
|
||||
<string name="notification_update_error">Si è verificato un errore imprevisto durante l\'aggiornamento della libreria</string>
|
||||
<string name="notification_no_new_chapters">Non sono stati trovati nuovi capitoli</string>
|
||||
<string name="notification_new_chapters">Nuovi capitoli trovati per:</string>
|
||||
<string name="notification_manga_update_failed">Impossibile aggiornare:</string>
|
||||
<string name="notification_new_chapters">Nuovi capitoli trovati</string>
|
||||
<string name="notification_cover_update_failed">Impossibile aggiornare la copertina</string>
|
||||
<string name="notification_first_add_to_library">Prima di fare questo per favore aggiungi il manga alla libreria</string>
|
||||
<string name="notification_not_connected_to_ac_title">Sincronizzazione annullata</string>
|
||||
|
@ -272,11 +272,7 @@
|
||||
|
||||
<!-- Library update service notifications -->
|
||||
<string name="notification_update_progress">Progresso da atualização: %1$d/%2$d</string>
|
||||
<string name="notification_update_completed">Atualização completa</string>
|
||||
<string name="notification_update_error">Ocorreu um erro inesperado ao atualizar a biblioteca</string>
|
||||
<string name="notification_no_new_chapters">Sem novos capítulos encontrados</string>
|
||||
<string name="notification_new_chapters">Novos capítulos encontrados para:</string>
|
||||
<string name="notification_manga_update_failed">Falha ao atualizar a manga:</string>
|
||||
<string name="notification_new_chapters">Novos capítulos encontrados</string>
|
||||
<string name="notification_first_add_to_library">Por favor adicione a manga à sua biblioteca antes de fazer isto</string>
|
||||
<string name="notification_not_connected_to_ac_title">Sincronização cancelada</string>
|
||||
<string name="notification_not_connected_to_ac_body">O dispositivo não está ligado ao carregador</string>
|
||||
|
@ -171,15 +171,11 @@
|
||||
<string name="no_valid_sources">Пожалуйста включите хотя бы один источник</string>
|
||||
<string name="notification_cover_update_failed">Не удалось обновить обложку</string>
|
||||
<string name="notification_first_add_to_library">Пожалуйста добавьте мангу в свою библиотеку, перед тем как делать это</string>
|
||||
<string name="notification_manga_update_failed">Не удалось обновить мангу:</string>
|
||||
<string name="notification_new_chapters">Новые главы найдены для:</string>
|
||||
<string name="notification_no_connection_body">Соединение не доступно</string>
|
||||
<string name="notification_no_connection_title">Синхронизация отменена</string>
|
||||
<string name="notification_no_new_chapters">Новые главы не найдены</string>
|
||||
<string name="notification_not_connected_to_ac_body">Не заряжается</string>
|
||||
<string name="notification_not_connected_to_ac_title">Синхронизация отменена</string>
|
||||
<string name="notification_update_completed">Обновление завершено</string>
|
||||
<string name="notification_update_error">Неожиданная ошибка случилась пока обновлялась библиотека</string>
|
||||
<string name="notification_update_progress">Статус обновления: %1$d/%2$d</string>
|
||||
<string name="on_hold">Заморожено</string>
|
||||
<string name="ongoing">Выпускается</string>
|
||||
|