Compare commits

...

35 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
len
4be5f0dab3 Release 0.5.1 2017-03-19 11:58:56 +01:00
len
abe1929b49 Update vietnamese strings. Document Kissmanga changes 2017-03-19 10:51:38 +01:00
68c4116327 Category-specific auto download (#701)
* Category-specific auto download
2017-03-18 13:09:40 -04:00
len
3be9881997 Kissmanga fix. Kotlin 1.1.1 2017-03-18 14:11:16 +01:00
2e44f29882 Prevent some manga breaking the download notifier (#711) 2017-03-15 22:19:06 +01:00
len
a5520c1936 Manga info with constraint layout 2017-03-12 13:00:47 +01:00
len
112cdd54e3 Update chapters adapter 2017-03-11 21:20:46 +01:00
len
b512c67b5d Fix #704. Dependency updates 2017-03-11 16:00:07 +01:00
len
d8fa7bc9d2 Post updater notification before starting downloads 2017-03-10 20:36:43 +01:00
len
41397ab41d Don't post too many notifications in the updater 2017-03-10 20:21:09 +01:00
len
c437f1473c Add dev flavor. Bugfix in reader 2017-03-08 18:56:27 +01:00
6020cd011d Fix #692. Mangasee needs proper headers for data requests. (#694) 2017-03-03 22:53:54 +01:00
len
582bb3e2ca Handle a few more possible external directories before Lollipop 2017-03-03 18:45:25 +01:00
len
5c67161dce Minor changes for Kotlin 1.1 2017-03-03 18:18:06 +01:00
len
c00eaae62b AS 2.3 and Kotlin 1.1 2017-03-03 17:42:46 +01:00
130 changed files with 3625 additions and 1958 deletions

View File

@ -38,12 +38,13 @@ android {
minSdkVersion 16
targetSdkVersion 25
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
versionCode 20
versionName "0.5.0"
versionCode 22
versionName "0.5.2"
buildConfigField "String", "COMMIT_COUNT", "\"${getCommitCount()}\""
buildConfigField "String", "COMMIT_SHA", "\"${getGitSha()}\""
buildConfigField "String", "BUILD_TIME", "\"${getBuildTime()}\""
buildConfigField "boolean", "INCLUDE_UPDATER", "false"
vectorDrawables.useSupportLibrary = true
@ -70,9 +71,11 @@ android {
standard {
buildConfigField "boolean", "INCLUDE_UPDATER", "true"
}
fdroid {
buildConfigField "boolean", "INCLUDE_UPDATER", "false"
}
dev {
minSdkVersion 21
resConfigs "en", "xxhdpi"
}
}
@ -98,11 +101,11 @@ android {
dependencies {
// Modified dependencies
compile 'com.github.inorichi:subsampling-scale-image-view:4255750'
compile 'com.github.inorichi:subsampling-scale-image-view:01e5385'
compile 'com.github.inorichi:junrar-android:634c1f5'
// Android support library
final support_library_version = '25.2.0'
final support_library_version = '25.3.1'
compile "com.android.support:support-v4:$support_library_version"
compile "com.android.support:appcompat-v7:$support_library_version"
compile "com.android.support:cardview-v7:$support_library_version"
@ -111,13 +114,13 @@ dependencies {
compile "com.android.support:support-annotations:$support_library_version"
compile "com.android.support:customtabs:$support_library_version"
compile 'com.android.support.constraint:constraint-layout:1.0.0'
compile 'com.android.support.constraint:constraint-layout:1.0.2'
compile 'com.android.support:multidex:1.0.1'
// ReactiveX
compile 'io.reactivex:rxandroid:1.2.1'
compile 'io.reactivex:rxjava:1.2.6'
compile 'io.reactivex:rxjava:1.2.9'
compile 'com.jakewharton.rxrelay:rxrelay:1.2.0'
compile 'com.f2prateek.rx.preferences:rx-preferences:1.0.2'
compile 'com.github.pwittchen:reactivenetwork:0.7.0'
@ -150,7 +153,7 @@ dependencies {
compile 'org.jsoup:jsoup:1.10.2'
// Job scheduling
compile 'com.evernote:android-job:1.1.6'
compile 'com.evernote:android-job:1.1.8'
compile 'com.google.android.gms:play-services-gcm:10.2.0'
// Changelog
@ -172,7 +175,7 @@ dependencies {
compile 'com.github.bumptech.glide:glide:3.7.0'
compile 'com.github.bumptech.glide:okhttp3-integration:1.4.0@aar'
// Transformations
compile 'jp.wasabeef:glide-transformations:2.0.1'
compile 'jp.wasabeef:glide-transformations:2.0.2'
// Logging
compile 'com.jakewharton.timber:timber:4.5.1'
@ -190,7 +193,7 @@ dependencies {
compile 'com.github.inorichi:FlexibleAdapter:93985fe' // v4.2.0 to be removed
compile 'com.nononsenseapps:filepicker:2.5.2'
compile 'com.github.amulyakhare:TextDrawable:558677e'
compile 'com.afollestad.material-dialogs:core:0.9.3.0'
compile 'com.afollestad.material-dialogs:core:0.9.4.2'
compile 'net.xpece.android:support-preference:1.2.5'
compile 'me.zhanghai.android.systemuihelper:library:1.0.0'
compile 'de.hdodenhof:circleimageview:2.1.0'
@ -209,7 +212,7 @@ dependencies {
}
buildscript {
ext.kotlin_version = '1.0.6'
ext.kotlin_version = '1.1.1'
repositories {
mavenCentral()
}
@ -221,3 +224,50 @@ buildscript {
repositories {
mavenCentral()
}
// Workaround to force a support lib version
configurations.all {
resolutionStrategy.eachDependency { details ->
def requested = details.requested
if (requested.group == 'com.android.support') {
if (!requested.name.startsWith("multidex")) {
details.useVersion '25.3.1'
}
}
}
}
// add support for placeholders in resource files
//https://code.google.com/p/android/issues/detail?id=69224
def replacePlaceholdersInFile(basePath, fileName, placeholders) {
def file = new File(basePath, fileName);
if (!file.exists()) {
logger.quiet("Unable to replace placeholders in " + file.toString() + ". File cannot be found.")
return;
}
logger.debug("Replacing placeholders in " + file.toString())
logger.debug("Placeholders: " + placeholders.toString())
def content = file.getText('UTF-8')
placeholders.each { entry ->
content = content.replaceAll("\\\$\\{${entry.key}\\}", entry.value)
}
file.write(content, 'UTF-8')
}
afterEvaluate {
android.applicationVariants.all { variant ->
variant.outputs.each { output ->
output.processResources.doFirst {
// prepare placeholder map from manifestPlaceholders including applicationId placeholder
def placeholders = variant.mergedFlavor.manifestPlaceholders + [applicationId: variant.applicationId]
replacePlaceholdersInFile(resDir, 'xml-v25/shortcuts.xml', placeholders)
}
}
}
}

View File

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

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

View File

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

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
import com.github.salomonbrys.kotson.fromJson
import android.content.Context
import com.github.salomonbrys.kotson.*
import com.google.gson.*
import com.google.gson.stream.JsonReader
import eu.kanade.tachiyomi.data.backup.serializer.BooleanSerializer
import eu.kanade.tachiyomi.data.backup.serializer.IdExclusion
import eu.kanade.tachiyomi.data.backup.serializer.IntegerSerializer
import eu.kanade.tachiyomi.data.backup.serializer.LongSerializer
import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_CATEGORY
import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_CATEGORY_MASK
import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_CHAPTER
import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_CHAPTER_MASK
import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_HISTORY
import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_HISTORY_MASK
import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_TRACK
import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_TRACK_MASK
import eu.kanade.tachiyomi.data.backup.models.Backup.CATEGORIES
import eu.kanade.tachiyomi.data.backup.models.Backup.CHAPTERS
import eu.kanade.tachiyomi.data.backup.models.Backup.CURRENT_VERSION
import eu.kanade.tachiyomi.data.backup.models.Backup.HISTORY
import eu.kanade.tachiyomi.data.backup.models.Backup.MANGA
import eu.kanade.tachiyomi.data.backup.models.Backup.TRACK
import eu.kanade.tachiyomi.data.backup.models.DHistory
import eu.kanade.tachiyomi.data.backup.serializer.*
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.*
import java.io.*
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.util.syncChaptersWithSource
import rx.Observable
import uy.kohesive.injekt.injectLazy
import java.util.*
/**
* This class provides the necessary methods to create and restore backups for the data of the
* application. The backup follows a JSON structure, with the following scheme:
*
* {
* "mangas": [
* {
* "manga": {"id": 1, ...},
* "chapters": [{"id": 1, ...}, {...}],
* "sync": [{"id": 1, ...}, {...}],
* "categories": ["cat1", "cat2", ...]
* },
* { ... }
* ],
* "categories": [
* {"id": 1, ...},
* {"id": 2, ...}
* ]
* }
*
* @param db the database helper.
*/
class BackupManager(private val db: DatabaseHelper) {
private val MANGA = "manga"
private val MANGAS = "mangas"
private val CHAPTERS = "chapters"
private val TRACK = "sync"
private val CATEGORIES = "categories"
@Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN")
private val gson = GsonBuilder()
.registerTypeAdapter(java.lang.Integer::class.java, IntegerSerializer())
.registerTypeAdapter(java.lang.Boolean::class.java, BooleanSerializer())
.registerTypeAdapter(java.lang.Long::class.java, LongSerializer())
.setExclusionStrategies(IdExclusion())
.create()
class BackupManager(val context: Context, version: Int = CURRENT_VERSION) {
/**
* Backups the data of the application to a file.
*
* @param file the file where the backup will be saved.
* @throws IOException if there's any IO error.
* Database.
*/
@Throws(IOException::class)
fun backupToFile(file: File) {
val root = backupToJson()
internal val databaseHelper: DatabaseHelper by injectLazy()
FileWriter(file).use {
gson.toJson(root, it)
/**
* Source manager.
*/
internal val sourceManager: SourceManager by injectLazy()
/**
* Version of parser
*/
var version: Int = version
private set
/**
* Json Parser
*/
var parser: Gson = initParser()
/**
* Preferences
*/
private val preferences: PreferencesHelper by injectLazy()
/**
* Set version of parser
*
* @param version version of parser
*/
internal fun setVersion(version: Int) {
this.version = version
parser = initParser()
}
private fun initParser(): Gson {
return when (version) {
1 -> GsonBuilder().create()
2 -> GsonBuilder()
.registerTypeAdapter<MangaImpl>(MangaTypeAdapter.build())
.registerTypeHierarchyAdapter<ChapterImpl>(ChapterTypeAdapter.build())
.registerTypeAdapter<CategoryImpl>(CategoryTypeAdapter.build())
.registerTypeAdapter<DHistory>(HistoryTypeAdapter.build())
.registerTypeHierarchyAdapter<TrackImpl>(TrackTypeAdapter.build())
.create()
else -> throw Exception("Json version unknown")
}
}
/**
* Creates a JSON object containing the backup of the app's data.
* Backup the categories of library
*
* @return the backup as a JSON object.
* @param root root of categories json
*/
fun backupToJson(): JsonObject {
val root = JsonObject()
// Backup library mangas and its dependencies
val mangaEntries = JsonArray()
root.add(MANGAS, mangaEntries)
for (manga in db.getFavoriteMangas().executeAsBlocking()) {
mangaEntries.add(backupManga(manga))
}
// Backup categories
val categoryEntries = JsonArray()
root.add(CATEGORIES, categoryEntries)
for (category in db.getCategories().executeAsBlocking()) {
categoryEntries.add(backupCategory(category))
}
return root
internal fun backupCategories(root: JsonArray) {
val categories = databaseHelper.getCategories().executeAsBlocking()
categories.forEach { root.add(parser.toJsonTree(it)) }
}
/**
* Backups a manga and its related data (chapters, categories this manga is in, sync...).
* Convert a manga to Json
*
* @param manga the manga to backup.
* @return a JSON object containing all the data of the manga.
* @param manga manga that gets converted
* @return [JsonElement] containing manga information
*/
private fun backupManga(manga: Manga): JsonObject {
internal fun backupMangaObject(manga: Manga, options: Int): JsonElement {
// Entry for this manga
val entry = JsonObject()
// Backup manga fields
entry.add(MANGA, gson.toJsonTree(manga))
entry[MANGA] = parser.toJsonTree(manga)
// Backup all the chapters
val chapters = db.getChapters(manga).executeAsBlocking()
if (!chapters.isEmpty()) {
entry.add(CHAPTERS, gson.toJsonTree(chapters))
}
// Backup tracks
val tracks = db.getTracks(manga).executeAsBlocking()
if (!tracks.isEmpty()) {
entry.add(TRACK, gson.toJsonTree(tracks))
}
// Backup categories for this manga
val categoriesForManga = db.getCategoriesForManga(manga).executeAsBlocking()
if (!categoriesForManga.isEmpty()) {
val categoriesNames = ArrayList<String>()
for (category in categoriesForManga) {
categoriesNames.add(category.name)
// Check if user wants chapter information in backup
if (options and BACKUP_CHAPTER_MASK == BACKUP_CHAPTER) {
// Backup all the chapters
val chapters = databaseHelper.getChapters(manga).executeAsBlocking()
if (!chapters.isEmpty()) {
val chaptersJson = parser.toJsonTree(chapters)
if (chaptersJson.asJsonArray.size() > 0) {
entry[CHAPTERS] = chaptersJson
}
}
}
// Check if user wants category information in backup
if (options and BACKUP_CATEGORY_MASK == BACKUP_CATEGORY) {
// Backup categories for this manga
val categoriesForManga = databaseHelper.getCategoriesForManga(manga).executeAsBlocking()
if (!categoriesForManga.isEmpty()) {
val categoriesNames = categoriesForManga.map { it.name }
entry[CATEGORIES] = parser.toJsonTree(categoriesNames)
}
}
// Check if user wants track information in backup
if (options and BACKUP_TRACK_MASK == BACKUP_TRACK) {
val tracks = databaseHelper.getTracks(manga).executeAsBlocking()
if (!tracks.isEmpty()) {
entry[TRACK] = parser.toJsonTree(tracks)
}
}
// Check if user wants history information in backup
if (options and BACKUP_HISTORY_MASK == BACKUP_HISTORY) {
val historyForManga = databaseHelper.getHistoryByMangaId(manga.id!!).executeAsBlocking()
if (!historyForManga.isEmpty()) {
val historyData = historyForManga.mapNotNull { history ->
val url = databaseHelper.getChapter(history.chapter_id).executeAsBlocking()?.url
url?.let { DHistory(url, history.last_read) }
}
val historyJson = parser.toJsonTree(historyData)
if (historyJson.asJsonArray.size() > 0) {
entry[HISTORY] = historyJson
}
}
entry.add(CATEGORIES, gson.toJsonTree(categoriesNames))
}
return entry
}
/**
* Backups a category.
*
* @param category the category to backup.
* @return a JSON object containing the data of the category.
*/
private fun backupCategory(category: Category): JsonElement {
return gson.toJsonTree(category)
fun restoreMangaNoFetch(manga: Manga, dbManga: Manga) {
manga.id = dbManga.id
manga.copyFrom(dbManga)
manga.favorite = true
insertManga(manga)
}
/**
* Restores a backup from a file.
* [Observable] that fetches manga information
*
* @param file the file containing the backup.
* @throws IOException if there's any IO error.
* @param source source of manga
* @param manga manga that needs updating
* @return [Observable] that contains manga
*/
@Throws(IOException::class)
fun restoreFromFile(file: File) {
JsonReader(FileReader(file)).use {
val root = JsonParser().parse(it).asJsonObject
restoreFromJson(root)
}
fun restoreMangaFetchObservable(source: Source, manga: Manga): Observable<Manga> {
return source.fetchMangaDetails(manga)
.map { networkManga ->
manga.copyFrom(networkManga)
manga.favorite = true
manga.initialized = true
manga.id = insertManga(manga)
manga
}
}
/**
* Restores a backup from an input stream.
* [Observable] that fetches chapter information
*
* @param stream the stream containing the backup.
* @throws IOException if there's any IO error.
* @param source source of manga
* @param manga manga that needs updating
* @return [Observable] that contains manga
*/
@Throws(IOException::class)
fun restoreFromStream(stream: InputStream) {
JsonReader(InputStreamReader(stream)).use {
val root = JsonParser().parse(it).asJsonObject
restoreFromJson(root)
}
fun restoreChapterFetchObservable(source: Source, manga: Manga, chapters: List<Chapter>): Observable<Pair<List<Chapter>, List<Chapter>>> {
return source.fetchChapterList(manga)
.map { syncChaptersWithSource(databaseHelper, it, manga, source) }
.doOnNext {
if (it.first.isNotEmpty()) {
chapters.forEach { it.manga_id = manga.id }
insertChapters(chapters)
}
}
}
/**
* Restores a backup from a JSON object. Everything executes in a single transaction so that
* nothing is modified if there's an error.
* Restore the categories from Json
*
* @param root the root of the JSON.
* @param jsonCategories array containing categories
*/
fun restoreFromJson(root: JsonObject) {
db.inTransaction {
// Restore categories
root.get(CATEGORIES)?.let {
restoreCategories(it.asJsonArray)
}
// Restore mangas
root.get(MANGAS)?.let {
restoreMangas(it.asJsonArray)
}
}
}
/**
* Restores the categories.
*
* @param jsonCategories the categories of the json.
*/
private fun restoreCategories(jsonCategories: JsonArray) {
internal fun restoreCategories(jsonCategories: JsonArray) {
// Get categories from file and from db
val dbCategories = db.getCategories().executeAsBlocking()
val backupCategories = gson.fromJson<List<CategoryImpl>>(jsonCategories)
val dbCategories = databaseHelper.getCategories().executeAsBlocking()
val backupCategories = parser.fromJson<List<CategoryImpl>>(jsonCategories)
// Iterate over them
for (category in backupCategories) {
backupCategories.forEach { category ->
// Used to know if the category is already in the db
var found = false
for (dbCategory in dbCategories) {
@ -214,102 +224,20 @@ class BackupManager(private val db: DatabaseHelper) {
if (!found) {
// Let the db assign the id
category.id = null
val result = db.insertCategory(category).executeAsBlocking()
val result = databaseHelper.insertCategory(category).executeAsBlocking()
category.id = result.insertedId()?.toInt()
}
}
}
/**
* Restores all the mangas and its related data.
*
* @param jsonMangas the mangas and its related data (chapters, sync, categories) from the json.
*/
private fun restoreMangas(jsonMangas: JsonArray) {
for (backupManga in jsonMangas) {
// Map every entry to objects
val element = backupManga.asJsonObject
val manga = gson.fromJson(element.get(MANGA), MangaImpl::class.java)
val chapters = gson.fromJson<List<ChapterImpl>>(element.get(CHAPTERS) ?: JsonArray())
val tracks = gson.fromJson<List<TrackImpl>>(element.get(TRACK) ?: JsonArray())
val categories = gson.fromJson<List<String>>(element.get(CATEGORIES) ?: JsonArray())
// Restore everything related to this manga
restoreManga(manga)
restoreChaptersForManga(manga, chapters)
restoreSyncForManga(manga, tracks)
restoreCategoriesForManga(manga, categories)
}
}
/**
* Restores a manga.
*
* @param manga the manga to restore.
*/
private fun restoreManga(manga: Manga) {
// Try to find existing manga in db
val dbManga = db.getManga(manga.url, manga.source).executeAsBlocking()
if (dbManga == null) {
// Let the db assign the id
manga.id = null
val result = db.insertManga(manga).executeAsBlocking()
manga.id = result.insertedId()
} else {
// If it exists already, we copy only the values related to the source from the db
// (they can be up to date). Local values (flags) are kept from the backup.
manga.id = dbManga.id
manga.copyFrom(dbManga)
manga.favorite = true
db.insertManga(manga).executeAsBlocking()
}
}
/**
* Restores the chapters of a manga.
*
* @param manga the manga whose chapters have to be restored.
* @param chapters the chapters to restore.
*/
private fun restoreChaptersForManga(manga: Manga, chapters: List<Chapter>) {
// Fix foreign keys with the current manga id
for (chapter in chapters) {
chapter.manga_id = manga.id
}
val dbChapters = db.getChapters(manga).executeAsBlocking()
val chaptersToUpdate = ArrayList<Chapter>()
for (backupChapter in chapters) {
// Try to find existing chapter in db
val pos = dbChapters.indexOf(backupChapter)
if (pos != -1) {
// The chapter is already in the db, only update its fields
val dbChapter = dbChapters[pos]
// If one of them was read, the chapter will be marked as read
dbChapter.read = backupChapter.read || dbChapter.read
dbChapter.last_page_read = Math.max(backupChapter.last_page_read, dbChapter.last_page_read)
chaptersToUpdate.add(dbChapter)
} else {
// Insert new chapter. Let the db assign the id
backupChapter.id = null
chaptersToUpdate.add(backupChapter)
}
}
// Update database
if (!chaptersToUpdate.isEmpty()) {
db.insertChapters(chaptersToUpdate).executeAsBlocking()
}
}
/**
* Restores the categories a manga is in.
*
* @param manga the manga whose categories have to be restored.
* @param categories the categories to restore.
*/
private fun restoreCategoriesForManga(manga: Manga, categories: List<String>) {
val dbCategories = db.getCategories().executeAsBlocking()
internal fun restoreCategoriesForManga(manga: Manga, categories: List<String>) {
val dbCategories = databaseHelper.getCategories().executeAsBlocking()
val mangaCategoriesToUpdate = ArrayList<MangaCategory>()
for (backupCategoryStr in categories) {
for (dbCategory in dbCategories) {
@ -324,45 +252,151 @@ class BackupManager(private val db: DatabaseHelper) {
if (!mangaCategoriesToUpdate.isEmpty()) {
val mangaAsList = ArrayList<Manga>()
mangaAsList.add(manga)
db.deleteOldMangasCategories(mangaAsList).executeAsBlocking()
db.insertMangasCategories(mangaCategoriesToUpdate).executeAsBlocking()
databaseHelper.deleteOldMangasCategories(mangaAsList).executeAsBlocking()
databaseHelper.insertMangasCategories(mangaCategoriesToUpdate).executeAsBlocking()
}
}
/**
* Restore history from Json
*
* @param history list containing history to be restored
*/
internal fun restoreHistoryForManga(history: List<DHistory>) {
// List containing history to be updated
val historyToBeUpdated = ArrayList<History>()
for ((url, lastRead) in history) {
val dbHistory = databaseHelper.getHistoryByChapterUrl(url).executeAsBlocking()
// Check if history already in database and update
if (dbHistory != null) {
dbHistory.apply {
last_read = Math.max(lastRead, dbHistory.last_read)
}
historyToBeUpdated.add(dbHistory)
} else {
// If not in database create
databaseHelper.getChapter(url).executeAsBlocking()?.let {
val historyToAdd = History.create(it).apply {
last_read = lastRead
}
historyToBeUpdated.add(historyToAdd)
}
}
}
databaseHelper.updateHistoryLastRead(historyToBeUpdated).executeAsBlocking()
}
/**
* Restores the sync of a manga.
*
* @param manga the manga whose sync have to be restored.
* @param tracks the track list to restore.
*/
private fun restoreSyncForManga(manga: Manga, tracks: List<Track>) {
internal fun restoreTrackForManga(manga: Manga, tracks: List<Track>) {
// Fix foreign keys with the current manga id
for (track in tracks) {
track.manga_id = manga.id!!
}
tracks.map { it.manga_id = manga.id!! }
val dbTracks = db.getTracks(manga).executeAsBlocking()
// Get tracks from database
val dbTracks = databaseHelper.getTracks(manga).executeAsBlocking()
val trackToUpdate = ArrayList<Track>()
for (backupTrack in tracks) {
// Try to find existing chapter in db
val pos = dbTracks.indexOf(backupTrack)
if (pos != -1) {
// The sync is already in the db, only update its fields
val dbSync = dbTracks[pos]
// Mark the max chapter as read and nothing else
dbSync.last_chapter_read = Math.max(backupTrack.last_chapter_read, dbSync.last_chapter_read)
trackToUpdate.add(dbSync)
} else {
for (track in tracks) {
var isInDatabase = false
for (dbTrack in dbTracks) {
if (track.sync_id == dbTrack.sync_id) {
// The sync is already in the db, only update its fields
if (track.remote_id != dbTrack.remote_id) {
dbTrack.remote_id = track.remote_id
}
dbTrack.last_chapter_read = Math.max(dbTrack.last_chapter_read, track.last_chapter_read)
isInDatabase = true
trackToUpdate.add(dbTrack)
break
}
}
if (!isInDatabase) {
// Insert new sync. Let the db assign the id
backupTrack.id = null
trackToUpdate.add(backupTrack)
track.id = null
trackToUpdate.add(track)
}
}
// Update database
if (!trackToUpdate.isEmpty()) {
db.insertTracks(trackToUpdate).executeAsBlocking()
databaseHelper.insertTracks(trackToUpdate).executeAsBlocking()
}
}
/**
* Restore the chapters for manga if chapters already in database
*
* @param manga manga of chapters
* @param chapters list containing chapters that get restored
* @return boolean answering if chapter fetch is not needed
*/
internal fun restoreChaptersForManga(manga: Manga, chapters: List<Chapter>): Boolean {
val dbChapters = databaseHelper.getChapters(manga).executeAsBlocking()
// Return if fetch is needed
if (dbChapters.isEmpty() || dbChapters.size < chapters.size)
return false
for (chapter in chapters) {
val pos = dbChapters.indexOf(chapter)
if (pos != -1) {
val dbChapter = dbChapters[pos]
chapter.id = dbChapter.id
chapter.copyFrom(dbChapter)
break
}
}
// Filter the chapters that couldn't be found.
chapters.filter { it.id != null }
chapters.map { it.manga_id = manga.id }
insertChapters(chapters)
return true
}
/**
* Returns manga
*
* @return [Manga], null if not found
*/
internal fun getMangaFromDatabase(manga: Manga): Manga? {
return databaseHelper.getManga(manga.url, manga.source).executeAsBlocking()
}
/**
* Returns list containing manga from library
*
* @return [Manga] from library
*/
internal fun getFavoriteManga(): List<Manga> {
return databaseHelper.getFavoriteMangas().executeAsBlocking()
}
/**
* Inserts manga and returns id
*
* @return id of [Manga], null if not found
*/
internal fun insertManga(manga: Manga): Long? {
return databaseHelper.insertManga(manga).executeAsBlocking().insertedId()
}
/**
* Inserts list of chapters
*/
internal fun insertChapters(chapters: List<Chapter>) {
databaseHelper.updateChaptersBackup(chapters).executeAsBlocking()
}
/**
* Return number of backups.
*
* @return number of backups selected by user
*/
fun numberOfBackups(): Int {
return preferences.numberOfBackups().getOrDefault()
}
}

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)
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.UpdateQuery
import eu.kanade.tachiyomi.data.database.models.History
import eu.kanade.tachiyomi.data.database.models.HistoryImpl
import eu.kanade.tachiyomi.data.database.tables.HistoryTable.COL_CHAPTER_ID
import eu.kanade.tachiyomi.data.database.tables.HistoryTable.COL_ID
import eu.kanade.tachiyomi.data.database.tables.HistoryTable.COL_LAST_READ
@ -44,7 +45,7 @@ open class HistoryPutResolver : DefaultPutResolver<History>() {
class HistoryGetResolver : DefaultGetResolver<History>() {
override fun mapFromCursor(cursor: Cursor): History = History().apply {
override fun mapFromCursor(cursor: Cursor): History = HistoryImpl().apply {
id = cursor.getLong(cursor.getColumnIndex(COL_ID))
chapter_id = cursor.getLong(cursor.getColumnIndex(COL_CHAPTER_ID))
last_read = cursor.getLong(cursor.getColumnIndex(COL_LAST_READ))

View File

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

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.Manga
import eu.kanade.tachiyomi.data.database.models.MangaChapter
import eu.kanade.tachiyomi.data.database.resolvers.ChapterBackupPutResolver
import eu.kanade.tachiyomi.data.database.resolvers.ChapterProgressPutResolver
import eu.kanade.tachiyomi.data.database.resolvers.ChapterSourceOrderPutResolver
import eu.kanade.tachiyomi.data.database.resolvers.MangaChapterGetResolver
@ -42,6 +43,16 @@ interface ChapterQueries : DbProvider {
.build())
.prepare()
fun getChapter(url: String) = db.get()
.`object`(Chapter::class.java)
.withQuery(Query.builder()
.table(ChapterTable.TABLE)
.where("${ChapterTable.COL_URL} = ?")
.whereArgs(url)
.build())
.prepare()
fun insertChapter(chapter: Chapter) = db.put().`object`(chapter).prepare()
fun insertChapters(chapters: List<Chapter>) = db.put().objects(chapters).prepare()
@ -50,6 +61,11 @@ interface ChapterQueries : DbProvider {
fun deleteChapters(chapters: List<Chapter>) = db.delete().objects(chapters).prepare()
fun updateChaptersBackup(chapters: List<Chapter>) = db.put()
.objects(chapters)
.withPutResolver(ChapterBackupPutResolver())
.prepare()
fun updateChapterProgress(chapter: Chapter) = db.put()
.`object`(chapter)
.withPutResolver(ChapterProgressPutResolver())

View File

@ -1,5 +1,6 @@
package eu.kanade.tachiyomi.data.database.queries
import com.pushtorefresh.storio.sqlite.queries.DeleteQuery
import com.pushtorefresh.storio.sqlite.queries.RawQuery
import eu.kanade.tachiyomi.data.database.DbProvider
import eu.kanade.tachiyomi.data.database.models.History
@ -40,6 +41,15 @@ interface HistoryQueries : DbProvider {
.build())
.prepare()
fun getHistoryByChapterUrl(chapterUrl: String) = db.get()
.`object`(History::class.java)
.withQuery(RawQuery.builder()
.query(getHistoryByChapterUrl())
.args(chapterUrl)
.observesTables(HistoryTable.TABLE)
.build())
.prepare()
/**
* Updates the history last read.
* Inserts history object if not yet in database
@ -59,4 +69,18 @@ interface HistoryQueries : DbProvider {
.objects(historyList)
.withPutResolver(HistoryLastReadPutResolver())
.prepare()
fun deleteHistory() = db.delete()
.byQuery(DeleteQuery.builder()
.table(HistoryTable.TABLE)
.build())
.prepare()
fun deleteHistoryNoLastRead() = db.delete()
.byQuery(DeleteQuery.builder()
.table(HistoryTable.TABLE)
.where("${HistoryTable.COL_LAST_READ} = ?")
.whereArgs(0)
.build())
.prepare()
}

View File

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

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}
"""
fun getHistoryByChapterUrl() = """
SELECT ${History.TABLE}.*
FROM ${History.TABLE}
JOIN ${Chapter.TABLE}
ON ${History.TABLE}.${History.COL_CHAPTER_ID} = ${Chapter.TABLE}.${Chapter.COL_ID}
WHERE ${Chapter.TABLE}.${Chapter.COL_URL} = ? AND ${History.TABLE}.${History.COL_CHAPTER_ID} = ${Chapter.TABLE}.${Chapter.COL_ID}
"""
fun getLastReadMangaQuery() = """
SELECT ${Manga.TABLE}.*, MAX(${History.TABLE}.${History.COL_LAST_READ}) AS max
FROM ${Manga.TABLE}

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

@ -11,6 +11,7 @@ import eu.kanade.tachiyomi.data.notification.NotificationHandler
import eu.kanade.tachiyomi.data.notification.NotificationReceiver
import eu.kanade.tachiyomi.util.chop
import eu.kanade.tachiyomi.util.notificationManager
import java.util.regex.Pattern
/**
* DownloadNotifier is used to show notifications when downloading one or multiple chapters.
@ -145,7 +146,8 @@ internal class DownloadNotifier(private val context: Context) {
} else {
download?.let {
val title = it.manga.title.chop(15)
val chapter = download.chapter.name.replaceFirst("$title[\\s]*[-]*[\\s]*".toRegex(RegexOption.IGNORE_CASE), "")
val quotedTitle = Pattern.quote(title)
val chapter = download.chapter.name.replaceFirst("$quotedTitle[\\s]*[-]*[\\s]*".toRegex(RegexOption.IGNORE_CASE), "")
setContentTitle("$title - $chapter".chop(30))
setContentText(context.getString(R.string.chapter_downloading_progress)
.format(it.downloadedImages, it.pages!!.size))
@ -202,7 +204,8 @@ internal class DownloadNotifier(private val context: Context) {
// Create notification.
with(notification) {
val title = download.manga.title.chop(15)
val chapter = download.chapter.name.replaceFirst("$title[\\s]*[-]*[\\s]*".toRegex(RegexOption.IGNORE_CASE), "")
val quotedTitle = Pattern.quote(title)
val chapter = download.chapter.name.replaceFirst("$quotedTitle[\\s]*[-]*[\\s]*".toRegex(RegexOption.IGNORE_CASE), "")
setContentTitle("$title - $chapter".chop(30))
setContentText(context.getString(R.string.update_check_notification_download_complete))
setSmallIcon(android.R.drawable.stat_sys_download_done)

View File

@ -1,10 +1,12 @@
package eu.kanade.tachiyomi.data.library
import android.app.Notification
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.graphics.BitmapFactory
import android.os.Build
import android.os.IBinder
import android.os.PowerManager
import android.support.v4.app.NotificationCompat
@ -28,7 +30,9 @@ import eu.kanade.tachiyomi.util.*
import rx.Observable
import rx.Subscription
import rx.schedulers.Schedulers
import uy.kohesive.injekt.injectLazy
import timber.log.Timber
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.util.*
import java.util.concurrent.atomic.AtomicInteger
@ -40,24 +44,12 @@ import java.util.concurrent.atomic.AtomicInteger
* progress of the update, and if case of an unexpected error, this service will be silently
* destroyed.
*/
class LibraryUpdateService : Service() {
/**
* Database helper.
*/
val db: DatabaseHelper by injectLazy()
/**
* Source manager.
*/
val sourceManager: SourceManager by injectLazy()
/**
* Preferences.
*/
val preferences: PreferencesHelper by injectLazy()
val downloadManager: DownloadManager by injectLazy()
class LibraryUpdateService(
val db: DatabaseHelper = Injekt.get(),
val sourceManager: SourceManager = Injekt.get(),
val preferences: PreferencesHelper = Injekt.get(),
val downloadManager: DownloadManager = Injekt.get()
) : Service() {
/**
* Wake lock that will be held until the service is destroyed.
@ -72,18 +64,27 @@ class LibraryUpdateService : Service() {
/**
* Pending intent of action that cancels the library update
*/
private val cancelPendingIntent by lazy {NotificationReceiver.cancelLibraryUpdatePendingBroadcast(this)}
private val cancelIntent by lazy {
NotificationReceiver.cancelLibraryUpdatePendingBroadcast(this)
}
/**
* Id of the library update notification.
* Bitmap of the app for notifications.
*/
private val notificationId: Int
get() = Constants.NOTIFICATION_LIBRARY_ID
private val notificationBitmap by lazy {
BitmapFactory.decodeResource(resources, R.mipmap.ic_launcher)
}
/**
* Cached progress notification to avoid creating a lot.
*/
private val progressNotification by lazy { NotificationCompat.Builder(this)
.setSmallIcon(R.drawable.ic_refresh_white_24dp_img)
.setLargeIcon(notificationBitmap)
.setOngoing(true)
.addAction(R.drawable.ic_clear_grey_24dp_img, getString(android.R.string.cancel), cancelIntent)
}
companion object {
/**
@ -141,16 +142,20 @@ class LibraryUpdateService : Service() {
*/
override fun onCreate() {
super.onCreate()
createAndAcquireWakeLock()
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK, "LibraryUpdateService:WakeLock")
wakeLock.acquire()
}
/**
* Method called when the service is destroyed. It destroys the running subscription, resets
* the alarm and release the wake lock.
* Method called when the service is destroyed. It destroys subscriptions and releases the wake
* lock.
*/
override fun onDestroy() {
subscription?.unsubscribe()
destroyWakeLock()
if (wakeLock.isHeld) {
wakeLock.release()
}
super.onDestroy()
}
@ -189,7 +194,7 @@ class LibraryUpdateService : Service() {
.subscribeOn(Schedulers.io())
.subscribe({
}, {
showNotification(getString(R.string.notification_update_error), "")
Timber.e(it)
stopSelf(startId)
}, {
stopSelf(startId)
@ -210,13 +215,13 @@ class LibraryUpdateService : Service() {
var listToUpdate = if (categoryId != -1)
db.getLibraryMangas().executeAsBlocking().filter { it.category == categoryId }
else {
val categoriesToUpdate = preferences.libraryUpdateCategories().getOrDefault().map { it.toInt() }
val categoriesToUpdate = preferences.libraryUpdateCategories().getOrDefault().map(String::toInt)
if (categoriesToUpdate.isNotEmpty())
db.getLibraryMangas().executeAsBlocking()
.filter { it.category in categoriesToUpdate }
.distinctBy { it.id }
else
db.getFavoriteMangas().executeAsBlocking().distinctBy { it.id }
db.getLibraryMangas().executeAsBlocking().distinctBy { it.id }
}
if (!intent.getBooleanExtra(UPDATE_DETAILS, false) && preferences.updateOnlyNonCompleted()) {
@ -238,13 +243,21 @@ class LibraryUpdateService : Service() {
fun updateChapterList(mangaToUpdate: List<Manga>): Observable<Manga> {
// Initialize the variables holding the progress of the updates.
val count = AtomicInteger(0)
// List containing new updates
val newUpdates = ArrayList<Manga>()
// list containing failed updates
val failedUpdates = ArrayList<Manga>()
// List containing categories that get included in downloads.
val categoriesToDownload = preferences.downloadNewCategories().getOrDefault().map(String::toInt)
// Boolean to determine if user wants to automatically download new chapters.
val downloadNew = preferences.downloadNew().getOrDefault()
// Boolean to determine if DownloadManager has downloads
var hasDownloads = false
// Emit each manga and update it sequentially.
return Observable.from(mangaToUpdate)
// Notify manga that will update.
.doOnNext { showProgressNotification(it, count.andIncrement, mangaToUpdate.size, cancelPendingIntent) }
.doOnNext { showProgressNotification(it, count.andIncrement, mangaToUpdate.size) }
// Update the chapters of the manga.
.concatMap { manga ->
updateManga(manga)
@ -254,10 +267,13 @@ class LibraryUpdateService : Service() {
Pair(emptyList<Chapter>(), emptyList<Chapter>())
}
// Filter out mangas without new chapters (or failed).
.filter { pair -> pair.first.size > 0 }
.filter { pair -> pair.first.isNotEmpty() }
.doOnNext {
if (preferences.downloadNew()) {
if (downloadNew && (categoriesToDownload.isEmpty() ||
manga.category in categoriesToDownload)) {
downloadChapters(manga, it.first)
hasDownloads = true
}
}
// Convert to the manga that contains new chapters.
@ -273,14 +289,18 @@ class LibraryUpdateService : Service() {
}
// Notify result of the overall update.
.doOnCompleted {
if (newUpdates.isEmpty()) {
cancelNotification()
} else {
if (preferences.downloadNew()) {
if (newUpdates.isNotEmpty()) {
showResultNotification(newUpdates)
if (downloadNew && hasDownloads) {
DownloadService.start(this)
}
showResultNotification(newUpdates, failedUpdates)
}
if (failedUpdates.isNotEmpty()) {
Timber.e("Failed updating: ${failedUpdates.map { it.title }}")
}
cancelProgressNotification()
}
}
@ -321,7 +341,7 @@ class LibraryUpdateService : Service() {
// Emit each manga and update it sequentially.
return Observable.from(mangaToUpdate)
// Notify manga that will update.
.doOnNext { showProgressNotification(it, count.andIncrement, mangaToUpdate.size, cancelPendingIntent) }
.doOnNext { showProgressNotification(it, count.andIncrement, mangaToUpdate.size) }
// Update the details of the manga.
.concatMap { manga ->
val source = sourceManager.get(manga.source) as? HttpSource
@ -336,73 +356,10 @@ class LibraryUpdateService : Service() {
.onErrorReturn { manga }
}
.doOnCompleted {
cancelNotification()
cancelProgressNotification()
}
}
/**
* Returns the text that will be displayed in the notification when there are new chapters.
*
* @param updates a list of manga that contains new chapters.
* @param failedUpdates a list of manga that failed to update.
* @return the body of the notification to display.
*/
private fun getUpdatedMangasBody(updates: List<Manga>, failedUpdates: List<Manga>): String {
return buildString {
if (updates.isEmpty()) {
append(getString(R.string.notification_no_new_chapters))
append("\n")
} else {
append(getString(R.string.notification_new_chapters))
for (manga in updates) {
append("\n")
append(manga.title.chop(45))
}
}
if (!failedUpdates.isEmpty()) {
append("\n\n")
append(getString(R.string.notification_manga_update_failed))
for (manga in failedUpdates) {
append("\n")
append(manga.title.chop(45))
}
}
}
}
/**
* Creates and acquires a wake lock until the library is updated.
*/
private fun createAndAcquireWakeLock() {
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK, "LibraryUpdateService:WakeLock")
wakeLock.acquire()
}
/**
* Releases the wake lock if it's held.
*/
private fun destroyWakeLock() {
if (wakeLock.isHeld) {
wakeLock.release()
}
}
/**
* Shows the notification with the given title and body.
*
* @param title the title of the notification.
* @param body the body of the notification.
*/
private fun showNotification(title: String, body: String) {
notificationManager.notify(notificationId, notification {
setSmallIcon(R.drawable.ic_refresh_white_24dp_img)
setLargeIcon(notificationBitmap)
setContentTitle(title)
setContentText(body)
})
}
/**
* Shows the notification containing the currently updating manga and the progress.
*
@ -410,52 +367,66 @@ class LibraryUpdateService : Service() {
* @param current the current progress.
* @param total the total progress.
*/
private fun showProgressNotification(manga: Manga, current: Int, total: Int, cancelIntent: PendingIntent) {
notificationManager.notify(notificationId, notification {
setSmallIcon(R.drawable.ic_refresh_white_24dp_img)
setLargeIcon(notificationBitmap)
setContentTitle(manga.title)
setProgress(total, current, false)
setOngoing(true)
addAction(R.drawable.ic_clear_grey_24dp_img, getString(android.R.string.cancel), cancelIntent)
})
private fun showProgressNotification(manga: Manga, current: Int, total: Int) {
notificationManager.notify(Constants.NOTIFICATION_LIBRARY_PROGRESS_ID, progressNotification
.setContentTitle(manga.title)
.setProgress(total, current, false)
.build())
}
/**
* Shows the notification containing the result of the update done by the service.
*
* @param updates a list of manga with new updates.
* @param failed a list of manga that failed to update.
*/
private fun showResultNotification(updates: List<Manga>, failed: List<Manga>) {
val title = getString(R.string.notification_update_completed)
val body = getUpdatedMangasBody(updates, failed)
private fun showResultNotification(updates: List<Manga>) {
val newUpdates = updates.map { it.title.chop(45) }.toMutableSet()
notificationManager.notify(notificationId, notification {
setSmallIcon(R.drawable.ic_refresh_white_24dp_img)
// Append new chapters from a previous, existing notification
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val previousNotification = notificationManager.activeNotifications
.find { it.id == Constants.NOTIFICATION_LIBRARY_RESULT_ID }
if (previousNotification != null) {
val oldUpdates = previousNotification.notification.extras
.getString(Notification.EXTRA_BIG_TEXT)
if (!oldUpdates.isNullOrEmpty()) {
newUpdates += oldUpdates.split("\n")
}
}
}
notificationManager.notify(Constants.NOTIFICATION_LIBRARY_RESULT_ID, notification {
setSmallIcon(R.drawable.ic_book_white_24dp)
setLargeIcon(notificationBitmap)
setContentTitle(title)
setStyle(NotificationCompat.BigTextStyle().bigText(body))
setContentIntent(notificationIntent)
setContentTitle(getString(R.string.notification_new_chapters))
if (newUpdates.size > 1) {
setContentText(getString(R.string.notification_new_chapters_text, newUpdates.size))
setStyle(NotificationCompat.BigTextStyle().bigText(newUpdates.joinToString("\n")))
} else {
setContentText(newUpdates.first())
}
priority = NotificationCompat.PRIORITY_HIGH
setContentIntent(getNotificationIntent())
setAutoCancel(true)
})
}
/**
* Cancels the notification.
* Cancels the progress notification.
*/
private fun cancelNotification() {
notificationManager.cancel(notificationId)
private fun cancelProgressNotification() {
notificationManager.cancel(Constants.NOTIFICATION_LIBRARY_PROGRESS_ID)
}
/**
* Property that returns an intent to open the main activity.
* Returns an intent to open the main activity.
*/
private val notificationIntent: PendingIntent
get() {
val intent = Intent(this, MainActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
return PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
}
private fun getNotificationIntent(): PendingIntent {
val intent = Intent(this, MainActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
return PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
}
}

View File

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

View File

@ -14,7 +14,7 @@ import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.download.DownloadService
import eu.kanade.tachiyomi.data.library.LibraryUpdateService
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.util.deleteIfExists
import eu.kanade.tachiyomi.util.DiskUtil
import eu.kanade.tachiyomi.util.getUriCompat
import eu.kanade.tachiyomi.util.notificationManager
import eu.kanade.tachiyomi.util.toast
@ -48,7 +48,7 @@ class NotificationReceiver : BroadcastReceiver() {
ACTION_DELETE_IMAGE -> deleteImage(context, intent.getStringExtra(EXTRA_FILE_LOCATION),
intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1))
// Cancel library update and dismiss notification
ACTION_CANCEL_LIBRARY_UPDATE -> cancelLibraryUpdate(context, Constants.NOTIFICATION_LIBRARY_ID)
ACTION_CANCEL_LIBRARY_UPDATE -> cancelLibraryUpdate(context, Constants.NOTIFICATION_LIBRARY_PROGRESS_ID)
// Open reader activity
ACTION_OPEN_CHAPTER -> {
openChapter(context, intent.getLongExtra(EXTRA_MANGA_ID, -1),
@ -120,7 +120,10 @@ class NotificationReceiver : BroadcastReceiver() {
dismissNotification(context, notificationId)
// Delete file
File(path).deleteIfExists()
val file = File(path)
file.delete()
DiskUtil.scanMedia(context, file)
}
/**
@ -180,7 +183,7 @@ class NotificationReceiver : BroadcastReceiver() {
val intent = Intent(context, NotificationReceiver::class.java).apply {
action = ACTION_RESUME_DOWNLOADS
}
return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_ONE_SHOT)
return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
}
/**
@ -193,7 +196,7 @@ class NotificationReceiver : BroadcastReceiver() {
val intent = Intent(context, NotificationReceiver::class.java).apply {
action = ACTION_CLEAR_DOWNLOADS
}
return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_ONE_SHOT)
return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
}
/**
@ -208,7 +211,7 @@ class NotificationReceiver : BroadcastReceiver() {
action = ACTION_DISMISS_NOTIFICATION
putExtra(EXTRA_NOTIFICATION_ID, notificationId)
}
return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_ONE_SHOT)
return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
}
/**
@ -225,7 +228,7 @@ class NotificationReceiver : BroadcastReceiver() {
putExtra(EXTRA_FILE_LOCATION, path)
putExtra(EXTRA_NOTIFICATION_ID, notificationId)
}
return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_ONE_SHOT)
return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
}
/**
@ -242,7 +245,7 @@ class NotificationReceiver : BroadcastReceiver() {
putExtra(EXTRA_FILE_LOCATION, path)
putExtra(EXTRA_NOTIFICATION_ID, notificationId)
}
return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_ONE_SHOT)
return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
}
/**
@ -258,7 +261,7 @@ class NotificationReceiver : BroadcastReceiver() {
putExtra(EXTRA_MANGA_ID, manga.id)
putExtra(EXTRA_CHAPTER_ID, chapter.id)
}
return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_ONE_SHOT)
return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
}
/**
@ -271,7 +274,7 @@ class NotificationReceiver : BroadcastReceiver() {
val intent = Intent(context, NotificationReceiver::class.java).apply {
action = ACTION_CANCEL_LIBRARY_UPDATE
}
return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_ONE_SHOT)
return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
}
}
}
}

View File

@ -65,12 +65,18 @@ class PreferenceKeys(context: Context) {
val enabledLanguages = context.getString(R.string.pref_source_languages)
val backupDirectory = context.getString(R.string.pref_backup_directory_key)
val downloadsDirectory = context.getString(R.string.pref_download_directory_key)
val downloadThreads = context.getString(R.string.pref_download_slots_key)
val downloadOnlyOverWifi = context.getString(R.string.pref_download_only_over_wifi_key)
val numberOfBackups = context.getString(R.string.pref_backup_slots_key)
val backupInterval = context.getString(R.string.pref_backup_interval_key)
val removeAfterReadSlots = context.getString(R.string.pref_remove_after_read_slots_key)
val removeAfterMarkedAsRead = context.getString(R.string.pref_remove_after_marked_as_read_key)
@ -93,6 +99,8 @@ class PreferenceKeys(context: Context) {
val downloadNew = context.getString(R.string.pref_download_new_key)
val downloadNewCategories = context.getString(R.string.pref_download_new_categories_key)
fun sourceUsername(sourceId: Long) = "pref_source_username_$sourceId"
fun sourcePassword(sourceId: Long) = "pref_source_password_$sourceId"
@ -107,4 +115,6 @@ class PreferenceKeys(context: Context) {
val lang = context.getString(R.string.pref_language_key)
val defaultCategory = context.getString(R.string.default_category_key)
}

View File

@ -26,6 +26,10 @@ class PreferencesHelper(val context: Context) {
File(Environment.getExternalStorageDirectory().absolutePath + File.separator +
context.getString(R.string.app_name), "downloads"))
private val defaultBackupDir = Uri.fromFile(
File(Environment.getExternalStorageDirectory().absolutePath + File.separator +
context.getString(R.string.app_name), "backup"))
fun startScreen() = prefs.getInt(keys.startScreen, 1)
fun clear() = prefs.edit().clear().apply()
@ -112,12 +116,18 @@ class PreferencesHelper(val context: Context) {
fun anilistScoreType() = rxPrefs.getInteger("anilist_score_type", 0)
fun backupsDirectory() = rxPrefs.getString(keys.backupDirectory, defaultBackupDir.toString())
fun downloadsDirectory() = rxPrefs.getString(keys.downloadsDirectory, defaultDownloadsDir.toString())
fun downloadThreads() = rxPrefs.getInteger(keys.downloadThreads, 1)
fun downloadOnlyOverWifi() = prefs.getBoolean(keys.downloadOnlyOverWifi, true)
fun numberOfBackups() = rxPrefs.getInteger(keys.numberOfBackups, 1)
fun backupInterval() = rxPrefs.getInteger(keys.backupInterval, 0)
fun removeAfterReadSlots() = prefs.getInt(keys.removeAfterReadSlots, -1)
fun removeAfterMarkedAsRead() = prefs.getBoolean(keys.removeAfterMarkedAsRead, false)
@ -142,8 +152,12 @@ class PreferencesHelper(val context: Context) {
fun hiddenCatalogues() = rxPrefs.getStringSet("hidden_catalogues", emptySet())
fun downloadNew() = prefs.getBoolean(keys.downloadNew, false)
fun downloadNew() = rxPrefs.getBoolean(keys.downloadNew, false)
fun downloadNewCategories() = rxPrefs.getStringSet(keys.downloadNewCategories, emptySet())
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.Observable
import uy.kohesive.injekt.injectLazy
import java.text.DecimalFormat
class Kitsu(private val context: Context, id: Int) : TrackService(id) {
@ -55,11 +56,17 @@ class Kitsu(private val context: Context, id: Int) : TrackService(id) {
}
override fun getScoreList(): List<String> {
return IntRange(0, 10).map { (it.toFloat() / 2).toString() }
val df = DecimalFormat("0.#")
return listOf("0") + IntRange(2, 20).map { df.format(it / 2f) }
}
override fun indexToScore(index: Int): Float {
return if (index > 0) (index + 1) / 2f else 0f
}
override fun displayScore(track: Track): String {
return track.toKitsuScore()
val df = DecimalFormat("0.#")
return df.format(track.score)
}
override fun add(track: Track): Observable<Track> {

View File

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

View File

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

View File

@ -6,7 +6,6 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Build
import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.NetworkHelper
@ -59,14 +58,21 @@ class UpdateDownloaderService : IntentService(UpdateDownloaderService::class.jav
fun downloadApk(url: String) {
// Show notification download starting.
sendInitialBroadcast()
// Progress of the download
var savedProgress = 0
val progressListener = object : ProgressListener {
// Progress of the download
var savedProgress = 0
// Keep track of the last notification sent to avoid posting too many.
var lastTick = 0L
override fun update(bytesRead: Long, contentLength: Long, done: Boolean) {
val progress = (100 * bytesRead / contentLength).toInt()
if (progress > savedProgress) {
val currentTime = System.currentTimeMillis()
if (progress > savedProgress && currentTime - 200 > lastTick) {
savedProgress = progress
lastTick = currentTime
sendProgressBroadcast(progress)
}
}
@ -112,11 +118,7 @@ class UpdateDownloaderService : IntentService(UpdateDownloaderService::class.jav
putExtra(UpdateDownloaderReceiver.EXTRA_ACTION, UpdateDownloaderReceiver.NOTIFICATION_UPDATER_PROGRESS)
putExtra(UpdateDownloaderReceiver.EXTRA_PROGRESS, progress)
}
// Prevents not showing of install notification TODO weird Android N bug. Find out what goes wrong
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N || progress <= 95) {
// Show download progress notification.
sendLocalBroadcastSync(intent)
}
sendLocalBroadcastSync(intent)
}
/**

View File

@ -10,7 +10,7 @@ import rx.Subscription
import java.util.concurrent.atomic.AtomicBoolean
fun Call.asObservable(): Observable<Response> {
return Observable.create { subscriber ->
return Observable.unsafeCreate { subscriber ->
// Since Call is a one-shot type, clone it for each new subscriber.
val call = clone()

View File

@ -176,8 +176,7 @@ class YamlHttpSource(mappings: Map<*, *>) : HttpSource() {
val pages = mutableListOf<Page>()
// TODO lazy initialization in Kotlin 1.1
val document = Jsoup.parse(body, url)
val document by lazy { Jsoup.parse(body, url) }
with(map.pages) {
// Capture a list of values where page urls will be resolved.

View File

@ -1,5 +1,6 @@
package eu.kanade.tachiyomi.source.online.english
import com.squareup.duktape.Duktape
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.source.model.*
@ -114,15 +115,37 @@ class Kissmanga : ParsedHttpSource() {
override fun pageListRequest(chapter: SChapter) = POST(baseUrl + chapter.url, headers)
override fun pageListParse(response: Response): List<Page> {
val pages = mutableListOf<Page>()
//language=RegExp
val p = Pattern.compile("""lstImages.push\("(.+?)"""")
val m = p.matcher(response.body().string())
val body = response.body().string()
var i = 0
while (m.find()) {
pages.add(Page(i++, "", m.group(1)))
val pages = mutableListOf<Page>()
// Kissmanga now encrypts the urls, so we need to execute these two scripts in JS.
val ca = client.newCall(GET("$baseUrl/Scripts/ca.js", headers)).execute().body().string()
val lo = client.newCall(GET("$baseUrl/Scripts/lo.js", headers)).execute().body().string()
Duktape.create().use {
it.evaluate(ca)
it.evaluate(lo)
// There are two functions in an inline script needed to decrypt the urls. We find and
// execute them.
var p = Pattern.compile("(.*CryptoJS.*)")
var m = p.matcher(body)
while (m.find()) {
it.evaluate(m.group(1))
}
// Finally find all the urls and decrypt them in JS.
p = Pattern.compile("""lstImages.push\((.*)\);""")
m = p.matcher(body)
var i = 0
while (m.find()) {
val url = it.evaluate(m.group(1)) as String
pages.add(Page(i++, "", url))
}
}
return pages
}

View File

@ -152,6 +152,11 @@ class Mangahere : ParsedHttpSource() {
}
override fun pageListParse(document: Document): List<Page> {
val licensedError = document.select(".mangaread_error > .mt10").first()
if (licensedError != null) {
throw Exception(licensedError.text())
}
val pages = mutableListOf<Page>()
document.select("select.wid60").first()?.getElementsByTag("option")?.forEach {
pages.add(Page(pages.size, it.attr("value")))

View File

@ -4,6 +4,7 @@ import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.source.model.*
import eu.kanade.tachiyomi.source.online.ParsedHttpSource
import okhttp3.FormBody
import okhttp3.Headers
import okhttp3.HttpUrl
import okhttp3.Request
import org.jsoup.nodes.Document
@ -27,11 +28,16 @@ class Mangasee : ParsedHttpSource() {
private val indexPattern = Pattern.compile("-index-(.*?)-")
private val catalogHeaders = Headers.Builder().apply {
add("User-Agent", "Mozilla/5.0 (Windows NT 6.3; WOW64)")
add("Host", "mangaseeonline.us")
}.build()
override fun popularMangaSelector() = "div.requested > div.row"
override fun popularMangaRequest(page: Int): Request {
val (body, requestUrl) = convertQueryToPost(page, "$baseUrl/search/request.php?sortBy=popularity&sortOrder=descending")
return POST(requestUrl, headers, body.build())
return POST(requestUrl, catalogHeaders, body.build())
}
override fun popularMangaFromElement(element: Element): SManga {
@ -74,7 +80,7 @@ class Mangasee : ParsedHttpSource() {
if (genresNo.isNotEmpty()) url.addQueryParameter("genreNo", genresNo.joinToString(","))
val (body, requestUrl) = convertQueryToPost(page, url.toString())
return POST(requestUrl, headers, body.build())
return POST(requestUrl, catalogHeaders, body.build())
}
private fun convertQueryToPost(page: Int, url: String): Pair<FormBody.Builder, String> {
@ -164,7 +170,7 @@ class Mangasee : ParsedHttpSource() {
override fun latestUpdatesRequest(page: Int): Request {
val url = "http://mangaseeonline.net/home/latest.request.php"
val (body, requestUrl) = convertQueryToPost(page, url)
return POST(requestUrl, headers, body.build())
return POST(requestUrl, catalogHeaders, body.build())
}
override fun latestUpdatesFromElement(element: Element): SManga {

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

@ -1,6 +1,6 @@
package eu.kanade.tachiyomi.ui.base.fragment
import android.support.v7.app.AppCompatActivity
import android.support.v4.app.FragmentActivity
import eu.kanade.tachiyomi.ui.base.activity.ActivityMixin
interface FragmentMixin {
@ -13,7 +13,7 @@ interface FragmentMixin {
(getActivity() as ActivityMixin).setToolbarTitle(getString(resourceId))
}
fun getActivity(): AppCompatActivity
fun getActivity(): FragmentActivity
fun getString(resource: Int): String
}

View File

@ -4,6 +4,7 @@ import android.content.res.Configuration
import android.os.Bundle
import android.support.design.widget.Snackbar
import android.support.v4.widget.DrawerLayout
import android.support.v7.app.AppCompatActivity
import android.support.v7.widget.*
import android.view.*
import android.widget.ArrayAdapter
@ -14,7 +15,9 @@ import com.f2prateek.rx.preferences.Preference
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.IFlexible
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Category
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.ui.base.fragment.BaseRxFragment
import eu.kanade.tachiyomi.ui.main.MainActivity
@ -32,6 +35,7 @@ import nucleus.factory.RequiresPresenter
import rx.Subscription
import rx.android.schedulers.AndroidSchedulers
import rx.subjects.PublishSubject
import uy.kohesive.injekt.injectLazy
import java.util.concurrent.TimeUnit.MILLISECONDS
/**
@ -44,6 +48,11 @@ open class CatalogueFragment : BaseRxFragment<CataloguePresenter>(),
FlexibleAdapter.OnItemLongClickListener,
FlexibleAdapter.EndlessScrollListener<ProgressItem> {
/**
* Preferences helper.
*/
private val preferences: PreferencesHelper by injectLazy()
/**
* Spinner shown in the toolbar to change the selected source.
*/
@ -155,7 +164,7 @@ open class CatalogueFragment : BaseRxFragment<CataloguePresenter>(),
setupRecycler()
// Create toolbar spinner
val themedContext = activity.supportActionBar?.themedContext ?: activity
val themedContext = (activity as AppCompatActivity).supportActionBar?.themedContext ?: activity
val spinnerAdapter = ArrayAdapter(themedContext,
android.R.layout.simple_spinner_item, presenter.sources)
@ -529,23 +538,72 @@ open class CatalogueFragment : BaseRxFragment<CataloguePresenter>(),
/**
* Called when a manga is long clicked.
*
* Adds the manga to the default category if none is set it shows a list of categories for the user to put the manga
* in, the list consists of the default category plus the user's categories. The default category is preselected on
* new manga, and on already favorited manga the manga's categories are preselected.
*
* @param position the position of the element clicked.
*/
override fun onItemLongClick(position: Int) {
// Get manga
val manga = (adapter.getItem(position) as? CatalogueItem?)?.manga ?: return
// Fetch categories
val categories = presenter.getCategories()
val textRes = if (manga.favorite) R.string.remove_from_library else R.string.add_to_library
MaterialDialog.Builder(activity)
.items(getString(textRes))
.itemsCallback { dialog, itemView, which, text ->
when (which) {
0 -> {
presenter.changeMangaFavorite(manga)
adapter.notifyItemChanged(position)
if (manga.favorite){
MaterialDialog.Builder(activity)
.items(getString(R.string.remove_from_library ))
.itemsCallback { _, _, which, _ ->
when (which) {
0 -> {
presenter.changeMangaFavorite(manga)
adapter.notifyItemChanged(position)
}
}
}
}.show()
}.show()
}else{
val defaultCategory = categories.find { it.id == preferences.defaultCategory()}
if(defaultCategory != null) {
presenter.changeMangaFavorite(manga)
presenter.moveMangaToCategory(defaultCategory, manga)
// Show manga has been added
context.toast(R.string.added_to_library)
adapter.notifyItemChanged(position)
} else {
MaterialDialog.Builder(activity)
.title(R.string.action_move_category)
.items(categories.map { it.name })
.itemsCallbackMultiChoice(presenter.getMangaCategoryIds(manga)) { dialog, position, _ ->
if (position.contains(0) && position.count() > 1) {
// Deselect default category
dialog.setSelectedIndices(position.filter {it > 0}.toTypedArray())
dialog.context.toast(R.string.invalid_combination)
}
true
}
.alwaysCallMultiChoiceCallback()
.positiveText(android.R.string.ok)
.negativeText(android.R.string.cancel)
.onPositive { dialog, _ ->
val selectedCategories = dialog.selectedIndices?.map { categories[it] } ?: emptyList()
updateMangaCategories(manga, selectedCategories, position)
}
.build()
.show()
}
}
}
/**
* Update manga to use selected categories.
*
* @param manga needed to change
* @param selectedCategories selected categories
* @param position position of adapter
*/
private fun updateMangaCategories(manga: Manga, selectedCategories: List<Category>, position: Int) {
presenter.updateMangaCategories(manga,selectedCategories)
adapter.notifyItemChanged(position)
}
}

View File

@ -5,7 +5,9 @@ import eu.davidea.flexibleadapter.items.IFlexible
import eu.davidea.flexibleadapter.items.ISectionable
import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Category
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.models.MangaCategory
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.source.CatalogueSource
@ -396,4 +398,66 @@ open class CataloguePresenter : BasePresenter<CatalogueFragment>() {
}
}
/**
* Get the default, and user categories.
*
* @return List of categories, default plus user categories
*/
fun getCategories(): List<Category> {
return arrayListOf(Category.createDefault()) + db.getCategories().executeAsBlocking()
}
/**
* Gets the category id's the manga is in, if the manga is not in a category, returns the default id.
*
* @param manga the manga to get categories from.
* @return Array of category ids the manga is in, if none returns default id
*/
fun getMangaCategoryIds(manga: Manga): Array<Int?> {
val categories = db.getCategoriesForManga(manga).executeAsBlocking()
if (categories.isEmpty()) {
return arrayListOf(Category.createDefault().id).toTypedArray()
}
return categories.map { it.id }.toTypedArray()
}
/**
* Move the given manga to categories.
*
* @param categories the selected categories.
* @param manga the manga to move.
*/
fun moveMangaToCategories(categories: List<Category>, manga: Manga) {
val mc = categories.map { MangaCategory.create(manga, it) }
db.setMangaCategories(mc, arrayListOf(manga))
}
/**
* Move the given manga to the category.
*
* @param category the selected category.
* @param manga the manga to move.
*/
fun moveMangaToCategory(category: Category, manga: Manga) {
moveMangaToCategories(arrayListOf(category), manga)
}
/**
* Update manga to use selected categories.
*
* @param manga needed to change
* @param selectedCategories selected categories
*/
fun updateMangaCategories(manga: Manga, selectedCategories: List<Category>) {
if (!selectedCategories.isEmpty()) {
if (!manga.favorite)
changeMangaFavorite(manga)
moveMangaToCategories(selectedCategories.filter { it.id != 0 }, manga)
} else {
changeMangaFavorite(manga)
}
}
}

View File

@ -9,6 +9,7 @@ import android.support.design.widget.TabLayout
import android.support.v4.graphics.drawable.DrawableCompat
import android.support.v4.view.ViewPager
import android.support.v4.widget.DrawerLayout
import android.support.v7.app.AppCompatActivity
import android.support.v7.view.ActionMode
import android.support.v7.widget.SearchView
import android.view.*
@ -356,7 +357,7 @@ class LibraryFragment : BaseRxFragment<LibraryPresenter>(), ActionMode.Callback
*/
fun createActionModeIfNeeded() {
if (actionMode == null) {
actionMode = activity.startSupportActionMode(this)
actionMode = (activity as AppCompatActivity).startSupportActionMode(this)
}
}
@ -440,11 +441,11 @@ class LibraryFragment : BaseRxFragment<LibraryPresenter>(), ActionMode.Callback
if (presenter.editCoverWithStream(it, manga)) {
// TODO refresh cover
} else {
context.toast(R.string.notification_manga_update_failed)
context.toast(R.string.notification_cover_update_failed)
}
}
} catch (error: IOException) {
context.toast(R.string.notification_manga_update_failed)
context.toast(R.string.notification_cover_update_failed)
Timber.e(error)
}
}

View File

@ -177,12 +177,9 @@ class LibraryPresenter : BasePresenter<LibraryFragment>() {
private fun applySort(map: Map<Int, List<Manga>>): Map<Int, List<Manga>> {
val sortingMode = preferences.librarySortingMode().getOrDefault()
// TODO lazy initialization in kotlin 1.1
var lastReadManga: Map<Long, Int>? = null
if (sortingMode == LibrarySort.LAST_READ) {
val lastReadManga by lazy {
var counter = 0
lastReadManga = db.getLastReadManga().executeAsBlocking()
.associate { it.id!! to counter++ }
db.getLastReadManga().executeAsBlocking().associate { it.id!! to counter++ }
}
val sortFn: (Manga, Manga) -> Int = { manga1, manga2 ->
@ -190,8 +187,8 @@ class LibraryPresenter : BasePresenter<LibraryFragment>() {
LibrarySort.ALPHA -> manga1.title.compareTo(manga2.title)
LibrarySort.LAST_READ -> {
// Get index of manga, set equal to list if size unknown.
val manga1LastRead = lastReadManga!![manga1.id!!] ?: lastReadManga!!.size
val manga2LastRead = lastReadManga!![manga2.id!!] ?: lastReadManga!!.size
val manga1LastRead = lastReadManga[manga1.id!!] ?: lastReadManga.size
val manga2LastRead = lastReadManga[manga2.id!!] ?: lastReadManga.size
manga1LastRead.compareTo(manga2LastRead)
}
LibrarySort.LAST_UPDATED -> manga2.last_update.compareTo(manga1.last_update)

View File

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

View File

@ -1,128 +1,116 @@
package eu.kanade.tachiyomi.ui.manga.chapter
import android.view.View
import android.widget.PopupMenu
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.ui.base.adapter.FlexibleViewHolder
import eu.kanade.tachiyomi.util.getResourceColor
import kotlinx.android.synthetic.main.item_chapter.view.*
import java.text.DateFormat
import java.text.DecimalFormat
import java.text.DecimalFormatSymbols
import java.util.*
class ChaptersHolder(
private val view: View,
private val adapter: ChaptersAdapter,
listener: FlexibleViewHolder.OnListItemClickListener)
: FlexibleViewHolder(view, adapter, listener) {
private val readColor = view.context.getResourceColor(android.R.attr.textColorHint)
private val unreadColor = view.context.getResourceColor(android.R.attr.textColorPrimary)
private val bookmarkedColor = view.context.getResourceColor(R.attr.colorAccent)
private val decimalFormat = DecimalFormat("#.###", DecimalFormatSymbols().apply { decimalSeparator = '.' })
private val df = DateFormat.getDateInstance(DateFormat.SHORT)
private var item: ChapterModel? = null
init {
// We need to post a Runnable to show the popup to make sure that the PopupMenu is
// correctly positioned. The reason being that the view may change position before the
// PopupMenu is shown.
view.chapter_menu.setOnClickListener { it.post { showPopupMenu(it) } }
}
fun onSetValues(chapter: ChapterModel, manga: Manga?) = with(view) {
item = chapter
chapter_title.text = when (manga?.displayMode) {
Manga.DISPLAY_NUMBER -> {
val formattedNumber = decimalFormat.format(chapter.chapter_number.toDouble())
context.getString(R.string.display_mode_chapter, formattedNumber)
}
else -> chapter.name
}
// Set correct text color
chapter_title.setTextColor(if (chapter.read) readColor else unreadColor)
if (chapter.bookmark) chapter_title.setTextColor(bookmarkedColor)
if (chapter.date_upload > 0) {
chapter_date.text = df.format(Date(chapter.date_upload))
chapter_date.setTextColor(if (chapter.read) readColor else unreadColor)
} else {
chapter_date.text = ""
}
chapter_pages.text = if (!chapter.read && chapter.last_page_read > 0) {
context.getString(R.string.chapter_progress, chapter.last_page_read + 1)
} else {
""
}
notifyStatus(chapter.status)
}
fun notifyStatus(status: Int) = with(view.download_text) {
when (status) {
Download.QUEUE -> setText(R.string.chapter_queued)
Download.DOWNLOADING -> setText(R.string.chapter_downloading)
Download.DOWNLOADED -> setText(R.string.chapter_downloaded)
Download.ERROR -> setText(R.string.chapter_error)
else -> text = ""
}
}
private fun showPopupMenu(view: View) = item?.let { chapter ->
// Create a PopupMenu, giving it the clicked view for an anchor
val popup = PopupMenu(view.context, view)
// Inflate our menu resource into the PopupMenu's Menu
popup.menuInflater.inflate(R.menu.chapter_single, popup.menu)
// Hide download and show delete if the chapter is downloaded
if (chapter.isDownloaded) {
popup.menu.findItem(R.id.action_download).isVisible = false
popup.menu.findItem(R.id.action_delete).isVisible = true
}
// Hide bookmark if bookmark
popup.menu.findItem(R.id.action_bookmark).isVisible = !chapter.bookmark
popup.menu.findItem(R.id.action_remove_bookmark).isVisible = chapter.bookmark
// Hide mark as unread when the chapter is unread
if (!chapter.read && chapter.last_page_read == 0) {
popup.menu.findItem(R.id.action_mark_as_unread).isVisible = false
}
// Hide mark as read when the chapter is read
if (chapter.read) {
popup.menu.findItem(R.id.action_mark_as_read).isVisible = false
}
// Set a listener so we are notified if a menu item is clicked
popup.setOnMenuItemClickListener { menuItem ->
val chapterList = listOf(chapter)
with(adapter.fragment) {
when (menuItem.itemId) {
R.id.action_download -> downloadChapters(chapterList)
R.id.action_bookmark -> bookmarkChapters(chapterList, true)
R.id.action_remove_bookmark -> bookmarkChapters(chapterList, false)
R.id.action_delete -> deleteChapters(chapterList)
R.id.action_mark_as_read -> markAsRead(chapterList)
R.id.action_mark_as_unread -> markAsUnread(chapterList)
R.id.action_mark_previous_as_read -> markPreviousAsRead(chapter)
}
}
true
}
// Finally show the PopupMenu
popup.show()
}
}
package eu.kanade.tachiyomi.ui.manga.chapter
import android.view.View
import android.widget.PopupMenu
import eu.davidea.viewholders.FlexibleViewHolder
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.util.getResourceColor
import kotlinx.android.synthetic.main.item_chapter.view.*
import java.text.DateFormat
import java.text.DecimalFormat
import java.text.DecimalFormatSymbols
import java.util.*
class ChapterHolder(
private val view: View,
private val adapter: ChaptersAdapter)
: FlexibleViewHolder(view, adapter) {
private val readColor = view.context.getResourceColor(android.R.attr.textColorHint)
private val unreadColor = view.context.getResourceColor(android.R.attr.textColorPrimary)
private val bookmarkedColor = view.context.getResourceColor(R.attr.colorAccent)
private val decimalFormat = DecimalFormat("#.###", DecimalFormatSymbols().apply { decimalSeparator = '.' })
private val df = DateFormat.getDateInstance(DateFormat.SHORT)
init {
// We need to post a Runnable to show the popup to make sure that the PopupMenu is
// correctly positioned. The reason being that the view may change position before the
// PopupMenu is shown.
view.chapter_menu.setOnClickListener { it.post { showPopupMenu(it) } }
}
fun bind(item: ChapterItem, manga: Manga) = with(view) {
val chapter = item.chapter
chapter_title.text = when (manga.displayMode) {
Manga.DISPLAY_NUMBER -> {
val formattedNumber = decimalFormat.format(chapter.chapter_number.toDouble())
context.getString(R.string.display_mode_chapter, formattedNumber)
}
else -> chapter.name
}
// Set correct text color
chapter_title.setTextColor(if (chapter.read) readColor else unreadColor)
if (chapter.bookmark) chapter_title.setTextColor(bookmarkedColor)
if (chapter.date_upload > 0) {
chapter_date.text = df.format(Date(chapter.date_upload))
chapter_date.setTextColor(if (chapter.read) readColor else unreadColor)
} else {
chapter_date.text = ""
}
chapter_pages.text = if (!chapter.read && chapter.last_page_read > 0) {
context.getString(R.string.chapter_progress, chapter.last_page_read + 1)
} else {
""
}
notifyStatus(item.status)
}
fun notifyStatus(status: Int) = with(view.download_text) {
when (status) {
Download.QUEUE -> setText(R.string.chapter_queued)
Download.DOWNLOADING -> setText(R.string.chapter_downloading)
Download.DOWNLOADED -> setText(R.string.chapter_downloaded)
Download.ERROR -> setText(R.string.chapter_error)
else -> text = ""
}
}
private fun showPopupMenu(view: View) {
val item = adapter.getItem(adapterPosition) ?: return
// Create a PopupMenu, giving it the clicked view for an anchor
val popup = PopupMenu(view.context, view)
// Inflate our menu resource into the PopupMenu's Menu
popup.menuInflater.inflate(R.menu.chapter_single, popup.menu)
val chapter = item.chapter
// Hide download and show delete if the chapter is downloaded
if (item.isDownloaded) {
popup.menu.findItem(R.id.action_download).isVisible = false
popup.menu.findItem(R.id.action_delete).isVisible = true
}
// Hide bookmark if bookmark
popup.menu.findItem(R.id.action_bookmark).isVisible = !chapter.bookmark
popup.menu.findItem(R.id.action_remove_bookmark).isVisible = chapter.bookmark
// Hide mark as unread when the chapter is unread
if (!chapter.read && chapter.last_page_read == 0) {
popup.menu.findItem(R.id.action_mark_as_unread).isVisible = false
}
// Hide mark as read when the chapter is read
if (chapter.read) {
popup.menu.findItem(R.id.action_mark_as_read).isVisible = false
}
// Set a listener so we are notified if a menu item is clicked
popup.setOnMenuItemClickListener { menuItem ->
adapter.menuItemListener(adapterPosition, menuItem)
true
}
// Finally show the PopupMenu
popup.show()
}
}

View File

@ -0,0 +1,50 @@
package eu.kanade.tachiyomi.ui.manga.chapter
import android.view.LayoutInflater
import android.view.ViewGroup
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.AbstractFlexibleItem
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.download.model.Download
class ChapterItem(val chapter: Chapter, val manga: Manga) : AbstractFlexibleItem<ChapterHolder>(),
Chapter by chapter {
private var _status: Int = 0
var status: Int
get() = download?.status ?: _status
set(value) { _status = value }
@Transient var download: Download? = null
val isDownloaded: Boolean
get() = status == Download.DOWNLOADED
override fun getLayoutRes(): Int {
return R.layout.item_chapter
}
override fun createViewHolder(adapter: FlexibleAdapter<*>, inflater: LayoutInflater, parent: ViewGroup): ChapterHolder {
return ChapterHolder(inflater.inflate(layoutRes, parent, false), adapter as ChaptersAdapter)
}
override fun bindViewHolder(adapter: FlexibleAdapter<*>, holder: ChapterHolder, position: Int, payloads: List<Any?>?) {
holder.bind(this, manga)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other is ChapterItem) {
return chapter.id!! == other.chapter.id!!
}
return false
}
override fun hashCode(): Int {
return chapter.id!!.hashCode()
}
}

View File

@ -1,19 +0,0 @@
package eu.kanade.tachiyomi.ui.manga.chapter
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.download.model.Download
class ChapterModel(c: Chapter) : Chapter by c {
private var _status: Int = 0
var status: Int
get() = download?.status ?: _status
set(value) { _status = value }
@Transient var download: Download? = null
val isDownloaded: Boolean
get() = status == Download.DOWNLOADED
}

View File

@ -1,42 +1,19 @@
package eu.kanade.tachiyomi.ui.manga.chapter
import android.view.ViewGroup
import eu.davidea.flexibleadapter4.FlexibleAdapter
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.util.inflate
import android.view.MenuItem
import eu.davidea.flexibleadapter.FlexibleAdapter
class ChaptersAdapter(val fragment: ChaptersFragment) : FlexibleAdapter<ChaptersHolder, ChapterModel>() {
class ChaptersAdapter(val fragment: ChaptersFragment) : FlexibleAdapter<ChapterItem>(null, fragment, true) {
init {
setHasStableIds(true)
var items: List<ChapterItem> = emptyList()
val menuItemListener: (Int, MenuItem) -> Unit = { position, item ->
fragment.onItemMenuClick(position, item)
}
var items: List<ChapterModel>
get() = mItems
set(value) {
mItems = value
notifyDataSetChanged()
}
override fun updateDataSet(param: String) {
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ChaptersHolder {
val v = parent.inflate(R.layout.item_chapter)
return ChaptersHolder(v, this, fragment)
}
override fun onBindViewHolder(holder: ChaptersHolder, position: Int) {
val chapter = getItem(position)
val manga = fragment.presenter.manga
holder.onSetValues(chapter, manga)
//When user scrolls this bind the correct selection status
holder.itemView.isActivated = isSelected(position)
}
override fun getItemId(position: Int): Long {
return mItems[position].id!!
override fun updateDataSet(items: List<ChapterItem>) {
this.items = items
super.updateDataSet(items.toList())
}
}

View File

@ -6,17 +6,17 @@ import android.content.Intent
import android.os.Bundle
import android.support.design.widget.Snackbar
import android.support.v4.app.DialogFragment
import android.support.v7.app.AppCompatActivity
import android.support.v7.view.ActionMode
import android.support.v7.widget.DividerItemDecoration
import android.support.v7.widget.LinearLayoutManager
import android.view.*
import com.afollestad.materialdialogs.MaterialDialog
import eu.davidea.flexibleadapter4.FlexibleAdapter
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.ui.base.adapter.FlexibleViewHolder
import eu.kanade.tachiyomi.ui.base.fragment.BaseRxFragment
import eu.kanade.tachiyomi.ui.manga.MangaActivity
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
@ -29,7 +29,10 @@ import nucleus.factory.RequiresPresenter
import timber.log.Timber
@RequiresPresenter(ChaptersPresenter::class)
class ChaptersFragment : BaseRxFragment<ChaptersPresenter>(), ActionMode.Callback, FlexibleViewHolder.OnListItemClickListener {
class ChaptersFragment : BaseRxFragment<ChaptersPresenter>(),
ActionMode.Callback,
FlexibleAdapter.OnItemClickListener,
FlexibleAdapter.OnItemLongClickListener {
companion object {
/**
@ -70,38 +73,31 @@ class ChaptersFragment : BaseRxFragment<ChaptersPresenter>(), ActionMode.Callbac
recycler.layoutManager = LinearLayoutManager(activity)
recycler.addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL))
recycler.setHasFixedSize(true)
// TODO enable in a future commit
// adapter.setFastScroller(fast_scroller, context.getResourceColor(R.attr.colorAccent))
// adapter.toggleFastScroller()
swipe_refresh.setOnRefreshListener { fetchChapters() }
fab.setOnClickListener {
val chapter = presenter.getNextUnreadChapter()
if (chapter != null) {
val item = presenter.getNextUnreadChapter()
if (item != null) {
// Create animation listener
val revealAnimationListener: Animator.AnimatorListener = object : AnimatorListenerAdapter() {
override fun onAnimationStart(animation: Animator?) {
openChapter(chapter, true)
openChapter(item.chapter, true)
}
}
// Get coordinates and start animation
val coordinates = fab.getCoordinates()
if (!reveal_view.showRevealEffect(coordinates.x, coordinates.y, revealAnimationListener)) {
openChapter(chapter)
openChapter(item.chapter)
}
} else {
context.toast(R.string.no_next_chapter)
}
}
}
override fun onPause() {
// Stop recycler's scrolling when onPause is called. If the activity is finishing
// the presenter will be destroyed, and it could cause NPE
// https://github.com/inorichi/tachiyomi/issues/159
recycler.stopScroll()
super.onPause()
}
override fun onResume() {
@ -172,19 +168,20 @@ class ChaptersFragment : BaseRxFragment<ChaptersPresenter>(), ActionMode.Callbac
return true
}
@Suppress("UNUSED_PARAMETER")
fun onNextManga(manga: Manga) {
// Set initial values
activity.supportInvalidateOptionsMenu()
}
fun onNextChapters(chapters: List<ChapterModel>) {
fun onNextChapters(chapters: List<ChapterItem>) {
// If the list is empty, fetch chapters from source if the conditions are met
// We use presenter chapters instead because they are always unfiltered
if (presenter.chapters.isEmpty())
initialFetchChapters()
destroyActionModeIfNeeded()
adapter.items = chapters
adapter.updateDataSet(chapters)
}
private fun initialFetchChapters() {
@ -229,7 +226,7 @@ class ChaptersFragment : BaseRxFragment<ChaptersPresenter>(), ActionMode.Callbac
.title(R.string.action_display_mode)
.items(modes.map { getString(it) })
.itemsIds(ids)
.itemsCallbackSingleChoice(selectedIndex) { dialog, itemView, which, text ->
.itemsCallbackSingleChoice(selectedIndex) { _, itemView, _, _ ->
// Save the new display mode
presenter.setDisplayMode(itemView.id)
// Refresh ui
@ -249,7 +246,7 @@ class ChaptersFragment : BaseRxFragment<ChaptersPresenter>(), ActionMode.Callbac
.title(R.string.sorting_mode)
.items(modes.map { getString(it) })
.itemsIds(ids)
.itemsCallbackSingleChoice(selectedIndex) { dialog, itemView, which, text ->
.itemsCallbackSingleChoice(selectedIndex) { _, itemView, _, _ ->
// Save the new sorting mode
presenter.setSorting(itemView.id)
true
@ -266,7 +263,7 @@ class ChaptersFragment : BaseRxFragment<ChaptersPresenter>(), ActionMode.Callbac
.title(R.string.manga_download)
.negativeText(android.R.string.cancel)
.items(modes.map { getString(it) })
.itemsCallback { dialog, view, i, charSequence ->
.itemsCallback { _, _, i, _ ->
fun getUnreadChaptersSorted() = presenter.chapters
.filter { !it.read && it.status == Download.NOT_DOWNLOADED }
@ -298,8 +295,8 @@ class ChaptersFragment : BaseRxFragment<ChaptersPresenter>(), ActionMode.Callbac
getHolder(download.chapter)?.notifyStatus(download.status)
}
private fun getHolder(chapter: Chapter): ChaptersHolder? {
return recycler.findViewHolderForItemId(chapter.id!!) as? ChaptersHolder
private fun getHolder(chapter: Chapter): ChapterHolder? {
return recycler.findViewHolderForItemId(chapter.id!!) as? ChapterHolder
}
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
@ -323,7 +320,7 @@ class ChaptersFragment : BaseRxFragment<ChaptersPresenter>(), ActionMode.Callbac
.content(R.string.confirm_delete_chapters)
.positiveText(android.R.string.yes)
.negativeText(android.R.string.no)
.onPositive { dialog, action -> deleteChapters(getSelectedChapters()) }
.onPositive { _, _ -> deleteChapters(getSelectedChapters()) }
.show()
}
else -> return false
@ -337,8 +334,8 @@ class ChaptersFragment : BaseRxFragment<ChaptersPresenter>(), ActionMode.Callbac
actionMode = null
}
fun getSelectedChapters(): List<ChapterModel> {
return adapter.selectedItems.map { adapter.getItem(it) }
fun getSelectedChapters(): List<ChapterItem> {
return adapter.selectedPositions.map { adapter.getItem(it) }
}
fun destroyActionModeIfNeeded() {
@ -350,18 +347,18 @@ class ChaptersFragment : BaseRxFragment<ChaptersPresenter>(), ActionMode.Callbac
setContextTitle(adapter.selectedItemCount)
}
fun markAsRead(chapters: List<ChapterModel>) {
fun markAsRead(chapters: List<ChapterItem>) {
presenter.markChaptersRead(chapters, true)
if (presenter.preferences.removeAfterMarkedAsRead()) {
deleteChapters(chapters)
}
}
fun markAsUnread(chapters: List<ChapterModel>) {
fun markAsUnread(chapters: List<ChapterItem>) {
presenter.markChaptersRead(chapters, false)
}
fun markPreviousAsRead(chapter: ChapterModel) {
fun markPreviousAsRead(chapter: ChapterItem) {
val chapters = if (presenter.sortDescending()) adapter.items.reversed() else adapter.items
val chapterPos = chapters.indexOf(chapter)
if (chapterPos != -1) {
@ -369,7 +366,7 @@ class ChaptersFragment : BaseRxFragment<ChaptersPresenter>(), ActionMode.Callbac
}
}
fun downloadChapters(chapters: List<ChapterModel>) {
fun downloadChapters(chapters: List<ChapterItem>) {
destroyActionModeIfNeeded()
presenter.downloadChapters(chapters)
if (!presenter.manga.favorite){
@ -381,12 +378,12 @@ class ChaptersFragment : BaseRxFragment<ChaptersPresenter>(), ActionMode.Callbac
}
}
fun bookmarkChapters(chapters: List<ChapterModel>, bookmarked: Boolean) {
fun bookmarkChapters(chapters: List<ChapterItem>, bookmarked: Boolean) {
destroyActionModeIfNeeded()
presenter.bookmarkChapters(chapters, bookmarked)
}
fun deleteChapters(chapters: List<ChapterModel>) {
fun deleteChapters(chapters: List<ChapterItem>) {
destroyActionModeIfNeeded()
DeletingChaptersDialog().show(childFragmentManager, DeletingChaptersDialog.TAG)
presenter.deleteChapters(chapters)
@ -407,26 +404,40 @@ class ChaptersFragment : BaseRxFragment<ChaptersPresenter>(), ActionMode.Callbac
?.dismissAllowingStateLoss()
}
override fun onListItemClick(position: Int): Boolean {
override fun onItemClick(position: Int): Boolean {
val item = adapter.getItem(position) ?: return false
if (actionMode != null && adapter.mode == FlexibleAdapter.MODE_MULTI) {
toggleSelection(position)
return true
} else {
openChapter(item)
openChapter(item.chapter)
return false
}
}
override fun onListItemLongClick(position: Int) {
override fun onItemLongClick(position: Int) {
if (actionMode == null)
actionMode = activity.startSupportActionMode(this)
actionMode = (activity as AppCompatActivity).startSupportActionMode(this)
toggleSelection(position)
}
fun onItemMenuClick(position: Int, item: MenuItem) {
val chapter = adapter.getItem(position)?.let { listOf(it) } ?: return
when (item.itemId) {
R.id.action_download -> downloadChapters(chapter)
R.id.action_bookmark -> bookmarkChapters(chapter, true)
R.id.action_remove_bookmark -> bookmarkChapters(chapter, false)
R.id.action_delete -> deleteChapters(chapter)
R.id.action_mark_as_read -> markAsRead(chapter)
R.id.action_mark_as_unread -> markAsUnread(chapter)
R.id.action_mark_previous_as_read -> markPreviousAsRead(chapter[0])
}
}
private fun toggleSelection(position: Int) {
adapter.toggleSelection(position, false)
adapter.toggleSelection(position)
val count = adapter.selectedItemCount
if (count == 0) {

View File

@ -65,14 +65,14 @@ class ChaptersPresenter : BasePresenter<ChaptersFragment>() {
/**
* List of chapters of the manga. It's always unfiltered and unsorted.
*/
var chapters: List<ChapterModel> = emptyList()
var chapters: List<ChapterItem> = emptyList()
private set
/**
* Subject of list of chapters to allow updating the view without going to DB.
*/
val chaptersRelay: PublishRelay<List<ChapterModel>>
by lazy { PublishRelay.create<List<ChapterModel>>() }
val chaptersRelay: PublishRelay<List<ChapterItem>>
by lazy { PublishRelay.create<List<ChapterItem>>() }
/**
* Whether the chapter list has been requested to the source.
@ -103,7 +103,7 @@ class ChaptersPresenter : BasePresenter<ChaptersFragment>() {
chaptersRelay.flatMap { applyChapterFilters(it) }
.observeOn(AndroidSchedulers.mainThread())
.subscribeLatestCache(ChaptersFragment::onNextChapters,
{ view, error -> Timber.e(error) })
{ _, error -> Timber.e(error) })
// Add the subscription that retrieves the chapters from the database, keeps subscribed to
// changes, and sends the list of chapters to the relay.
@ -135,15 +135,15 @@ class ChaptersPresenter : BasePresenter<ChaptersFragment>() {
.filter { download -> download.manga.id == manga.id }
.doOnNext { onDownloadStatusChange(it) }
.subscribeLatestCache(ChaptersFragment::onChapterStatusChange,
{ view, error -> Timber.e(error) })
{ _, error -> Timber.e(error) })
}
/**
* Converts a chapter from the database to an extended model, allowing to store new fields.
*/
private fun Chapter.toModel(): ChapterModel {
private fun Chapter.toModel(): ChapterItem {
// Create the model object.
val model = ChapterModel(this)
val model = ChapterItem(this, manga)
// Find an active download for this chapter.
val download = downloadManager.queue.find { it.chapter.id == id }
@ -160,7 +160,7 @@ class ChaptersPresenter : BasePresenter<ChaptersFragment>() {
*
* @param chapters the list of chapter from the database.
*/
private fun setDownloadedChapters(chapters: List<ChapterModel>) {
private fun setDownloadedChapters(chapters: List<ChapterItem>) {
val files = downloadManager.findMangaDir(source, manga)?.listFiles() ?: return
val cached = mutableMapOf<Chapter, String>()
files.mapNotNull { it.name }
@ -181,7 +181,7 @@ class ChaptersPresenter : BasePresenter<ChaptersFragment>() {
.subscribeOn(Schedulers.io())
.map { syncChaptersWithSource(db, it, manga, source) }
.observeOn(AndroidSchedulers.mainThread())
.subscribeFirst({ view, chapters ->
.subscribeFirst({ view, _ ->
view.onFetchChaptersDone()
}, ChaptersFragment::onFetchChaptersError)
}
@ -198,7 +198,7 @@ class ChaptersPresenter : BasePresenter<ChaptersFragment>() {
* @param chapters the list of chapters from the database
* @return an observable of the list of chapters filtered and sorted.
*/
private fun applyChapterFilters(chapters: List<ChapterModel>): Observable<List<ChapterModel>> {
private fun applyChapterFilters(chapters: List<ChapterItem>): Observable<List<ChapterItem>> {
var observable = Observable.from(chapters).subscribeOn(Schedulers.io())
if (onlyUnread()) {
observable = observable.filter { !it.read }
@ -248,7 +248,7 @@ class ChaptersPresenter : BasePresenter<ChaptersFragment>() {
/**
* Returns the next unread chapter or null if everything is read.
*/
fun getNextUnreadChapter(): ChapterModel? {
fun getNextUnreadChapter(): ChapterItem? {
return chapters.sortedByDescending { it.source_order }.find { !it.read }
}
@ -257,7 +257,7 @@ class ChaptersPresenter : BasePresenter<ChaptersFragment>() {
* @param selectedChapters the list of selected chapters.
* @param read whether to mark chapters as read or unread.
*/
fun markChaptersRead(selectedChapters: List<ChapterModel>, read: Boolean) {
fun markChaptersRead(selectedChapters: List<ChapterItem>, read: Boolean) {
Observable.from(selectedChapters)
.doOnNext { chapter ->
chapter.read = read
@ -275,7 +275,7 @@ class ChaptersPresenter : BasePresenter<ChaptersFragment>() {
* Downloads the given list of chapters with the manager.
* @param chapters the list of chapters to download.
*/
fun downloadChapters(chapters: List<ChapterModel>) {
fun downloadChapters(chapters: List<ChapterItem>) {
DownloadService.start(context)
downloadManager.downloadChapters(manga, chapters)
}
@ -284,7 +284,7 @@ class ChaptersPresenter : BasePresenter<ChaptersFragment>() {
* Bookmarks the given list of chapters.
* @param selectedChapters the list of chapters to bookmark.
*/
fun bookmarkChapters(selectedChapters: List<ChapterModel>, bookmarked: Boolean) {
fun bookmarkChapters(selectedChapters: List<ChapterItem>, bookmarked: Boolean) {
Observable.from(selectedChapters)
.doOnNext { chapter ->
chapter.bookmark = bookmarked
@ -299,14 +299,14 @@ class ChaptersPresenter : BasePresenter<ChaptersFragment>() {
* Deletes the given list of chapter.
* @param chapters the list of chapters to delete.
*/
fun deleteChapters(chapters: List<ChapterModel>) {
fun deleteChapters(chapters: List<ChapterItem>) {
Observable.from(chapters)
.doOnNext { deleteChapter(it) }
.toList()
.doOnNext { if (onlyDownloaded()) refreshChapters() }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeFirst({ view, result ->
.subscribeFirst({ view, _ ->
view.onChaptersDeleted()
}, ChaptersFragment::onChaptersDeletedError)
}
@ -315,7 +315,7 @@ class ChaptersPresenter : BasePresenter<ChaptersFragment>() {
* Deletes a chapter from disk. This method is called in a background thread.
* @param chapter the chapter to delete.
*/
private fun deleteChapter(chapter: ChapterModel) {
private fun deleteChapter(chapter: ChapterItem) {
downloadManager.queue.remove(chapter)
downloadManager.deleteChapter(source, manga, chapter)
chapter.status = Download.NOT_DOWNLOADED

View File

@ -6,6 +6,7 @@ import android.net.Uri
import android.os.Bundle
import android.support.customtabs.CustomTabsIntent
import android.view.*
import android.widget.Toast
import com.afollestad.materialdialogs.MaterialDialog
import com.bumptech.glide.BitmapRequestBuilder
import com.bumptech.glide.BitmapTypeRequest
@ -14,6 +15,7 @@ import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.load.resource.bitmap.CenterCrop
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource
@ -31,6 +33,7 @@ import nucleus.factory.RequiresPresenter
import rx.Observable
import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers
import uy.kohesive.injekt.injectLazy
/**
* Fragment that shows manga information.
@ -52,6 +55,11 @@ class MangaInfoFragment : BaseRxFragment<MangaInfoPresenter>() {
}
/**
* Preferences helper.
*/
private val preferences: PreferencesHelper by injectLazy()
override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState)
setHasOptionsMenu(true)
@ -63,7 +71,19 @@ class MangaInfoFragment : BaseRxFragment<MangaInfoPresenter>() {
override fun onViewCreated(view: View?, savedState: Bundle?) {
// Set onclickListener to toggle favorite when FAB clicked.
fab_favorite.setOnClickListener { toggleFavorite() }
fab_favorite.setOnClickListener {
if(!presenter.manga.favorite) {
val defaultCategory = presenter.getCategories().find { it.id == preferences.defaultCategory()}
if(defaultCategory == null) {
onFabClick()
} else {
toggleFavorite()
presenter.moveMangaToCategory(defaultCategory, presenter.manga)
}
} else {
toggleFavorite()
}
}
// Set SwipeRefresh to refresh manga data.
swipe_refresh.setOnRefreshListener { fetchMangaFromSource() }
@ -334,4 +354,40 @@ class MangaInfoFragment : BaseRxFragment<MangaInfoPresenter>() {
swipe_refresh.isRefreshing = value
}
/**
* Called when the fab is clicked.
*/
private fun onFabClick() {
val categories = presenter.getCategories()
MaterialDialog.Builder(activity)
.title(R.string.action_move_category)
.items(categories.map { it.name })
.itemsCallbackMultiChoice(presenter.getMangaCategoryIds(presenter.manga)) { dialog, position, text ->
if (position.contains(0) && position.count() > 1) {
dialog.setSelectedIndices(position.filter {it > 0}.toTypedArray())
Toast.makeText(dialog.context, R.string.invalid_combination, Toast.LENGTH_SHORT).show()
}
true
}
.alwaysCallMultiChoiceCallback()
.positiveText(android.R.string.ok)
.negativeText(android.R.string.cancel)
.onPositive { dialog, _ ->
val selectedCategories = dialog.selectedIndices?.map { categories[it] } ?: emptyList()
if(!selectedCategories.isEmpty()) {
if(!presenter.manga.favorite) {
toggleFavorite()
}
presenter.moveMangaToCategories(selectedCategories.filter { it.id != 0}, presenter.manga)
} else {
toggleFavorite()
}
}
.build()
.show()
}
}

View File

@ -3,7 +3,9 @@ package eu.kanade.tachiyomi.ui.manga.info
import android.os.Bundle
import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Category
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.models.MangaCategory
import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceManager
@ -76,9 +78,12 @@ class MangaInfoPresenter : BasePresenter<MangaInfoFragment>() {
?.subscribeLatestCache(MangaInfoFragment::setChapterCount)
// Update favorite status
SharedData.get(MangaFavoriteEvent::class.java)?.observable
?.observeOn(AndroidSchedulers.mainThread())
?.subscribe { setFavorite(it) }
SharedData.get(MangaFavoriteEvent::class.java)?.let {
it.observable
.observeOn(AndroidSchedulers.mainThread())
.subscribe { setFavorite(it) }
.apply { add(this) }
}
}
/**
@ -148,4 +153,49 @@ class MangaInfoPresenter : BasePresenter<MangaInfoFragment>() {
downloadManager.findMangaDir(source, manga)?.delete()
}
/**
* Get the default, and user categories.
*
* @return List of categories, default plus user categories
*/
fun getCategories(): List<Category> {
return arrayListOf(Category.createDefault()) + db.getCategories().executeAsBlocking()
}
/**
* Gets the category id's the manga is in, if the manga is not in a category, returns the default id.
*
* @param manga the manga to get categories from.
* @return Array of category ids the manga is in, if none returns default id
*/
fun getMangaCategoryIds(manga: Manga): Array<Int?> {
val categories = db.getCategoriesForManga(manga).executeAsBlocking()
if(categories.isEmpty()) {
return arrayListOf(Category.createDefault().id).toTypedArray()
}
return categories.map { it.id }.toTypedArray()
}
/**
* Move the given manga to categories.
*
* @param categories the selected categories.
* @param manga the manga to move.
*/
fun moveMangaToCategories(categories: List<Category>, manga: Manga) {
val mc = categories.map { MangaCategory.create(manga, it) }
db.setMangaCategories(mc, arrayListOf(manga))
}
/**
* Move the given manga to the category.
*
* @param category the selected category.
* @param manga the manga to move.
*/
fun moveMangaToCategory(category: Category, manga: Manga) {
moveMangaToCategories(arrayListOf(category), manga)
}
}

View File

@ -607,13 +607,17 @@ class ReaderPresenter : BasePresenter<ReaderActivity>() {
}
}
DiskUtil.scanMedia(context, destFile)
imageNotifier.onComplete(destFile)
}
.subscribeOn(Schedulers.io())
.subscribe({},
{ error ->
Timber.e(error)
imageNotifier.onError(error.message)
})
.observeOn(AndroidSchedulers.mainThread())
.subscribe({
context.toast(R.string.picture_saved)
}, { error ->
Timber.e(error)
imageNotifier.onError(error.message)
})
}
}

View File

@ -230,7 +230,10 @@ abstract class PagerReader : BaseReader() {
*/
protected fun setPagesOnAdapter() {
if (pages.isNotEmpty()) {
// Prevent a wrong active page when changing chapters with the navigation buttons.
val currPage = currentPage
adapter.pages = pages
currentPage = currPage
if (currentPage == pager.currentItem) {
onPageChanged(currentPage)
} else {

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

View File

@ -1,7 +1,9 @@
package eu.kanade.tachiyomi.ui.reader.viewer.webtoon
import android.os.Build
import android.os.Bundle
import android.support.v7.widget.RecyclerView
import android.util.DisplayMetrics
import android.view.*
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
@ -64,6 +66,19 @@ class WebtoonReader : BaseReader() {
private var scrollDistance: Int = 0
val screenHeight by lazy {
val display = activity.windowManager.defaultDisplay
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
val metrics = DisplayMetrics()
display.getRealMetrics(metrics)
metrics.heightPixels
} else {
val field = Display::class.java.getMethod("getRawHeight")
field.invoke(display) as Int
}
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedState: Bundle?): View? {
adapter = WebtoonAdapter(this)
@ -72,9 +87,6 @@ class WebtoonReader : BaseReader() {
layoutManager = PreCachingLayoutManager(activity)
layoutManager.extraLayoutSpace = screenHeight / 2
if (savedState != null) {
layoutManager.scrollToPositionWithOffset(savedState.getInt(SAVED_POSITION), 0)
}
recycler = RecyclerView(activity).apply {
layoutParams = ViewGroup.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
@ -107,6 +119,26 @@ class WebtoonReader : BaseReader() {
return recycler
}
/**
* Uses two ways to scroll to the last page read.
*/
private fun scrollToLastPageRead(page: Int) {
// Scrolls to the correct page initially, but isn't reliable beyond that.
recycler.addOnLayoutChangeListener(object: View.OnLayoutChangeListener {
override fun onLayoutChange(p0: View?, p1: Int, p2: Int, p3: Int, p4: Int, p5: Int, p6: Int, p7: Int, p8: Int) {
if(pages.isEmpty()) {
setActivePage(page)
} else {
recycler.removeOnLayoutChangeListener(this)
}
}
})
// Scrolls to the correct page after app has been in use, but can't do it the very first time.
recycler.post { setActivePage(page) }
}
override fun onDestroyView() {
subscriptions.unsubscribe()
super.onDestroyView()
@ -150,6 +182,7 @@ class WebtoonReader : BaseReader() {
// Make sure the view is already initialized.
if (view != null) {
setPagesOnAdapter()
scrollToLastPageRead(this.currentPage)
}
}

View File

@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.ui.recent_updates
import android.os.Bundle
import android.support.v4.app.DialogFragment
import android.support.v7.app.AppCompatActivity
import android.support.v7.view.ActionMode
import android.support.v7.widget.DividerItemDecoration
import android.support.v7.widget.LinearLayoutManager
@ -142,7 +143,7 @@ class RecentChaptersFragment:
*/
override fun onItemLongClick(position: Int) {
if (actionMode == null)
actionMode = activity.startSupportActionMode(this)
actionMode = (activity as AppCompatActivity).startSupportActionMode(this)
toggleSelection(position)
}

View File

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

View File

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

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,20 +9,17 @@ import android.os.Environment
import android.support.v4.content.ContextCompat
import android.support.v7.preference.Preference
import android.support.v7.preference.XpPreferenceFragment
import android.support.v7.widget.RecyclerView
import android.view.View
import android.view.ViewGroup
import com.afollestad.materialdialogs.MaterialDialog
import com.hippo.unifile.UniFile
import com.nononsenseapps.filepicker.AbstractFilePickerFragment
import com.nononsenseapps.filepicker.FilePickerActivity
import com.nononsenseapps.filepicker.FilePickerFragment
import com.nononsenseapps.filepicker.LogicHandler
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.util.inflate
import eu.kanade.tachiyomi.util.plusAssign
import eu.kanade.tachiyomi.widget.CustomLayoutPickerActivity
import net.xpece.android.support.preference.MultiSelectListPreference
import uy.kohesive.injekt.injectLazy
import java.io.File
@ -41,8 +38,12 @@ class SettingsDownloadsFragment : SettingsFragment() {
private val preferences: PreferencesHelper by injectLazy()
private val db: DatabaseHelper by injectLazy()
val downloadDirPref: Preference by bindPref(R.string.pref_download_directory_key)
val downloadCategory: MultiSelectListPreference by bindPref(R.string.pref_download_new_categories_key)
override fun onViewCreated(view: View, savedState: Bundle?) {
super.onViewCreated(view, savedState)
@ -92,6 +93,29 @@ class SettingsDownloadsFragment : SettingsFragment() {
dir.createFile(".nomedia")
}
}
subscriptions += preferences.downloadNew().asObservable()
.subscribe { downloadCategory.isVisible = it }
val dbCategories = db.getCategories().executeAsBlocking()
downloadCategory.apply {
entries = dbCategories.map { it.name }.toTypedArray()
entryValues = dbCategories.map { it.id.toString() }.toTypedArray()
}
subscriptions += preferences.downloadNewCategories().asObservable()
.subscribe {
val selectedCategories = it
.mapNotNull { id -> dbCategories.find { it.id == id.toInt() } }
.sortedBy { it.order }
val summary = if (selectedCategories.isEmpty())
getString(R.string.all)
else
selectedCategories.joinToString { it.name }
downloadCategory.summary = summary
}
}
fun getExternalFilesDirs(): List<File> {
@ -122,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_sources)
addPreferencesFromResource(R.xml.pref_tracking)
addPreferencesFromResource(R.xml.pref_backup)
addPreferencesFromResource(R.xml.pref_advanced)
addPreferencesFromResource(R.xml.pref_about)

View File

@ -7,6 +7,7 @@ import android.support.v7.preference.XpPreferenceFragment
import android.view.View
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Category
import eu.kanade.tachiyomi.data.library.LibraryUpdateJob
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.util.LocaleHelper
@ -46,6 +47,8 @@ class SettingsGeneralFragment : SettingsFragment(),
val categoryUpdate: MultiSelectListPreference by bindPref(R.string.pref_library_update_categories_key)
val defaultCategory: IntListPreference by bindPref(R.string.default_category_key)
val langPreference: ListPreference by bindPref(R.string.pref_language_key)
override fun onViewCreated(view: View, savedState: Bundle?) {
@ -100,6 +103,22 @@ class SettingsGeneralFragment : SettingsFragment(),
categoryUpdate.summary = summary
}
defaultCategory.apply {
val selectedCategory = dbCategories.find { it.id == preferences.defaultCategory()}
value = selectedCategory?.id?.toString() ?: value
entries += dbCategories.map { it.name }.toTypedArray()
entryValues += dbCategories.map { it.id.toString() }.toTypedArray()
summary = selectedCategory?.name ?: summary
}
defaultCategory.setOnPreferenceChangeListener { _, newValue ->
defaultCategory.summary = dbCategories.find {
it.id == (newValue as String).toInt()
}?.name ?: getString(R.string.default_category_summary)
true
}
themePreference.setOnPreferenceChangeListener { preference, newValue ->
(activity as SettingsActivity).parentFlags = SettingsActivity.FLAG_THEME_CHANGED
activity.recreate()

View File

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

View File

@ -1,6 +1,9 @@
package eu.kanade.tachiyomi.util
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.support.v4.content.ContextCompat
import android.support.v4.os.EnvironmentCompat
@ -13,8 +16,11 @@ import java.security.NoSuchAlgorithmException
object DiskUtil {
fun isImage(name: String, openStream: (() -> InputStream)? = null): Boolean {
val contentType = URLConnection.guessContentTypeFromName(name)
?: openStream?.let { findImageMime(it) }
val contentType = try {
URLConnection.guessContentTypeFromName(name)
} catch (e: Exception) {
null
} ?: openStream?.let { findImageMime(it) }
return contentType?.startsWith("image/") ?: false
}
@ -73,8 +79,9 @@ object DiskUtil {
/**
* Returns the root folders of all the available external storages.
*/
fun getExternalStorages(context: Context): List<File> {
return ContextCompat.getExternalFilesDirs(context, null)
fun getExternalStorages(context: Context): Collection<File> {
val directories = mutableSetOf<File>()
directories += ContextCompat.getExternalFilesDirs(context, null)
.filterNotNull()
.mapNotNull {
val file = File(it.absolutePath.substringBefore("/Android/"))
@ -85,6 +92,29 @@ object DiskUtil {
null
}
}
if (Build.VERSION.SDK_INT < 21) {
val extStorages = System.getenv("SECONDARY_STORAGE")
if (extStorages != null) {
directories += extStorages.split(":").map(::File)
}
}
return directories
}
/**
* Scans the given file so that it can be shown in gallery apps, for example.
*/
fun scanMedia(context: Context, file: File) {
val action = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) {
Intent.ACTION_MEDIA_MOUNTED
} else {
Intent.ACTION_MEDIA_SCANNER_SCAN_FILE
}
val mediaScanIntent = Intent(action)
mediaScanIntent.data = Uri.fromFile(file)
context.sendBroadcast(mediaScanIntent)
}
/**

View File

@ -19,15 +19,3 @@ fun File.getUriCompat(context: Context): Uri {
return uri
}
/**
* Deletes file if exists
*
* @return success of file deletion
*/
fun File.deleteIfExists(): Boolean {
if (this.exists()) {
this.delete()
return true
}
return false
}

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"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android"
android:opacity="opaque">
<item android:drawable="@color/background_material_light"/>
<item android:drawable="@color/material_grey_50"/>
<item>
<bitmap
android:src="@drawable/application_logo_144dp"
android:src="@drawable/branded_logo_icon"
android:gravity="center"/>
</item>
</layer-list>

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

@ -13,8 +13,7 @@
android:layout_height="match_parent"
android:background="?attr/colorAccent"
android:elevation="5dp"
android:visibility="invisible"
/>
android:visibility="invisible"/>
<android.support.v4.widget.SwipeRefreshLayout
android:id="@+id/swipe_refresh"
@ -36,6 +35,17 @@
</android.support.v4.widget.SwipeRefreshLayout>
<eu.davidea.fastscroller.FastScroller
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/fast_scroller"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_centerHorizontal="true"
android:layout_gravity="end"
android:visibility="gone"
tools:visibility="visible"/>
<android.support.design.widget.FloatingActionButton
android:id="@+id/fab"
style="@style/Theme.Widget.FAB"

View File

@ -1,298 +1,266 @@
<?xml version="1.0" encoding="utf-8"?>
<android.support.design.widget.CoordinatorLayout
<android.support.v4.widget.SwipeRefreshLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
tools:context="eu.kanade.tachiyomi.ui.catalogue.CatalogueFragment"
android:id="@id/swipe_refresh"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="eu.kanade.tachiyomi.ui.catalogue.CatalogueFragment">
android:layout_height="match_parent">
<android.support.v4.widget.SwipeRefreshLayout
android:id="@id/swipe_refresh"
<android.support.constraint.ConstraintLayout
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
android:layout_height="match_parent"
xmlns:android="http://schemas.android.com/apk/res/android">
<LinearLayout
android:id="@+id/global_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<android.support.constraint.Guideline
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:id="@+id/guideline"
android:orientation="horizontal"
app:layout_constraintGuide_percent="0.38"/>
<RelativeLayout
android:id="@+id/top_view"
<android.support.constraint.Guideline
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:id="@+id/guideline2"
android:orientation="vertical"
app:layout_constraintGuide_percent="0.38"/>
<ImageView
android:id="@+id/backdrop"
android:layout_width="0dp"
android:layout_height="0dp"
android:alpha="0.2"
tools:background="@color/material_grey_700"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toTopOf="@+id/guideline"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"/>
<ImageView
android:id="@+id/manga_cover"
android:layout_width="0dp"
android:layout_height="0dp"
android:contentDescription="@string/description_cover"
tools:background="@color/material_grey_700"
android:layout_marginTop="16dp"
android:layout_marginBottom="16dp"
android:layout_marginLeft="16dp"
android:layout_marginRight="16dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toTopOf="@+id/guideline"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toLeftOf="@+id/guideline2"/>
<android.support.design.widget.FloatingActionButton
android:id="@+id/fab_favorite"
style="@style/Theme.Widget.FAB"
app:srcCompat="@drawable/ic_bookmark_border_white_24dp"
android:layout_marginTop="0dp"
android:layout_marginBottom="0dp"
android:layout_marginRight="8dp"
app:layout_constraintTop_toBottomOf="@+id/guideline"
app:layout_constraintBottom_toTopOf="@+id/guideline"
app:layout_constraintRight_toRightOf="parent"/>
<android.support.v4.widget.NestedScrollView
android:id="@+id/info_scrollview"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginTop="16dp"
android:layout_marginBottom="16dp"
android:layout_marginLeft="0dp"
android:layout_marginRight="16dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toTopOf="@+id/guideline"
app:layout_constraintLeft_toLeftOf="@+id/guideline2"
app:layout_constraintRight_toRightOf="parent">
<android.support.constraint.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="0.4">
android:layout_height="match_parent">
<ImageView
android:id="@+id/backdrop"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:alpha="0.2"
android:contentDescription="@string/description_backdrop"/>
<TextView
android:id="@+id/manga_author_label"
style="@style/TextAppearance.Medium.Body2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/manga_info_author_label"
android:textIsSelectable="false"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintLeft_toLeftOf="parent"/>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal">
<TextView
android:id="@+id/manga_author"
style="@style/TextAppearance.Regular.Body1.Secondary"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginLeft="8dp"
android:ellipsize="end"
android:maxLines="1"
android:textIsSelectable="false"
app:layout_constraintBaseline_toBaselineOf="@+id/manga_author_label"
app:layout_constraintLeft_toRightOf="@+id/manga_author_label"
app:layout_constraintRight_toRightOf="parent"/>
<ImageView
android:id="@+id/manga_cover"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_margin="@dimen/activity_vertical_margin"
android:layout_weight="0.35"
android:contentDescription="@string/description_cover"/>
<TextView
android:id="@+id/manga_artist_label"
style="@style/TextAppearance.Medium.Body2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/manga_info_artist_label"
android:textIsSelectable="false"
app:layout_constraintTop_toBottomOf="@+id/manga_author_label"
app:layout_constraintLeft_toLeftOf="parent"/>
<RelativeLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_margin="@dimen/activity_vertical_margin"
android:layout_weight="0.65">
<TextView
android:id="@+id/manga_artist"
style="@style/TextAppearance.Regular.Body1.Secondary"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginLeft="8dp"
android:ellipsize="end"
android:maxLines="1"
android:textIsSelectable="false"
app:layout_constraintBaseline_toBaselineOf="@+id/manga_artist_label"
app:layout_constraintLeft_toRightOf="@+id/manga_artist_label"
app:layout_constraintRight_toRightOf="parent"/>
<TextView
android:id="@+id/manga_chapters_label"
style="@style/TextAppearance.Medium.Body2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/manga_info_chapters_label"
android:textIsSelectable="false"
app:layout_constraintTop_toBottomOf="@+id/manga_artist_label"
app:layout_constraintLeft_toLeftOf="parent"/>
<android.support.v4.widget.NestedScrollView
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:id="@+id/manga_chapters"
style="@style/TextAppearance.Regular.Body1.Secondary"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginLeft="8dp"
android:ellipsize="end"
android:maxLines="1"
android:textIsSelectable="false"
app:layout_constraintBaseline_toBaselineOf="@+id/manga_chapters_label"
app:layout_constraintLeft_toRightOf="@+id/manga_chapters_label"
app:layout_constraintRight_toRightOf="parent"/>
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:id="@+id/manga_status_label"
style="@style/TextAppearance.Medium.Body2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/manga_info_status_label"
android:textIsSelectable="false"
app:layout_constraintTop_toBottomOf="@+id/manga_chapters_label"
app:layout_constraintLeft_toLeftOf="parent"/>
<LinearLayout
android:id="@+id/manga_author_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:id="@+id/manga_status"
style="@style/TextAppearance.Regular.Body1.Secondary"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginLeft="8dp"
android:ellipsize="end"
android:maxLines="1"
android:textIsSelectable="false"
app:layout_constraintBaseline_toBaselineOf="@+id/manga_status_label"
app:layout_constraintLeft_toRightOf="@+id/manga_status_label"
app:layout_constraintRight_toRightOf="parent"/>
<TextView
android:id="@+id/manga_author_label"
style="@style/TextAppearance.Medium.Body2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:paddingRight="10dp"
android:singleLine="true"
android:text="@string/manga_info_author_label"
android:textIsSelectable="false"
/>
<TextView
android:id="@+id/manga_source_label"
style="@style/TextAppearance.Medium.Body2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/manga_info_source_label"
android:textIsSelectable="false"
app:layout_constraintTop_toBottomOf="@+id/manga_status_label"
app:layout_constraintLeft_toLeftOf="parent"/>
<TextView
android:id="@+id/manga_author"
style="@style/TextAppearance.Regular.Body1.Secondary"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:singleLine="true"
android:textIsSelectable="false"
/>
</LinearLayout>
<TextView
android:id="@+id/manga_source"
style="@style/TextAppearance.Regular.Body1.Secondary"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginLeft="8dp"
android:ellipsize="end"
android:maxLines="1"
android:textIsSelectable="false"
app:layout_constraintBaseline_toBaselineOf="@+id/manga_source_label"
app:layout_constraintLeft_toRightOf="@+id/manga_source_label"
app:layout_constraintRight_toRightOf="parent"/>
<LinearLayout
android:id="@+id/manga_artist_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@+id/manga_author_view"
android:orientation="horizontal">
<TextView
android:id="@+id/manga_genres_label"
style="@style/TextAppearance.Medium.Body2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/manga_info_genres_label"
android:textIsSelectable="false"
app:layout_constraintTop_toBottomOf="@+id/manga_source_label"
app:layout_constraintLeft_toLeftOf="parent"/>
<TextView
android:id="@+id/manga_artist_label"
style="@style/TextAppearance.Medium.Body2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:paddingRight="10dp"
android:singleLine="true"
android:text="@string/manga_info_artist_label"
android:textIsSelectable="false"
/>
<TextView
android:id="@+id/manga_genres"
style="@style/TextAppearance.Regular.Body1.Secondary"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:textIsSelectable="false"
app:layout_constraintTop_toBottomOf="@+id/manga_genres_label"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"/>
<TextView
android:id="@+id/manga_artist"
style="@style/TextAppearance.Regular.Body1.Secondary"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:singleLine="true"
android:textIsSelectable="false"
/>
</LinearLayout>
</android.support.constraint.ConstraintLayout>
<LinearLayout
android:id="@+id/manga_chapters_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@+id/manga_artist_view"
android:orientation="horizontal">
</android.support.v4.widget.NestedScrollView>
<TextView
android:id="@+id/manga_chapters_label"
style="@style/TextAppearance.Medium.Body2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:paddingRight="10dp"
android:singleLine="true"
android:text="@string/manga_info_chapters_label"
android:textIsSelectable="false"
/>
<android.support.v4.widget.NestedScrollView
android:id="@+id/description_scrollview"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginBottom="16dp"
android:layout_marginEnd="8dp"
android:layout_marginLeft="16dp"
android:layout_marginRight="16dp"
android:layout_marginStart="8dp"
android:layout_marginTop="16dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="@+id/guideline">
<TextView
android:id="@+id/manga_chapters"
style="@style/TextAppearance.Regular.Body1.Secondary"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:singleLine="true"
android:textIsSelectable="false"
/>
</LinearLayout>
<LinearLayout
android:id="@+id/manga_status_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@+id/manga_chapters_view"
android:orientation="horizontal">
<TextView
android:id="@+id/manga_status_label"
style="@style/TextAppearance.Medium.Body2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:paddingRight="10dp"
android:singleLine="true"
android:text="@string/manga_info_status_label"
android:textIsSelectable="false"
/>
<TextView
android:id="@+id/manga_status"
style="@style/TextAppearance.Regular.Body1.Secondary"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:singleLine="true"
android:textIsSelectable="false"
/>
</LinearLayout>
<LinearLayout
android:id="@+id/manga_source_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@+id/manga_status_view"
android:orientation="horizontal">
<TextView
android:id="@+id/manga_source_label"
style="@style/TextAppearance.Medium.Body2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:paddingRight="10dp"
android:singleLine="true"
android:text="@string/manga_info_source_label"
android:textIsSelectable="false"
/>
<TextView
android:id="@+id/manga_source"
style="@style/TextAppearance.Regular.Body1.Secondary"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:singleLine="true"
android:textIsSelectable="false"
/>
</LinearLayout>
<LinearLayout
android:id="@+id/manga_genres_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@+id/manga_source_view"
android:orientation="vertical">
<TextView
android:id="@+id/manga_genres_label"
style="@style/TextAppearance.Medium.Body2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:paddingRight="10dp"
android:singleLine="true"
android:text="@string/manga_info_genres_label"
android:textIsSelectable="false"
/>
<TextView
android:id="@+id/manga_genres"
style="@style/TextAppearance.Regular.Body1.Secondary"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:singleLine="false"
android:textIsSelectable="false"
/>
</LinearLayout>
</RelativeLayout>
</android.support.v4.widget.NestedScrollView>
</RelativeLayout>
</LinearLayout>
</RelativeLayout>
<android.support.v4.widget.NestedScrollView
android:id="@+id/bottom_view"
<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_margin="@dimen/activity_vertical_margin"
android:layout_weight="0.6">
android:layout_height="wrap_content"
android:orientation="vertical">
<LinearLayout
<TextView
android:id="@+id/manga_summary_label"
style="@style/TextAppearance.Medium.Body2"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
android:text="@string/description"
android:textIsSelectable="false"/>
<TextView
android:id="@+id/manga_summary_label"
style="@style/TextAppearance.Medium.Body2"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:paddingRight="10dp"
android:singleLine="true"
android:text="@string/description"
android:textIsSelectable="false"/>
<TextView
android:id="@+id/manga_summary"
style="@style/TextAppearance.Regular.Body1.Secondary"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textIsSelectable="false"/>
<TextView
android:id="@+id/manga_summary"
style="@style/TextAppearance.Regular.Body1.Secondary"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:singleLine="false"
android:textIsSelectable="false"
/>
</LinearLayout>
</LinearLayout>
</android.support.v4.widget.NestedScrollView>
</android.support.v4.widget.NestedScrollView>
</android.support.constraint.ConstraintLayout>
</LinearLayout>
</android.support.v4.widget.SwipeRefreshLayout>
<android.support.design.widget.FloatingActionButton
android:id="@+id/fab_favorite"
style="@style/Theme.Widget.FAB"
android:layout_gravity=""
app:layout_anchor="@id/top_view"
app:layout_anchorGravity="bottom|right|end"
app:layout_behavior=""
app:srcCompat="@drawable/ic_bookmark_border_white_24dp"/>
</android.support.design.widget.CoordinatorLayout>
</android.support.v4.widget.SwipeRefreshLayout>

View File

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

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