Compare commits

..

20 Commits

Author SHA1 Message Date
len
91cb892c74 Release 0.5.2 2017-04-14 12:46:58 +02:00
len
a26f908370 Dependency updates 2017-04-09 18:25:05 +02:00
len
4d14f56fa8 Improve webtoon reader scroll up 2017-04-09 18:24:52 +02:00
d9a2255be9 Retain last read page when using the webtoon mode (#738)
* Retain last read page when using the webtoon mode, see issue #453

* #738 inorichi's request change to webtoonreader pull request

* #738 per inorichi recycler could be null at the point
scrollToLastPageRead was called, moved to below the check in the view
had been initialized.
2017-04-09 16:01:07 +02:00
len
5e3d71c6c5 Fix shortcuts 2017-04-09 15:53:47 +02:00
len
619d94bf36 Kitsu: use new rating system. Fixes #743 2017-04-09 13:56:52 +02:00
6069659e0f Small fixes (#740) 2017-04-07 21:17:21 +02:00
f6a79bde6f Add manga straight into a category from catalogues (#737)
* Add feature mention in issue #625
2017-04-07 20:39:09 +02:00
len
bb9e230b35 Fix #708 2017-04-07 20:32:22 +02:00
len
bc9417e16b Notify licensed content in mangahere 2017-04-06 20:23:03 +02:00
len
a4313d388d Fix activity leaks in backup, restore dialogs and properly handle db transactions 2017-04-06 17:26:24 +02:00
4ebb3a894d Added round icon + added shortcuts (#732)
* Added round icon + added shortcuts

* Moved values to companion
2017-04-04 17:42:39 +02:00
0642889b64 Rewrote Backup (#650)
* Rewrote Backup

* Save automatic backups with datetime

* Minor improvements

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

* Bugfix

* Fix tests

* Run restore inside a transaction, use external cache dir for log and other minor changes
2017-04-04 17:42:17 +02:00
len
3094d084d6 Library notification: handle only one update as a special case 2017-04-01 12:05:09 +02:00
len
f9fec74ffd Add short description to library update notification 2017-03-30 20:02:48 +02:00
8ef3ab0d49 Cancel library progress notification after posting the result 2017-03-29 09:17:53 +02:00
len
e9a6f8ef46 Update app icon with shadow 2017-03-26 11:42:32 +02:00
len
68724752f8 Separate some changes unrelated to backup from PR 2017-03-26 11:26:10 +02:00
len
de8fa09366 Keep new chapters notification across updates 2017-03-25 22:00:55 +01:00
len
e619870eec Fix #716 2017-03-20 20:34:26 +01:00
110 changed files with 2947 additions and 1404 deletions

View File

@ -38,8 +38,8 @@ android {
minSdkVersion 16 minSdkVersion 16
targetSdkVersion 25 targetSdkVersion 25
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
versionCode 21 versionCode 22
versionName "0.5.1" versionName "0.5.2"
buildConfigField "String", "COMMIT_COUNT", "\"${getCommitCount()}\"" buildConfigField "String", "COMMIT_COUNT", "\"${getCommitCount()}\""
buildConfigField "String", "COMMIT_SHA", "\"${getGitSha()}\"" buildConfigField "String", "COMMIT_SHA", "\"${getGitSha()}\""
@ -105,7 +105,7 @@ dependencies {
compile 'com.github.inorichi:junrar-android:634c1f5' compile 'com.github.inorichi:junrar-android:634c1f5'
// Android support library // 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:support-v4:$support_library_version"
compile "com.android.support:appcompat-v7:$support_library_version" compile "com.android.support:appcompat-v7:$support_library_version"
compile "com.android.support:cardview-v7:$support_library_version" compile "com.android.support:cardview-v7:$support_library_version"
@ -120,7 +120,7 @@ dependencies {
// ReactiveX // ReactiveX
compile 'io.reactivex:rxandroid:1.2.1' 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.jakewharton.rxrelay:rxrelay:1.2.0'
compile 'com.f2prateek.rx.preferences:rx-preferences:1.0.2' compile 'com.f2prateek.rx.preferences:rx-preferences:1.0.2'
compile 'com.github.pwittchen:reactivenetwork:0.7.0' compile 'com.github.pwittchen:reactivenetwork:0.7.0'
@ -153,7 +153,7 @@ dependencies {
compile 'org.jsoup:jsoup:1.10.2' compile 'org.jsoup:jsoup:1.10.2'
// Job scheduling // 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' compile 'com.google.android.gms:play-services-gcm:10.2.0'
// Changelog // Changelog
@ -175,7 +175,7 @@ dependencies {
compile 'com.github.bumptech.glide:glide:3.7.0' compile 'com.github.bumptech.glide:glide:3.7.0'
compile 'com.github.bumptech.glide:okhttp3-integration:1.4.0@aar' compile 'com.github.bumptech.glide:okhttp3-integration:1.4.0@aar'
// Transformations // Transformations
compile 'jp.wasabeef:glide-transformations:2.0.1' compile 'jp.wasabeef:glide-transformations:2.0.2'
// Logging // Logging
compile 'com.jakewharton.timber:timber:4.5.1' 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.github.inorichi:FlexibleAdapter:93985fe' // v4.2.0 to be removed
compile 'com.nononsenseapps:filepicker:2.5.2' compile 'com.nononsenseapps:filepicker:2.5.2'
compile 'com.github.amulyakhare:TextDrawable:558677e' 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 'net.xpece.android:support-preference:1.2.5'
compile 'me.zhanghai.android.systemuihelper:library:1.0.0' compile 'me.zhanghai.android.systemuihelper:library:1.0.0'
compile 'de.hdodenhof:circleimageview:2.1.0' compile 'de.hdodenhof:circleimageview:2.1.0'
@ -224,3 +224,50 @@ buildscript {
repositories { repositories {
mavenCentral() 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)
}
}
}
}

View File

@ -19,6 +19,7 @@
android:allowBackup="true" android:allowBackup="true"
android:hardwareAccelerated="true" android:hardwareAccelerated="true"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:label="@string/app_name" android:label="@string/app_name"
android:largeHeap="true" android:largeHeap="true"
android:theme="@style/Theme.Tachiyomi"> android:theme="@style/Theme.Tachiyomi">
@ -28,6 +29,8 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<meta-data android:name="android.app.shortcuts"
android:resource="@xml/shortcuts"/>
</activity> </activity>
<activity <activity
android:name=".ui.manga.MangaActivity" android:name=".ui.manga.MangaActivity"
@ -45,7 +48,7 @@
android:label="@string/label_categories" android:label="@string/label_categories"
android:parentActivityName=".ui.main.MainActivity" /> android:parentActivityName=".ui.main.MainActivity" />
<activity <activity
android:name=".ui.setting.SettingsDownloadsFragment$CustomLayoutPickerActivity" android:name=".widget.CustomLayoutPickerActivity"
android:label="@string/app_name" android:label="@string/app_name"
android:theme="@style/FilePickerTheme" /> android:theme="@style/FilePickerTheme" />
<activity <activity
@ -102,6 +105,14 @@
android:name=".data.updater.UpdateDownloaderService" android:name=".data.updater.UpdateDownloaderService"
android:exported="false" /> android:exported="false" />
<service
android:name=".data.backup.BackupCreateService"
android:exported="false"/>
<service
android:name=".data.backup.BackupRestoreService"
android:exported="false"/>
<meta-data <meta-data
android:name="eu.kanade.tachiyomi.data.glide.AppGlideModule" android:name="eu.kanade.tachiyomi.data.glide.AppGlideModule"
android:value="GlideModule" /> android:value="GlideModule" />

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 22 KiB

View File

@ -5,6 +5,7 @@ import android.content.Context
import android.content.res.Configuration import android.content.res.Configuration
import android.support.multidex.MultiDex import android.support.multidex.MultiDex
import com.evernote.android.job.JobManager 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.library.LibraryUpdateJob
import eu.kanade.tachiyomi.data.updater.UpdateCheckerJob import eu.kanade.tachiyomi.data.updater.UpdateCheckerJob
import eu.kanade.tachiyomi.util.LocaleHelper import eu.kanade.tachiyomi.util.LocaleHelper
@ -58,6 +59,7 @@ open class App : Application() {
when (tag) { when (tag) {
LibraryUpdateJob.TAG -> LibraryUpdateJob() LibraryUpdateJob.TAG -> LibraryUpdateJob()
UpdateCheckerJob.TAG -> UpdateCheckerJob() UpdateCheckerJob.TAG -> UpdateCheckerJob()
BackupCreatorJob.TAG -> BackupCreatorJob()
else -> null else -> null
} }
} }

View File

@ -1,10 +1,10 @@
package eu.kanade.tachiyomi package eu.kanade.tachiyomi
object Constants { object Constants {
const val NOTIFICATION_LIBRARY_ID = 1 const val NOTIFICATION_LIBRARY_PROGRESS_ID = 1
const val NOTIFICATION_UPDATER_ID = 2 const val NOTIFICATION_LIBRARY_RESULT_ID = 2
const val NOTIFICATION_DOWNLOAD_CHAPTER_ID = 3 const val NOTIFICATION_UPDATER_ID = 3
const val NOTIFICATION_DOWNLOAD_CHAPTER_ERROR_ID = 4 const val NOTIFICATION_DOWNLOAD_CHAPTER_ID = 4
const val NOTIFICATION_DOWNLOAD_IMAGE_ID = 5 const val NOTIFICATION_DOWNLOAD_CHAPTER_ERROR_ID = 5
const val NOTIFICATION_DOWNLOAD_IMAGE_ID = 6
} }

View File

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

View File

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

View File

@ -1,203 +1,213 @@
package eu.kanade.tachiyomi.data.backup 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.*
import com.google.gson.stream.JsonReader import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_CATEGORY
import eu.kanade.tachiyomi.data.backup.serializer.BooleanSerializer import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_CATEGORY_MASK
import eu.kanade.tachiyomi.data.backup.serializer.IdExclusion import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_CHAPTER
import eu.kanade.tachiyomi.data.backup.serializer.IntegerSerializer import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_CHAPTER_MASK
import eu.kanade.tachiyomi.data.backup.serializer.LongSerializer 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.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.* 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.* import java.util.*
/** class BackupManager(val context: Context, version: Int = CURRENT_VERSION) {
* 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()
/** /**
* Backups the data of the application to a file. * Database.
*
* @param file the file where the backup will be saved.
* @throws IOException if there's any IO error.
*/ */
@Throws(IOException::class) internal val databaseHelper: DatabaseHelper by injectLazy()
fun backupToFile(file: File) {
val root = backupToJson()
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 { internal fun backupCategories(root: JsonArray) {
val root = JsonObject() val categories = databaseHelper.getCategories().executeAsBlocking()
categories.forEach { root.add(parser.toJsonTree(it)) }
// 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
} }
/** /**
* 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. * @param manga manga that gets converted
* @return a JSON object containing all the data of the manga. * @return [JsonElement] containing manga information
*/ */
private fun backupManga(manga: Manga): JsonObject { internal fun backupMangaObject(manga: Manga, options: Int): JsonElement {
// Entry for this manga // Entry for this manga
val entry = JsonObject() val entry = JsonObject()
// Backup manga fields // Backup manga fields
entry.add(MANGA, gson.toJsonTree(manga)) entry[MANGA] = parser.toJsonTree(manga)
// Backup all the chapters // Check if user wants chapter information in backup
val chapters = db.getChapters(manga).executeAsBlocking() if (options and BACKUP_CHAPTER_MASK == BACKUP_CHAPTER) {
if (!chapters.isEmpty()) { // Backup all the chapters
entry.add(CHAPTERS, gson.toJsonTree(chapters)) val chapters = databaseHelper.getChapters(manga).executeAsBlocking()
} if (!chapters.isEmpty()) {
val chaptersJson = parser.toJsonTree(chapters)
// Backup tracks if (chaptersJson.asJsonArray.size() > 0) {
val tracks = db.getTracks(manga).executeAsBlocking() entry[CHAPTERS] = chaptersJson
if (!tracks.isEmpty()) { }
entry.add(TRACK, gson.toJsonTree(tracks)) }
} }
// Backup categories for this manga // Check if user wants category information in backup
val categoriesForManga = db.getCategoriesForManga(manga).executeAsBlocking() if (options and BACKUP_CATEGORY_MASK == BACKUP_CATEGORY) {
if (!categoriesForManga.isEmpty()) { // Backup categories for this manga
val categoriesNames = ArrayList<String>() val categoriesForManga = databaseHelper.getCategoriesForManga(manga).executeAsBlocking()
for (category in categoriesForManga) { if (!categoriesForManga.isEmpty()) {
categoriesNames.add(category.name) 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 return entry
} }
/** fun restoreMangaNoFetch(manga: Manga, dbManga: Manga) {
* Backups a category. manga.id = dbManga.id
* manga.copyFrom(dbManga)
* @param category the category to backup. manga.favorite = true
* @return a JSON object containing the data of the category. insertManga(manga)
*/
private fun backupCategory(category: Category): JsonElement {
return gson.toJsonTree(category)
} }
/** /**
* Restores a backup from a file. * [Observable] that fetches manga information
* *
* @param file the file containing the backup. * @param source source of manga
* @throws IOException if there's any IO error. * @param manga manga that needs updating
* @return [Observable] that contains manga
*/ */
@Throws(IOException::class) fun restoreMangaFetchObservable(source: Source, manga: Manga): Observable<Manga> {
fun restoreFromFile(file: File) { return source.fetchMangaDetails(manga)
JsonReader(FileReader(file)).use { .map { networkManga ->
val root = JsonParser().parse(it).asJsonObject manga.copyFrom(networkManga)
restoreFromJson(root) 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. * @param source source of manga
* @throws IOException if there's any IO error. * @param manga manga that needs updating
* @return [Observable] that contains manga
*/ */
@Throws(IOException::class) fun restoreChapterFetchObservable(source: Source, manga: Manga, chapters: List<Chapter>): Observable<Pair<List<Chapter>, List<Chapter>>> {
fun restoreFromStream(stream: InputStream) { return source.fetchChapterList(manga)
JsonReader(InputStreamReader(stream)).use { .map { syncChaptersWithSource(databaseHelper, it, manga, source) }
val root = JsonParser().parse(it).asJsonObject .doOnNext {
restoreFromJson(root) 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 * Restore the categories from Json
* nothing is modified if there's an error.
* *
* @param root the root of the JSON. * @param jsonCategories array containing categories
*/ */
fun restoreFromJson(root: JsonObject) { internal fun restoreCategories(jsonCategories: JsonArray) {
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) {
// Get categories from file and from db // Get categories from file and from db
val dbCategories = db.getCategories().executeAsBlocking() val dbCategories = databaseHelper.getCategories().executeAsBlocking()
val backupCategories = gson.fromJson<List<CategoryImpl>>(jsonCategories) val backupCategories = parser.fromJson<List<CategoryImpl>>(jsonCategories)
// Iterate over them // Iterate over them
for (category in backupCategories) { backupCategories.forEach { category ->
// Used to know if the category is already in the db // Used to know if the category is already in the db
var found = false var found = false
for (dbCategory in dbCategories) { for (dbCategory in dbCategories) {
@ -214,102 +224,20 @@ class BackupManager(private val db: DatabaseHelper) {
if (!found) { if (!found) {
// Let the db assign the id // Let the db assign the id
category.id = null category.id = null
val result = db.insertCategory(category).executeAsBlocking() val result = databaseHelper.insertCategory(category).executeAsBlocking()
category.id = result.insertedId()?.toInt() 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. * Restores the categories a manga is in.
* *
* @param manga the manga whose categories have to be restored. * @param manga the manga whose categories have to be restored.
* @param categories the categories to restore. * @param categories the categories to restore.
*/ */
private fun restoreCategoriesForManga(manga: Manga, categories: List<String>) { internal fun restoreCategoriesForManga(manga: Manga, categories: List<String>) {
val dbCategories = db.getCategories().executeAsBlocking() val dbCategories = databaseHelper.getCategories().executeAsBlocking()
val mangaCategoriesToUpdate = ArrayList<MangaCategory>() val mangaCategoriesToUpdate = ArrayList<MangaCategory>()
for (backupCategoryStr in categories) { for (backupCategoryStr in categories) {
for (dbCategory in dbCategories) { for (dbCategory in dbCategories) {
@ -324,45 +252,151 @@ class BackupManager(private val db: DatabaseHelper) {
if (!mangaCategoriesToUpdate.isEmpty()) { if (!mangaCategoriesToUpdate.isEmpty()) {
val mangaAsList = ArrayList<Manga>() val mangaAsList = ArrayList<Manga>()
mangaAsList.add(manga) mangaAsList.add(manga)
db.deleteOldMangasCategories(mangaAsList).executeAsBlocking() databaseHelper.deleteOldMangasCategories(mangaAsList).executeAsBlocking()
db.insertMangasCategories(mangaCategoriesToUpdate).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. * Restores the sync of a manga.
* *
* @param manga the manga whose sync have to be restored. * @param manga the manga whose sync have to be restored.
* @param tracks the track list to restore. * @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 // Fix foreign keys with the current manga id
for (track in tracks) { tracks.map { it.manga_id = manga.id!! }
track.manga_id = manga.id!!
}
val dbTracks = db.getTracks(manga).executeAsBlocking() // Get tracks from database
val dbTracks = databaseHelper.getTracks(manga).executeAsBlocking()
val trackToUpdate = ArrayList<Track>() val trackToUpdate = ArrayList<Track>()
for (backupTrack in tracks) {
// Try to find existing chapter in db for (track in tracks) {
val pos = dbTracks.indexOf(backupTrack) var isInDatabase = false
if (pos != -1) { for (dbTrack in dbTracks) {
// The sync is already in the db, only update its fields if (track.sync_id == dbTrack.sync_id) {
val dbSync = dbTracks[pos] // The sync is already in the db, only update its fields
// Mark the max chapter as read and nothing else if (track.remote_id != dbTrack.remote_id) {
dbSync.last_chapter_read = Math.max(backupTrack.last_chapter_read, dbSync.last_chapter_read) dbTrack.remote_id = track.remote_id
trackToUpdate.add(dbSync) }
} else { 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 // Insert new sync. Let the db assign the id
backupTrack.id = null track.id = null
trackToUpdate.add(backupTrack) trackToUpdate.add(track)
} }
} }
// Update database // Update database
if (!trackToUpdate.isEmpty()) { 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()
}
} }

View File

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

View File

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

View File

@ -0,0 +1,3 @@
package eu.kanade.tachiyomi.data.backup.models
data class DHistory(val url: String,val lastRead: Long)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -24,4 +24,6 @@ open class DatabaseHelper(context: Context)
inline fun inTransaction(block: () -> Unit) = db.inTransaction(block) inline fun inTransaction(block: () -> Unit) = db.inTransaction(block)
fun lowLevel() = db.lowLevel()
} }

View File

@ -10,6 +10,7 @@ import com.pushtorefresh.storio.sqlite.queries.DeleteQuery
import com.pushtorefresh.storio.sqlite.queries.InsertQuery import com.pushtorefresh.storio.sqlite.queries.InsertQuery
import com.pushtorefresh.storio.sqlite.queries.UpdateQuery import com.pushtorefresh.storio.sqlite.queries.UpdateQuery
import eu.kanade.tachiyomi.data.database.models.History 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_CHAPTER_ID
import eu.kanade.tachiyomi.data.database.tables.HistoryTable.COL_ID import eu.kanade.tachiyomi.data.database.tables.HistoryTable.COL_ID
import eu.kanade.tachiyomi.data.database.tables.HistoryTable.COL_LAST_READ import eu.kanade.tachiyomi.data.database.tables.HistoryTable.COL_LAST_READ
@ -44,7 +45,7 @@ open class HistoryPutResolver : DefaultPutResolver<History>() {
class HistoryGetResolver : DefaultGetResolver<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)) id = cursor.getLong(cursor.getColumnIndex(COL_ID))
chapter_id = cursor.getLong(cursor.getColumnIndex(COL_CHAPTER_ID)) chapter_id = cursor.getLong(cursor.getColumnIndex(COL_CHAPTER_ID))
last_read = cursor.getLong(cursor.getColumnIndex(COL_LAST_READ)) last_read = cursor.getLong(cursor.getColumnIndex(COL_LAST_READ))

View File

@ -5,27 +5,27 @@ import java.io.Serializable
/** /**
* Object containing the history statistics of a chapter * Object containing the history statistics of a chapter
*/ */
class History : Serializable { interface History : Serializable {
/** /**
* Id of history object. * Id of history object.
*/ */
var id: Long? = null var id: Long?
/** /**
* Chapter id of history object. * Chapter id of history object.
*/ */
var chapter_id: Long = 0 var chapter_id: Long
/** /**
* Last time chapter was read in time long format * 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 * Total time chapter was read - todo not yet implemented
*/ */
var time_read: Long = 0 var time_read: Long
companion object { companion object {
@ -35,10 +35,8 @@ class History : Serializable {
* @param chapter chapter object * @param chapter chapter object
* @return history object * @return history object
*/ */
fun create(chapter: Chapter): History { fun create(chapter: Chapter): History = HistoryImpl().apply {
val history = History() this.chapter_id = chapter.id!!
history.chapter_id = chapter.id!!
return history
} }
} }
} }

View File

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

View File

@ -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.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.models.MangaChapter 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.ChapterProgressPutResolver
import eu.kanade.tachiyomi.data.database.resolvers.ChapterSourceOrderPutResolver import eu.kanade.tachiyomi.data.database.resolvers.ChapterSourceOrderPutResolver
import eu.kanade.tachiyomi.data.database.resolvers.MangaChapterGetResolver import eu.kanade.tachiyomi.data.database.resolvers.MangaChapterGetResolver
@ -42,6 +43,16 @@ interface ChapterQueries : DbProvider {
.build()) .build())
.prepare() .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 insertChapter(chapter: Chapter) = db.put().`object`(chapter).prepare()
fun insertChapters(chapters: List<Chapter>) = db.put().objects(chapters).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 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() fun updateChapterProgress(chapter: Chapter) = db.put()
.`object`(chapter) .`object`(chapter)
.withPutResolver(ChapterProgressPutResolver()) .withPutResolver(ChapterProgressPutResolver())

View File

@ -1,5 +1,6 @@
package eu.kanade.tachiyomi.data.database.queries package eu.kanade.tachiyomi.data.database.queries
import com.pushtorefresh.storio.sqlite.queries.DeleteQuery
import com.pushtorefresh.storio.sqlite.queries.RawQuery import com.pushtorefresh.storio.sqlite.queries.RawQuery
import eu.kanade.tachiyomi.data.database.DbProvider import eu.kanade.tachiyomi.data.database.DbProvider
import eu.kanade.tachiyomi.data.database.models.History import eu.kanade.tachiyomi.data.database.models.History
@ -40,6 +41,15 @@ interface HistoryQueries : DbProvider {
.build()) .build())
.prepare() .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. * Updates the history last read.
* Inserts history object if not yet in database * Inserts history object if not yet in database
@ -59,4 +69,18 @@ interface HistoryQueries : DbProvider {
.objects(historyList) .objects(historyList)
.withPutResolver(HistoryLastReadPutResolver()) .withPutResolver(HistoryLastReadPutResolver())
.prepare() .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()
} }

View File

@ -84,6 +84,12 @@ interface MangaQueries : DbProvider {
.build()) .build())
.prepare() .prepare()
fun deleteMangas() = db.delete()
.byQuery(DeleteQuery.builder()
.table(MangaTable.TABLE)
.build())
.prepare()
fun getLastReadManga() = db.get() fun getLastReadManga() = db.get()
.listOfObjects(Manga::class.java) .listOfObjects(Manga::class.java)
.withQuery(RawQuery.builder() .withQuery(RawQuery.builder()

View File

@ -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} 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() = """ fun getLastReadMangaQuery() = """
SELECT ${Manga.TABLE}.*, MAX(${History.TABLE}.${History.COL_LAST_READ}) AS max SELECT ${Manga.TABLE}.*, MAX(${History.TABLE}.${History.COL_LAST_READ}) AS max
FROM ${Manga.TABLE} FROM ${Manga.TABLE}

View File

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

View File

@ -1,10 +1,12 @@
package eu.kanade.tachiyomi.data.library package eu.kanade.tachiyomi.data.library
import android.app.Notification
import android.app.PendingIntent import android.app.PendingIntent
import android.app.Service import android.app.Service
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.os.Build
import android.os.IBinder import android.os.IBinder
import android.os.PowerManager import android.os.PowerManager
import android.support.v4.app.NotificationCompat import android.support.v4.app.NotificationCompat
@ -28,7 +30,9 @@ import eu.kanade.tachiyomi.util.*
import rx.Observable import rx.Observable
import rx.Subscription import rx.Subscription
import rx.schedulers.Schedulers 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.*
import java.util.concurrent.atomic.AtomicInteger 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 * progress of the update, and if case of an unexpected error, this service will be silently
* destroyed. * destroyed.
*/ */
class LibraryUpdateService : Service() { class LibraryUpdateService(
val db: DatabaseHelper = Injekt.get(),
/** val sourceManager: SourceManager = Injekt.get(),
* Database helper. val preferences: PreferencesHelper = Injekt.get(),
*/ val downloadManager: DownloadManager = Injekt.get()
val db: DatabaseHelper by injectLazy() ) : Service() {
/**
* Source manager.
*/
val sourceManager: SourceManager by injectLazy()
/**
* Preferences.
*/
val preferences: PreferencesHelper by injectLazy()
/**
* Download Manager
*/
val downloadManager: DownloadManager by injectLazy()
/** /**
* Wake lock that will be held until the service is destroyed. * 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 * 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 { private val notificationBitmap by lazy {
BitmapFactory.decodeResource(resources, R.mipmap.ic_launcher) 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 { companion object {
/** /**
@ -144,16 +142,20 @@ class LibraryUpdateService : Service() {
*/ */
override fun onCreate() { override fun onCreate() {
super.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 * Method called when the service is destroyed. It destroys subscriptions and releases the wake
* the alarm and release the wake lock. * lock.
*/ */
override fun onDestroy() { override fun onDestroy() {
subscription?.unsubscribe() subscription?.unsubscribe()
destroyWakeLock() if (wakeLock.isHeld) {
wakeLock.release()
}
super.onDestroy() super.onDestroy()
} }
@ -192,7 +194,7 @@ class LibraryUpdateService : Service() {
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
.subscribe({ .subscribe({
}, { }, {
showNotification(getString(R.string.notification_update_error), "") Timber.e(it)
stopSelf(startId) stopSelf(startId)
}, { }, {
stopSelf(startId) stopSelf(startId)
@ -213,7 +215,7 @@ class LibraryUpdateService : Service() {
var listToUpdate = if (categoryId != -1) var listToUpdate = if (categoryId != -1)
db.getLibraryMangas().executeAsBlocking().filter { it.category == categoryId } db.getLibraryMangas().executeAsBlocking().filter { it.category == categoryId }
else { else {
val categoriesToUpdate = preferences.libraryUpdateCategories().getOrDefault().map { it.toInt() } val categoriesToUpdate = preferences.libraryUpdateCategories().getOrDefault().map(String::toInt)
if (categoriesToUpdate.isNotEmpty()) if (categoriesToUpdate.isNotEmpty())
db.getLibraryMangas().executeAsBlocking() db.getLibraryMangas().executeAsBlocking()
.filter { it.category in categoriesToUpdate } .filter { it.category in categoriesToUpdate }
@ -255,7 +257,7 @@ class LibraryUpdateService : Service() {
// Emit each manga and update it sequentially. // Emit each manga and update it sequentially.
return Observable.from(mangaToUpdate) return Observable.from(mangaToUpdate)
// Notify manga that will update. // 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. // Update the chapters of the manga.
.concatMap { manga -> .concatMap { manga ->
updateManga(manga) updateManga(manga)
@ -267,11 +269,11 @@ class LibraryUpdateService : Service() {
// Filter out mangas without new chapters (or failed). // Filter out mangas without new chapters (or failed).
.filter { pair -> pair.first.isNotEmpty() } .filter { pair -> pair.first.isNotEmpty() }
.doOnNext { .doOnNext {
if (downloadNew) { if (downloadNew && (categoriesToDownload.isEmpty() ||
if (categoriesToDownload.isEmpty() || manga.category in categoriesToDownload) { manga.category in categoriesToDownload)) {
downloadChapters(manga, it.first)
hasDownloads = true downloadChapters(manga, it.first)
} hasDownloads = true
} }
} }
// Convert to the manga that contains new chapters. // Convert to the manga that contains new chapters.
@ -287,16 +289,18 @@ class LibraryUpdateService : Service() {
} }
// Notify result of the overall update. // Notify result of the overall update.
.doOnCompleted { .doOnCompleted {
if (newUpdates.isEmpty()) { if (newUpdates.isNotEmpty()) {
cancelNotification() showResultNotification(newUpdates)
} else { if (downloadNew && hasDownloads) {
showResultNotification(newUpdates, failedUpdates) DownloadService.start(this)
if (downloadNew) {
if (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. // Emit each manga and update it sequentially.
return Observable.from(mangaToUpdate) return Observable.from(mangaToUpdate)
// Notify manga that will update. // 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. // Update the details of the manga.
.concatMap { manga -> .concatMap { manga ->
val source = sourceManager.get(manga.source) as? HttpSource val source = sourceManager.get(manga.source) as? HttpSource
@ -352,73 +356,10 @@ class LibraryUpdateService : Service() {
.onErrorReturn { manga } .onErrorReturn { manga }
} }
.doOnCompleted { .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. * Shows the notification containing the currently updating manga and the progress.
* *
@ -426,52 +367,66 @@ class LibraryUpdateService : Service() {
* @param current the current progress. * @param current the current progress.
* @param total the total progress. * @param total the total progress.
*/ */
private fun showProgressNotification(manga: Manga, current: Int, total: Int, cancelIntent: PendingIntent) { private fun showProgressNotification(manga: Manga, current: Int, total: Int) {
notificationManager.notify(notificationId, notification { notificationManager.notify(Constants.NOTIFICATION_LIBRARY_PROGRESS_ID, progressNotification
setSmallIcon(R.drawable.ic_refresh_white_24dp_img) .setContentTitle(manga.title)
setLargeIcon(notificationBitmap) .setProgress(total, current, false)
setContentTitle(manga.title) .build())
setProgress(total, current, false)
setOngoing(true)
addAction(R.drawable.ic_clear_grey_24dp_img, getString(android.R.string.cancel), cancelIntent)
})
} }
/** /**
* Shows the notification containing the result of the update done by the service. * Shows the notification containing the result of the update done by the service.
* *
* @param updates a list of manga with new updates. * @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>) { private fun showResultNotification(updates: List<Manga>) {
val title = getString(R.string.notification_update_completed) val newUpdates = updates.map { it.title.chop(45) }.toMutableSet()
val body = getUpdatedMangasBody(updates, failed)
notificationManager.notify(notificationId, notification { // Append new chapters from a previous, existing notification
setSmallIcon(R.drawable.ic_refresh_white_24dp_img) 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) setLargeIcon(notificationBitmap)
setContentTitle(title) setContentTitle(getString(R.string.notification_new_chapters))
setStyle(NotificationCompat.BigTextStyle().bigText(body)) if (newUpdates.size > 1) {
setContentIntent(notificationIntent) 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) setAutoCancel(true)
}) })
} }
/** /**
* Cancels the notification. * Cancels the progress notification.
*/ */
private fun cancelNotification() { private fun cancelProgressNotification() {
notificationManager.cancel(notificationId) 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 private fun getNotificationIntent(): PendingIntent {
get() { val intent = Intent(this, MainActivity::class.java)
val intent = Intent(this, MainActivity::class.java) intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP return PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
return PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) }
}
} }

View File

@ -3,8 +3,6 @@ package eu.kanade.tachiyomi.data.notification
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent 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.ui.download.DownloadActivity
import eu.kanade.tachiyomi.util.getUriCompat import eu.kanade.tachiyomi.util.getUriCompat
import java.io.File import java.io.File
@ -33,7 +31,7 @@ object NotificationHandler {
*/ */
internal fun openImagePendingActivity(context: Context, file: File): PendingIntent { internal fun openImagePendingActivity(context: Context, file: File): PendingIntent {
val intent = Intent(Intent.ACTION_VIEW).apply { 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/*") setDataAndType(uri, "image/*")
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION
} }

View File

@ -14,7 +14,10 @@ import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.download.DownloadService import eu.kanade.tachiyomi.data.download.DownloadService
import eu.kanade.tachiyomi.data.library.LibraryUpdateService import eu.kanade.tachiyomi.data.library.LibraryUpdateService
import eu.kanade.tachiyomi.ui.reader.ReaderActivity 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 uy.kohesive.injekt.injectLazy
import java.io.File import java.io.File
import eu.kanade.tachiyomi.BuildConfig.APPLICATION_ID as ID 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), ACTION_DELETE_IMAGE -> deleteImage(context, intent.getStringExtra(EXTRA_FILE_LOCATION),
intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1)) intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1))
// Cancel library update and dismiss notification // 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 // Open reader activity
ACTION_OPEN_CHAPTER -> { ACTION_OPEN_CHAPTER -> {
openChapter(context, intent.getLongExtra(EXTRA_MANGA_ID, -1), openChapter(context, intent.getLongExtra(EXTRA_MANGA_ID, -1),
@ -118,7 +121,7 @@ class NotificationReceiver : BroadcastReceiver() {
// Delete file // Delete file
val file = File(path) val file = File(path)
file.deleteIfExists() file.delete()
DiskUtil.scanMedia(context, file) DiskUtil.scanMedia(context, file)
} }

View File

@ -65,12 +65,18 @@ class PreferenceKeys(context: Context) {
val enabledLanguages = context.getString(R.string.pref_source_languages) 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 downloadsDirectory = context.getString(R.string.pref_download_directory_key)
val downloadThreads = context.getString(R.string.pref_download_slots_key) val downloadThreads = context.getString(R.string.pref_download_slots_key)
val downloadOnlyOverWifi = context.getString(R.string.pref_download_only_over_wifi_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 removeAfterReadSlots = context.getString(R.string.pref_remove_after_read_slots_key)
val removeAfterMarkedAsRead = context.getString(R.string.pref_remove_after_marked_as_read_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 lang = context.getString(R.string.pref_language_key)
val defaultCategory = context.getString(R.string.default_category_key)
} }

View File

@ -26,6 +26,10 @@ class PreferencesHelper(val context: Context) {
File(Environment.getExternalStorageDirectory().absolutePath + File.separator + File(Environment.getExternalStorageDirectory().absolutePath + File.separator +
context.getString(R.string.app_name), "downloads")) 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 startScreen() = prefs.getInt(keys.startScreen, 1)
fun clear() = prefs.edit().clear().apply() fun clear() = prefs.edit().clear().apply()
@ -112,12 +116,18 @@ class PreferencesHelper(val context: Context) {
fun anilistScoreType() = rxPrefs.getInteger("anilist_score_type", 0) 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 downloadsDirectory() = rxPrefs.getString(keys.downloadsDirectory, defaultDownloadsDir.toString())
fun downloadThreads() = rxPrefs.getInteger(keys.downloadThreads, 1) fun downloadThreads() = rxPrefs.getInteger(keys.downloadThreads, 1)
fun downloadOnlyOverWifi() = prefs.getBoolean(keys.downloadOnlyOverWifi, true) 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 removeAfterReadSlots() = prefs.getInt(keys.removeAfterReadSlots, -1)
fun removeAfterMarkedAsRead() = prefs.getBoolean(keys.removeAfterMarkedAsRead, false) fun removeAfterMarkedAsRead() = prefs.getBoolean(keys.removeAfterMarkedAsRead, false)
@ -148,4 +158,6 @@ class PreferencesHelper(val context: Context) {
fun lang() = prefs.getString(keys.lang, "") fun lang() = prefs.getString(keys.lang, "")
fun defaultCategory() = prefs.getInt(keys.defaultCategory, -1)
} }

View File

@ -9,6 +9,7 @@ import eu.kanade.tachiyomi.data.track.TrackService
import rx.Completable import rx.Completable
import rx.Observable import rx.Observable
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.text.DecimalFormat
class Kitsu(private val context: Context, id: Int) : TrackService(id) { 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> { 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 { 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> { override fun add(track: Track): Observable<Track> {

View File

@ -1,6 +1,7 @@
package eu.kanade.tachiyomi.data.track.kitsu package eu.kanade.tachiyomi.data.track.kitsu
import com.github.salomonbrys.kotson.* import com.github.salomonbrys.kotson.*
import com.google.gson.GsonBuilder
import com.google.gson.JsonObject import com.google.gson.JsonObject
import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.network.POST import eu.kanade.tachiyomi.network.POST
@ -17,7 +18,7 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
private val rest = Retrofit.Builder() private val rest = Retrofit.Builder()
.baseUrl(baseUrl) .baseUrl(baseUrl)
.client(client.newBuilder().addInterceptor(interceptor).build()) .client(client.newBuilder().addInterceptor(interceptor).build())
.addConverterFactory(GsonConverterFactory.create()) .addConverterFactory(GsonConverterFactory.create(GsonBuilder().serializeNulls().create()))
.addCallAdapterFactory(RxJavaCallAdapterFactory.create()) .addCallAdapterFactory(RxJavaCallAdapterFactory.create())
.build() .build()
.create(KitsuApi.Rest::class.java) .create(KitsuApi.Rest::class.java)
@ -65,7 +66,7 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
"attributes" to jsonObject( "attributes" to jsonObject(
"status" to track.toKitsuStatus(), "status" to track.toKitsuStatus(),
"progress" to track.last_chapter_read, "progress" to track.last_chapter_read,
"rating" to track.toKitsuScore() "ratingTwenty" to track.toKitsuScore()
) )
) )
// @formatter:on // @formatter:on

View File

@ -23,13 +23,13 @@ open class KitsuManga(obj: JsonObject) {
class KitsuLibManga(obj: JsonObject, manga: JsonObject) : KitsuManga(manga) { class KitsuLibManga(obj: JsonObject, manga: JsonObject) : KitsuManga(manga) {
val remoteId by obj.byInt("id") val remoteId by obj.byInt("id")
val status by obj["attributes"].byString 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 val progress by obj["attributes"].byInt
override fun toTrack() = super.toTrack().apply { override fun toTrack() = super.toTrack().apply {
remote_id = remoteId remote_id = remoteId
status = toTrackStatus() status = toTrackStatus()
score = rating?.let { it.toFloat() * 2 } ?: 0f score = ratingTwenty?.let { it.toInt() / 2f } ?: 0f
last_chapter_read = progress last_chapter_read = progress
} }
@ -53,6 +53,6 @@ fun Track.toKitsuStatus() = when (status) {
else -> throw Exception("Unknown status") else -> throw Exception("Unknown status")
} }
fun Track.toKitsuScore(): String { fun Track.toKitsuScore(): String? {
return if (score > 0) (score / 2).toString() else "" return if (score > 0) (score * 2).toInt().toString() else null
} }

View File

@ -152,6 +152,11 @@ class Mangahere : ParsedHttpSource() {
} }
override fun pageListParse(document: Document): List<Page> { 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>() val pages = mutableListOf<Page>()
document.select("select.wid60").first()?.getElementsByTag("option")?.forEach { document.select("select.wid60").first()?.getElementsByTag("option")?.forEach {
pages.add(Page(pages.size, it.attr("value"))) pages.add(Page(pages.size, it.attr("value")))

View File

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

View File

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

View File

@ -15,7 +15,9 @@ import com.f2prateek.rx.preferences.Preference
import eu.davidea.flexibleadapter.FlexibleAdapter import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.IFlexible import eu.davidea.flexibleadapter.items.IFlexible
import eu.kanade.tachiyomi.R 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.database.models.Manga
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.source.model.FilterList import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.ui.base.fragment.BaseRxFragment import eu.kanade.tachiyomi.ui.base.fragment.BaseRxFragment
import eu.kanade.tachiyomi.ui.main.MainActivity import eu.kanade.tachiyomi.ui.main.MainActivity
@ -33,6 +35,7 @@ import nucleus.factory.RequiresPresenter
import rx.Subscription import rx.Subscription
import rx.android.schedulers.AndroidSchedulers import rx.android.schedulers.AndroidSchedulers
import rx.subjects.PublishSubject import rx.subjects.PublishSubject
import uy.kohesive.injekt.injectLazy
import java.util.concurrent.TimeUnit.MILLISECONDS import java.util.concurrent.TimeUnit.MILLISECONDS
/** /**
@ -45,6 +48,11 @@ open class CatalogueFragment : BaseRxFragment<CataloguePresenter>(),
FlexibleAdapter.OnItemLongClickListener, FlexibleAdapter.OnItemLongClickListener,
FlexibleAdapter.EndlessScrollListener<ProgressItem> { FlexibleAdapter.EndlessScrollListener<ProgressItem> {
/**
* Preferences helper.
*/
private val preferences: PreferencesHelper by injectLazy()
/** /**
* Spinner shown in the toolbar to change the selected source. * 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. * 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. * @param position the position of the element clicked.
*/ */
override fun onItemLongClick(position: Int) { override fun onItemLongClick(position: Int) {
// Get manga
val manga = (adapter.getItem(position) as? CatalogueItem?)?.manga ?: return 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 if (manga.favorite){
MaterialDialog.Builder(activity)
MaterialDialog.Builder(activity) .items(getString(R.string.remove_from_library ))
.items(getString(textRes)) .itemsCallback { _, _, which, _ ->
.itemsCallback { dialog, itemView, which, text -> when (which) {
when (which) { 0 -> {
0 -> { presenter.changeMangaFavorite(manga)
presenter.changeMangaFavorite(manga) adapter.notifyItemChanged(position)
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)
} }
} }

View File

@ -5,7 +5,9 @@ import eu.davidea.flexibleadapter.items.IFlexible
import eu.davidea.flexibleadapter.items.ISectionable import eu.davidea.flexibleadapter.items.ISectionable
import eu.kanade.tachiyomi.data.cache.CoverCache import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.data.database.DatabaseHelper 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.Manga
import eu.kanade.tachiyomi.data.database.models.MangaCategory
import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.source.CatalogueSource 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)
}
}
} }

View File

@ -441,11 +441,11 @@ class LibraryFragment : BaseRxFragment<LibraryPresenter>(), ActionMode.Callback
if (presenter.editCoverWithStream(it, manga)) { if (presenter.editCoverWithStream(it, manga)) {
// TODO refresh cover // TODO refresh cover
} else { } else {
context.toast(R.string.notification_manga_update_failed) context.toast(R.string.notification_cover_update_failed)
} }
} }
} catch (error: IOException) { } catch (error: IOException) {
context.toast(R.string.notification_manga_update_failed) context.toast(R.string.notification_cover_update_failed)
Timber.e(error) Timber.e(error)
} }
} }

View File

@ -8,7 +8,6 @@ import android.support.v4.view.GravityCompat
import android.view.MenuItem import android.view.MenuItem
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.preference.PreferencesHelper 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.base.activity.BaseActivity
import eu.kanade.tachiyomi.ui.catalogue.CatalogueFragment import eu.kanade.tachiyomi.ui.catalogue.CatalogueFragment
import eu.kanade.tachiyomi.ui.download.DownloadActivity import eu.kanade.tachiyomi.ui.download.DownloadActivity
@ -38,8 +37,8 @@ class MainActivity : BaseActivity() {
setAppTheme() setAppTheme()
super.onCreate(savedState) super.onCreate(savedState)
// Do not let the launcher create a new activity // Do not let the launcher create a new activity http://stackoverflow.com/questions/16283079
if (intent.flags and Intent.FLAG_ACTIVITY_BROUGHT_TO_FRONT != 0) { if (!isTaskRoot) {
finish() finish()
return return
} }
@ -71,7 +70,6 @@ class MainActivity : BaseActivity() {
val intent = Intent(this, SettingsActivity::class.java) val intent = Intent(this, SettingsActivity::class.java)
startActivityForResult(intent, REQUEST_OPEN_SETTINGS) startActivityForResult(intent, REQUEST_OPEN_SETTINGS)
} }
R.id.nav_drawer_backup -> setFragment(BackupFragment.newInstance(), id)
} }
} }
drawer.closeDrawer(GravityCompat.START) drawer.closeDrawer(GravityCompat.START)
@ -80,11 +78,19 @@ class MainActivity : BaseActivity() {
if (savedState == null) { if (savedState == null) {
// Set start screen // 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 // Show changelog if needed
ChangelogDialogFragment.show(this, preferences, supportFragmentManager) ChangelogDialogFragment.show(this, preferences, supportFragmentManager)
} }
} }
override fun onOptionsItemSelected(item: MenuItem): Boolean { override fun onOptionsItemSelected(item: MenuItem): Boolean {
@ -145,5 +151,10 @@ class MainActivity : BaseActivity() {
companion object { companion object {
private const val REQUEST_OPEN_SETTINGS = 200 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"
} }
} }

View File

@ -6,6 +6,7 @@ import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.support.customtabs.CustomTabsIntent import android.support.customtabs.CustomTabsIntent
import android.view.* import android.view.*
import android.widget.Toast
import com.afollestad.materialdialogs.MaterialDialog import com.afollestad.materialdialogs.MaterialDialog
import com.bumptech.glide.BitmapRequestBuilder import com.bumptech.glide.BitmapRequestBuilder
import com.bumptech.glide.BitmapTypeRequest 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 com.bumptech.glide.load.resource.bitmap.CenterCrop
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Manga 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.Source
import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.HttpSource
@ -31,6 +33,7 @@ import nucleus.factory.RequiresPresenter
import rx.Observable import rx.Observable
import rx.android.schedulers.AndroidSchedulers import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers import rx.schedulers.Schedulers
import uy.kohesive.injekt.injectLazy
/** /**
* Fragment that shows manga information. * 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?) { override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState) super.onCreate(savedState)
setHasOptionsMenu(true) setHasOptionsMenu(true)
@ -63,7 +71,19 @@ class MangaInfoFragment : BaseRxFragment<MangaInfoPresenter>() {
override fun onViewCreated(view: View?, savedState: Bundle?) { override fun onViewCreated(view: View?, savedState: Bundle?) {
// Set onclickListener to toggle favorite when FAB clicked. // 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. // Set SwipeRefresh to refresh manga data.
swipe_refresh.setOnRefreshListener { fetchMangaFromSource() } swipe_refresh.setOnRefreshListener { fetchMangaFromSource() }
@ -334,4 +354,40 @@ class MangaInfoFragment : BaseRxFragment<MangaInfoPresenter>() {
swipe_refresh.isRefreshing = value 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()
}
} }

View File

@ -3,7 +3,9 @@ package eu.kanade.tachiyomi.ui.manga.info
import android.os.Bundle import android.os.Bundle
import eu.kanade.tachiyomi.data.cache.CoverCache import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.data.database.DatabaseHelper 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.Manga
import eu.kanade.tachiyomi.data.database.models.MangaCategory
import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.source.SourceManager
@ -76,9 +78,12 @@ class MangaInfoPresenter : BasePresenter<MangaInfoFragment>() {
?.subscribeLatestCache(MangaInfoFragment::setChapterCount) ?.subscribeLatestCache(MangaInfoFragment::setChapterCount)
// Update favorite status // Update favorite status
SharedData.get(MangaFavoriteEvent::class.java)?.observable SharedData.get(MangaFavoriteEvent::class.java)?.let {
?.observeOn(AndroidSchedulers.mainThread()) it.observable
?.subscribe { setFavorite(it) } .observeOn(AndroidSchedulers.mainThread())
.subscribe { setFavorite(it) }
.apply { add(this) }
}
} }
/** /**
@ -148,4 +153,49 @@ class MangaInfoPresenter : BasePresenter<MangaInfoFragment>() {
downloadManager.findMangaDir(source, manga)?.delete() 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)
}
} }

View File

@ -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.setOnTouchListener(adapter.touchListener)
view.retry_button.setOnTouchListener { v, event -> view.retry_button.setOnTouchListener { _, event ->
if (event.action == MotionEvent.ACTION_UP) { if (event.action == MotionEvent.ACTION_UP) {
readerActivity.presenter.retryPage(page) readerActivity.presenter.retryPage(page)
} }

View File

@ -1,7 +1,9 @@
package eu.kanade.tachiyomi.ui.reader.viewer.webtoon package eu.kanade.tachiyomi.ui.reader.viewer.webtoon
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.support.v7.widget.RecyclerView import android.support.v7.widget.RecyclerView
import android.util.DisplayMetrics
import android.view.* import android.view.*
import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
@ -64,6 +66,19 @@ class WebtoonReader : BaseReader() {
private var scrollDistance: Int = 0 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? { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedState: Bundle?): View? {
adapter = WebtoonAdapter(this) adapter = WebtoonAdapter(this)
@ -72,9 +87,6 @@ class WebtoonReader : BaseReader() {
layoutManager = PreCachingLayoutManager(activity) layoutManager = PreCachingLayoutManager(activity)
layoutManager.extraLayoutSpace = screenHeight / 2 layoutManager.extraLayoutSpace = screenHeight / 2
if (savedState != null) {
layoutManager.scrollToPositionWithOffset(savedState.getInt(SAVED_POSITION), 0)
}
recycler = RecyclerView(activity).apply { recycler = RecyclerView(activity).apply {
layoutParams = ViewGroup.LayoutParams(MATCH_PARENT, WRAP_CONTENT) layoutParams = ViewGroup.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
@ -107,6 +119,26 @@ class WebtoonReader : BaseReader() {
return recycler 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() { override fun onDestroyView() {
subscriptions.unsubscribe() subscriptions.unsubscribe()
super.onDestroyView() super.onDestroyView()
@ -150,6 +182,7 @@ class WebtoonReader : BaseReader() {
// Make sure the view is already initialized. // Make sure the view is already initialized.
if (view != null) { if (view != null) {
setPagesOnAdapter() setPagesOnAdapter()
scrollToLastPageRead(this.currentPage)
} }
} }

View File

@ -65,6 +65,7 @@ class SettingsActivity : BaseActivity(),
"downloads_screen" -> SettingsDownloadsFragment.newInstance(key) "downloads_screen" -> SettingsDownloadsFragment.newInstance(key)
"sources_screen" -> SettingsSourcesFragment.newInstance(key) "sources_screen" -> SettingsSourcesFragment.newInstance(key)
"tracking_screen" -> SettingsTrackingFragment.newInstance(key) "tracking_screen" -> SettingsTrackingFragment.newInstance(key)
"backup_screen" -> SettingsBackupFragment.newInstance(key)
"advanced_screen" -> SettingsAdvancedFragment.newInstance(key) "advanced_screen" -> SettingsAdvancedFragment.newInstance(key)
"about_screen" -> SettingsAboutFragment.newInstance(key) "about_screen" -> SettingsAboutFragment.newInstance(key)
else -> SettingsFragment.newInstance(key) else -> SettingsFragment.newInstance(key)

View File

@ -108,6 +108,7 @@ class SettingsAdvancedFragment : SettingsFragment() {
.onPositive { dialog, which -> .onPositive { dialog, which ->
(activity as SettingsActivity).parentFlags = SettingsActivity.FLAG_DATABASE_CLEARED (activity as SettingsActivity).parentFlags = SettingsActivity.FLAG_DATABASE_CLEARED
db.deleteMangasNotInLibrary().executeAsBlocking() db.deleteMangasNotInLibrary().executeAsBlocking()
db.deleteHistoryNoLastRead().executeAsBlocking()
activity.toast(R.string.clear_database_completed) activity.toast(R.string.clear_database_completed)
} }
.show() .show()

View File

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

View File

@ -9,21 +9,16 @@ import android.os.Environment
import android.support.v4.content.ContextCompat import android.support.v4.content.ContextCompat
import android.support.v7.preference.Preference import android.support.v7.preference.Preference
import android.support.v7.preference.XpPreferenceFragment import android.support.v7.preference.XpPreferenceFragment
import android.support.v7.widget.RecyclerView
import android.view.View import android.view.View
import android.view.ViewGroup
import com.afollestad.materialdialogs.MaterialDialog import com.afollestad.materialdialogs.MaterialDialog
import com.hippo.unifile.UniFile import com.hippo.unifile.UniFile
import com.nononsenseapps.filepicker.AbstractFilePickerFragment
import com.nononsenseapps.filepicker.FilePickerActivity import com.nononsenseapps.filepicker.FilePickerActivity
import com.nononsenseapps.filepicker.FilePickerFragment
import com.nononsenseapps.filepicker.LogicHandler
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.util.inflate
import eu.kanade.tachiyomi.util.plusAssign import eu.kanade.tachiyomi.util.plusAssign
import eu.kanade.tachiyomi.widget.CustomLayoutPickerActivity
import net.xpece.android.support.preference.MultiSelectListPreference import net.xpece.android.support.preference.MultiSelectListPreference
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.io.File 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)
}
}
}
} }

View File

@ -29,6 +29,7 @@ open class SettingsFragment : XpPreferenceFragment() {
addPreferencesFromResource(R.xml.pref_downloads) addPreferencesFromResource(R.xml.pref_downloads)
addPreferencesFromResource(R.xml.pref_sources) addPreferencesFromResource(R.xml.pref_sources)
addPreferencesFromResource(R.xml.pref_tracking) addPreferencesFromResource(R.xml.pref_tracking)
addPreferencesFromResource(R.xml.pref_backup)
addPreferencesFromResource(R.xml.pref_advanced) addPreferencesFromResource(R.xml.pref_advanced)
addPreferencesFromResource(R.xml.pref_about) addPreferencesFromResource(R.xml.pref_about)

View File

@ -7,6 +7,7 @@ import android.support.v7.preference.XpPreferenceFragment
import android.view.View import android.view.View
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.DatabaseHelper 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.library.LibraryUpdateJob
import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.util.LocaleHelper 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 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) val langPreference: ListPreference by bindPref(R.string.pref_language_key)
override fun onViewCreated(view: View, savedState: Bundle?) { override fun onViewCreated(view: View, savedState: Bundle?) {
@ -100,6 +103,22 @@ class SettingsGeneralFragment : SettingsFragment(),
categoryUpdate.summary = summary 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 -> themePreference.setOnPreferenceChangeListener { preference, newValue ->
(activity as SettingsActivity).parentFlags = SettingsActivity.FLAG_THEME_CHANGED (activity as SettingsActivity).parentFlags = SettingsActivity.FLAG_THEME_CHANGED
activity.recreate() activity.recreate()

View File

@ -122,7 +122,7 @@ fun Context.sendLocalBroadcastSync(intent: Intent) {
* *
* @param receiver receiver that gets registered. * @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) LocalBroadcastManager.getInstance(this).registerReceiver(receiver, filter)
} }
@ -131,7 +131,7 @@ fun Context.registerLocalReceiver(receiver: BroadcastReceiver, filter: IntentFil
* *
* @param receiver receiver that gets unregistered. * @param receiver receiver that gets unregistered.
*/ */
fun Context.unregisterLocalReceiver(receiver: BroadcastReceiver){ fun Context.unregisterLocalReceiver(receiver: BroadcastReceiver) {
LocalBroadcastManager.getInstance(this).unregisterReceiver(receiver) LocalBroadcastManager.getInstance(this).unregisterReceiver(receiver)
} }

View File

@ -16,8 +16,11 @@ import java.security.NoSuchAlgorithmException
object DiskUtil { object DiskUtil {
fun isImage(name: String, openStream: (() -> InputStream)? = null): Boolean { fun isImage(name: String, openStream: (() -> InputStream)? = null): Boolean {
val contentType = URLConnection.guessContentTypeFromName(name) val contentType = try {
?: openStream?.let { findImageMime(it) } URLConnection.guessContentTypeFromName(name)
} catch (e: Exception) {
null
} ?: openStream?.let { findImageMime(it) }
return contentType?.startsWith("image/") ?: false return contentType?.startsWith("image/") ?: false
} }

View File

@ -19,15 +19,3 @@ fun File.getUriCompat(context: Context): Uri {
return 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
}

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 350 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -1,10 +1,10 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android" <layer-list xmlns:android="http://schemas.android.com/apk/res/android"
android:opacity="opaque"> android:opacity="opaque">
<item android:drawable="@color/background_material_light"/> <item android:drawable="@color/material_grey_50"/>
<item> <item>
<bitmap <bitmap
android:src="@drawable/application_logo_144dp" android:src="@drawable/branded_logo_icon"
android:gravity="center"/> android:gravity="center"/>
</item> </item>
</layer-list> </layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

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

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

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

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

View File

@ -247,7 +247,6 @@
style="@style/TextAppearance.Medium.Body2" style="@style/TextAppearance.Medium.Body2"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:maxLines="1"
android:text="@string/description" android:text="@string/description"
android:textIsSelectable="false"/> android:textIsSelectable="false"/>
@ -256,8 +255,6 @@
style="@style/TextAppearance.Regular.Body1.Secondary" style="@style/TextAppearance.Regular.Body1.Secondary"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:textIsSelectable="false"/> android:textIsSelectable="false"/>
</LinearLayout> </LinearLayout>

View File

@ -17,6 +17,6 @@
android:layout_marginLeft="16dp" android:layout_marginLeft="16dp"
android:layout_marginTop="@dimen/navigation_drawer_header_margin" android:layout_marginTop="@dimen/navigation_drawer_header_margin"
android:scaleType="centerCrop" android:scaleType="centerCrop"
android:src="@drawable/icon"/> android:src="@drawable/tachiyomi_circle"/>
</FrameLayout> </FrameLayout>

View File

@ -36,9 +36,5 @@
android:icon="@drawable/ic_settings_black_24dp" android:icon="@drawable/ic_settings_black_24dp"
android:title="@string/label_settings" android:title="@string/label_settings"
android:checkable="false" /> android:checkable="false" />
<item
android:id="@+id/nav_drawer_backup"
android:icon="@drawable/ic_backup_black_24dp"
android:title="@string/label_backup" />
</group> </group>
</menu> </menu>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -1,6 +1,23 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<changelog bulletedList="true"> <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=""> <changelogversion versionName="v0.5.1" changeDate="">
<changelogtext>Added an option to auto download from selected categories.</changelogtext> <changelogtext>Added an option to auto download from selected categories.</changelogtext>

View File

@ -331,11 +331,7 @@
<!-- Library update service notifications --> <!-- Library update service notifications -->
<string name="notification_update_progress">Напредък: %1$d/%2$d</string> <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_new_chapters">Открити нови глави за:</string>
<string name="notification_manga_update_failed">Не успяха да се обновят:</string>
<string name="notification_cover_update_failed">Грешка при обновяването на корицата.</string> <string name="notification_cover_update_failed">Грешка при обновяването на корицата.</string>
<string name="notification_first_add_to_library">Моля, добавете мангата в библиотеката си, преди да направите това.</string> <string name="notification_first_add_to_library">Моля, добавете мангата в библиотеката си, преди да направите това.</string>
<string name="notification_not_connected_to_ac_title">Синхронизиране отказано</string> <string name="notification_not_connected_to_ac_title">Синхронизиране отказано</string>

View File

@ -265,11 +265,7 @@
<!-- Library update service notifications --> <!-- Library update service notifications -->
<string name="notification_update_progress">Progreso de actualización: %1$d/%2$d</string> <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_new_chapters">Nuevos capítulos encontrados</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_first_add_to_library">Por favor añada el manga a la librería antes de hacer esto</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_title">Sincronización cancelada</string>
<string name="notification_not_connected_to_ac_body">El dispositivo no está cargando</string> <string name="notification_not_connected_to_ac_body">El dispositivo no está cargando</string>

View File

@ -313,9 +313,7 @@
<!-- Library update service notifications --> <!-- Library update service notifications -->
<string name="notification_update_progress">Progression mise à jour: %1$d/%2$d</string> <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_new_chapters">Des nouveaux chapitres ont été trouvés</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_cover_update_failed">La mise à jour de la couverture a échoué</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_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> <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="fifth_to_last">Du cinquième au dernier chapitre</string>
<string name="login_success">Connexion réussie</string> <string name="login_success">Connexion réussie</string>
<string name="manga_chapter_no_title">Pas de titre</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_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_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="pref_remove_after_marked_as_read">Supprimer quand marqué comme lu</string>
<string name="restore_please_wait">Restauration de la sauvegarde. Veuillez patienter…</string> <string name="restore_please_wait">Restauration de la sauvegarde. Veuillez patienter…</string>

View File

@ -322,11 +322,7 @@
<!-- Library update service notifications --> <!-- Library update service notifications -->
<string name="notification_update_progress">Progressione aggiornamento: %1$d/%2$d</string> <string name="notification_update_progress">Progressione aggiornamento: %1$d/%2$d</string>
<string name="notification_update_completed">Aggiornamento completato</string> <string name="notification_new_chapters">Nuovi capitoli trovati</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_cover_update_failed">Impossibile aggiornare la copertina</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_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> <string name="notification_not_connected_to_ac_title">Sincronizzazione annullata</string>

View File

@ -272,11 +272,7 @@
<!-- Library update service notifications --> <!-- Library update service notifications -->
<string name="notification_update_progress">Progresso da atualização: %1$d/%2$d</string> <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_new_chapters">Novos capítulos encontrados</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_first_add_to_library">Por favor adicione a manga à sua biblioteca antes de fazer isto</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_title">Sincronização cancelada</string>
<string name="notification_not_connected_to_ac_body">O dispositivo não está ligado ao carregador</string> <string name="notification_not_connected_to_ac_body">O dispositivo não está ligado ao carregador</string>

View File

@ -171,15 +171,11 @@
<string name="no_valid_sources">Пожалуйста включите хотя бы один источник</string> <string name="no_valid_sources">Пожалуйста включите хотя бы один источник</string>
<string name="notification_cover_update_failed">Не удалось обновить обложку</string> <string name="notification_cover_update_failed">Не удалось обновить обложку</string>
<string name="notification_first_add_to_library">Пожалуйста добавьте мангу в свою библиотеку, перед тем как делать это</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_new_chapters">Новые главы найдены для:</string>
<string name="notification_no_connection_body">Соединение не доступно</string> <string name="notification_no_connection_body">Соединение не доступно</string>
<string name="notification_no_connection_title">Синхронизация отменена</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_body">Не заряжается</string>
<string name="notification_not_connected_to_ac_title">Синхронизация отменена</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="notification_update_progress">Статус обновления: %1$d/%2$d</string>
<string name="on_hold">Заморожено</string> <string name="on_hold">Заморожено</string>
<string name="ongoing">Выпускается</string> <string name="ongoing">Выпускается</string>

Some files were not shown because too many files have changed in this diff Show More