Compare commits
35 Commits
Author | SHA1 | Date | |
---|---|---|---|
91cb892c74 | |||
a26f908370 | |||
4d14f56fa8 | |||
d9a2255be9 | |||
5e3d71c6c5 | |||
619d94bf36 | |||
6069659e0f | |||
f6a79bde6f | |||
bb9e230b35 | |||
bc9417e16b | |||
a4313d388d | |||
4ebb3a894d | |||
0642889b64 | |||
3094d084d6 | |||
f9fec74ffd | |||
8ef3ab0d49 | |||
e9a6f8ef46 | |||
68724752f8 | |||
de8fa09366 | |||
e619870eec | |||
4be5f0dab3 | |||
abe1929b49 | |||
68c4116327 | |||
3be9881997 | |||
2e44f29882 | |||
a5520c1936 | |||
112cdd54e3 | |||
b512c67b5d | |||
d8fa7bc9d2 | |||
41397ab41d | |||
c437f1473c | |||
6020cd011d | |||
582bb3e2ca | |||
5c67161dce | |||
c00eaae62b |
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -19,6 +19,7 @@
|
||||
android:allowBackup="true"
|
||||
android:hardwareAccelerated="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:label="@string/app_name"
|
||||
android:largeHeap="true"
|
||||
android:theme="@style/Theme.Tachiyomi">
|
||||
@ -28,6 +29,8 @@
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.app.shortcuts"
|
||||
android:resource="@xml/shortcuts"/>
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".ui.manga.MangaActivity"
|
||||
@ -45,7 +48,7 @@
|
||||
android:label="@string/label_categories"
|
||||
android:parentActivityName=".ui.main.MainActivity" />
|
||||
<activity
|
||||
android:name=".ui.setting.SettingsDownloadsFragment$CustomLayoutPickerActivity"
|
||||
android:name=".widget.CustomLayoutPickerActivity"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/FilePickerTheme" />
|
||||
<activity
|
||||
@ -102,6 +105,14 @@
|
||||
android:name=".data.updater.UpdateDownloaderService"
|
||||
android:exported="false" />
|
||||
|
||||
<service
|
||||
android:name=".data.backup.BackupCreateService"
|
||||
android:exported="false"/>
|
||||
|
||||
<service
|
||||
android:name=".data.backup.BackupRestoreService"
|
||||
android:exported="false"/>
|
||||
|
||||
<meta-data
|
||||
android:name="eu.kanade.tachiyomi.data.glide.AppGlideModule"
|
||||
android:value="GlideModule" />
|
||||
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 22 KiB |
@ -5,6 +5,7 @@ import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.support.multidex.MultiDex
|
||||
import com.evernote.android.job.JobManager
|
||||
import eu.kanade.tachiyomi.data.backup.BackupCreatorJob
|
||||
import eu.kanade.tachiyomi.data.library.LibraryUpdateJob
|
||||
import eu.kanade.tachiyomi.data.updater.UpdateCheckerJob
|
||||
import eu.kanade.tachiyomi.util.LocaleHelper
|
||||
@ -58,6 +59,7 @@ open class App : Application() {
|
||||
when (tag) {
|
||||
LibraryUpdateJob.TAG -> LibraryUpdateJob()
|
||||
UpdateCheckerJob.TAG -> UpdateCheckerJob()
|
||||
BackupCreatorJob.TAG -> BackupCreatorJob()
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
@ -1,10 +1,10 @@
|
||||
package eu.kanade.tachiyomi
|
||||
|
||||
object Constants {
|
||||
const val NOTIFICATION_LIBRARY_ID = 1
|
||||
const val NOTIFICATION_UPDATER_ID = 2
|
||||
const val NOTIFICATION_DOWNLOAD_CHAPTER_ID = 3
|
||||
const val NOTIFICATION_DOWNLOAD_CHAPTER_ERROR_ID = 4
|
||||
const val NOTIFICATION_DOWNLOAD_IMAGE_ID = 5
|
||||
|
||||
const val NOTIFICATION_LIBRARY_PROGRESS_ID = 1
|
||||
const val NOTIFICATION_LIBRARY_RESULT_ID = 2
|
||||
const val NOTIFICATION_UPDATER_ID = 3
|
||||
const val NOTIFICATION_DOWNLOAD_CHAPTER_ID = 4
|
||||
const val NOTIFICATION_DOWNLOAD_CHAPTER_ERROR_ID = 5
|
||||
const val NOTIFICATION_DOWNLOAD_IMAGE_ID = 6
|
||||
}
|
||||
|
@ -0,0 +1,171 @@
|
||||
package eu.kanade.tachiyomi.data.backup
|
||||
|
||||
import android.app.IntentService
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import com.github.salomonbrys.kotson.set
|
||||
import com.google.gson.JsonArray
|
||||
import com.google.gson.JsonObject
|
||||
import com.hippo.unifile.UniFile
|
||||
import eu.kanade.tachiyomi.data.backup.models.Backup
|
||||
import eu.kanade.tachiyomi.data.backup.models.Backup.CATEGORIES
|
||||
import eu.kanade.tachiyomi.data.backup.models.Backup.MANGAS
|
||||
import eu.kanade.tachiyomi.data.backup.models.Backup.VERSION
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.ui.setting.SettingsBackupFragment
|
||||
import eu.kanade.tachiyomi.util.AndroidComponentUtil
|
||||
import eu.kanade.tachiyomi.util.sendLocalBroadcast
|
||||
import timber.log.Timber
|
||||
import eu.kanade.tachiyomi.BuildConfig.APPLICATION_ID as ID
|
||||
|
||||
/**
|
||||
* [IntentService] used to backup [Manga] information to [JsonArray]
|
||||
*/
|
||||
class BackupCreateService : IntentService(NAME) {
|
||||
|
||||
companion object {
|
||||
// Name of class
|
||||
private const val NAME = "BackupCreateService"
|
||||
|
||||
// Uri as string
|
||||
private const val EXTRA_URI = "$ID.$NAME.EXTRA_URI"
|
||||
// Backup called from job
|
||||
private const val EXTRA_IS_JOB = "$ID.$NAME.EXTRA_IS_JOB"
|
||||
// Options for backup
|
||||
private const val EXTRA_FLAGS = "$ID.$NAME.EXTRA_FLAGS"
|
||||
|
||||
// Filter options
|
||||
internal const val BACKUP_CATEGORY = 0x1
|
||||
internal const val BACKUP_CATEGORY_MASK = 0x1
|
||||
internal const val BACKUP_CHAPTER = 0x2
|
||||
internal const val BACKUP_CHAPTER_MASK = 0x2
|
||||
internal const val BACKUP_HISTORY = 0x4
|
||||
internal const val BACKUP_HISTORY_MASK = 0x4
|
||||
internal const val BACKUP_TRACK = 0x8
|
||||
internal const val BACKUP_TRACK_MASK = 0x8
|
||||
internal const val BACKUP_ALL = 0xF
|
||||
|
||||
/**
|
||||
* Make a backup from library
|
||||
*
|
||||
* @param context context of application
|
||||
* @param path path of Uri
|
||||
* @param flags determines what to backup
|
||||
* @param isJob backup called from job
|
||||
*/
|
||||
fun makeBackup(context: Context, path: String, flags: Int, isJob: Boolean = false) {
|
||||
val intent = Intent(context, BackupCreateService::class.java).apply {
|
||||
putExtra(EXTRA_URI, path)
|
||||
putExtra(EXTRA_IS_JOB, isJob)
|
||||
putExtra(EXTRA_FLAGS, flags)
|
||||
}
|
||||
context.startService(intent)
|
||||
}
|
||||
|
||||
fun isRunning(context: Context): Boolean {
|
||||
return AndroidComponentUtil.isServiceRunning(context, BackupCreateService::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
private val backupManager by lazy { BackupManager(this) }
|
||||
|
||||
override fun onHandleIntent(intent: Intent?) {
|
||||
if (intent == null) return
|
||||
|
||||
// Get values
|
||||
val uri = intent.getStringExtra(EXTRA_URI)
|
||||
val isJob = intent.getBooleanExtra(EXTRA_IS_JOB, false)
|
||||
val flags = intent.getIntExtra(EXTRA_FLAGS, 0)
|
||||
// Create backup
|
||||
createBackupFromApp(Uri.parse(uri), flags, isJob)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create backup Json file from database
|
||||
*
|
||||
* @param uri path of Uri
|
||||
* @param isJob backup called from job
|
||||
*/
|
||||
fun createBackupFromApp(uri: Uri, flags: Int, isJob: Boolean) {
|
||||
// Create root object
|
||||
val root = JsonObject()
|
||||
|
||||
// Create information object
|
||||
val information = JsonObject()
|
||||
|
||||
// Create manga array
|
||||
val mangaEntries = JsonArray()
|
||||
|
||||
// Create category array
|
||||
val categoryEntries = JsonArray()
|
||||
|
||||
// Add value's to root
|
||||
root[VERSION] = Backup.CURRENT_VERSION
|
||||
root[MANGAS] = mangaEntries
|
||||
root[CATEGORIES] = categoryEntries
|
||||
|
||||
backupManager.databaseHelper.inTransaction {
|
||||
// Get manga from database
|
||||
val mangas = backupManager.getFavoriteManga()
|
||||
|
||||
// Backup library manga and its dependencies
|
||||
mangas.forEach { manga ->
|
||||
mangaEntries.add(backupManager.backupMangaObject(manga, flags))
|
||||
}
|
||||
|
||||
// Backup categories
|
||||
if ((flags and BACKUP_CATEGORY_MASK) == BACKUP_CATEGORY) {
|
||||
backupManager.backupCategories(categoryEntries)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// When BackupCreatorJob
|
||||
if (isJob) {
|
||||
// Get dir of file
|
||||
val dir = UniFile.fromUri(this, uri)
|
||||
|
||||
// Delete older backups
|
||||
val numberOfBackups = backupManager.numberOfBackups()
|
||||
val backupRegex = Regex("""tachiyomi_\d+-\d+-\d+_\d+-\d+.json""")
|
||||
dir.listFiles { _, filename -> backupRegex.matches(filename) }
|
||||
.orEmpty()
|
||||
.sortedByDescending { it.name }
|
||||
.drop(numberOfBackups - 1)
|
||||
.forEach { it.delete() }
|
||||
|
||||
// Create new file to place backup
|
||||
val newFile = dir.createFile(Backup.getDefaultFilename())
|
||||
?: throw Exception("Couldn't create backup file")
|
||||
|
||||
newFile.openOutputStream().bufferedWriter().use {
|
||||
backupManager.parser.toJson(root, it)
|
||||
}
|
||||
} else {
|
||||
val file = UniFile.fromUri(this, uri)
|
||||
?: throw Exception("Couldn't create backup file")
|
||||
file.openOutputStream().bufferedWriter().use {
|
||||
backupManager.parser.toJson(root, it)
|
||||
}
|
||||
|
||||
// Show completed dialog
|
||||
val intent = Intent(SettingsBackupFragment.INTENT_FILTER).apply {
|
||||
putExtra(SettingsBackupFragment.ACTION, SettingsBackupFragment.ACTION_BACKUP_COMPLETED_DIALOG)
|
||||
putExtra(SettingsBackupFragment.EXTRA_URI, file.uri.toString())
|
||||
}
|
||||
sendLocalBroadcast(intent)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e)
|
||||
if (!isJob) {
|
||||
// Show error dialog
|
||||
val intent = Intent(SettingsBackupFragment.INTENT_FILTER).apply {
|
||||
putExtra(SettingsBackupFragment.ACTION, SettingsBackupFragment.ACTION_ERROR_BACKUP_DIALOG)
|
||||
putExtra(SettingsBackupFragment.EXTRA_ERROR_MESSAGE, e.message)
|
||||
}
|
||||
sendLocalBroadcast(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,41 @@
|
||||
package eu.kanade.tachiyomi.data.backup
|
||||
|
||||
import com.evernote.android.job.Job
|
||||
import com.evernote.android.job.JobManager
|
||||
import com.evernote.android.job.JobRequest
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
|
||||
class BackupCreatorJob : Job() {
|
||||
|
||||
override fun onRunJob(params: Params): Result {
|
||||
val preferences = Injekt.get<PreferencesHelper>()
|
||||
val path = preferences.backupsDirectory().getOrDefault()
|
||||
val flags = BackupCreateService.BACKUP_ALL
|
||||
BackupCreateService.makeBackup(context,path,flags,true)
|
||||
return Result.SUCCESS
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TAG = "BackupCreator"
|
||||
|
||||
fun setupTask(prefInterval: Int? = null) {
|
||||
val preferences = Injekt.get<PreferencesHelper>()
|
||||
val interval = prefInterval ?: preferences.backupInterval().getOrDefault()
|
||||
if (interval > 0) {
|
||||
JobRequest.Builder(TAG)
|
||||
.setPeriodic(interval * 60 * 60 * 1000L, 10 * 60 * 1000)
|
||||
.setPersisted(true)
|
||||
.setUpdateCurrent(true)
|
||||
.build()
|
||||
.schedule()
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelTask() {
|
||||
JobManager.instance().cancelAllForTag(TAG)
|
||||
}
|
||||
}
|
||||
}
|
@ -1,203 +1,213 @@
|
||||
package eu.kanade.tachiyomi.data.backup
|
||||
|
||||
import com.github.salomonbrys.kotson.fromJson
|
||||
import android.content.Context
|
||||
import com.github.salomonbrys.kotson.*
|
||||
import com.google.gson.*
|
||||
import com.google.gson.stream.JsonReader
|
||||
import eu.kanade.tachiyomi.data.backup.serializer.BooleanSerializer
|
||||
import eu.kanade.tachiyomi.data.backup.serializer.IdExclusion
|
||||
import eu.kanade.tachiyomi.data.backup.serializer.IntegerSerializer
|
||||
import eu.kanade.tachiyomi.data.backup.serializer.LongSerializer
|
||||
import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_CATEGORY
|
||||
import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_CATEGORY_MASK
|
||||
import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_CHAPTER
|
||||
import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_CHAPTER_MASK
|
||||
import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_HISTORY
|
||||
import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_HISTORY_MASK
|
||||
import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_TRACK
|
||||
import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_TRACK_MASK
|
||||
import eu.kanade.tachiyomi.data.backup.models.Backup.CATEGORIES
|
||||
import eu.kanade.tachiyomi.data.backup.models.Backup.CHAPTERS
|
||||
import eu.kanade.tachiyomi.data.backup.models.Backup.CURRENT_VERSION
|
||||
import eu.kanade.tachiyomi.data.backup.models.Backup.HISTORY
|
||||
import eu.kanade.tachiyomi.data.backup.models.Backup.MANGA
|
||||
import eu.kanade.tachiyomi.data.backup.models.Backup.TRACK
|
||||
import eu.kanade.tachiyomi.data.backup.models.DHistory
|
||||
import eu.kanade.tachiyomi.data.backup.serializer.*
|
||||
import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
||||
import eu.kanade.tachiyomi.data.database.models.*
|
||||
import java.io.*
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.SourceManager
|
||||
import eu.kanade.tachiyomi.util.syncChaptersWithSource
|
||||
import rx.Observable
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* This class provides the necessary methods to create and restore backups for the data of the
|
||||
* application. The backup follows a JSON structure, with the following scheme:
|
||||
*
|
||||
* {
|
||||
* "mangas": [
|
||||
* {
|
||||
* "manga": {"id": 1, ...},
|
||||
* "chapters": [{"id": 1, ...}, {...}],
|
||||
* "sync": [{"id": 1, ...}, {...}],
|
||||
* "categories": ["cat1", "cat2", ...]
|
||||
* },
|
||||
* { ... }
|
||||
* ],
|
||||
* "categories": [
|
||||
* {"id": 1, ...},
|
||||
* {"id": 2, ...}
|
||||
* ]
|
||||
* }
|
||||
*
|
||||
* @param db the database helper.
|
||||
*/
|
||||
class BackupManager(private val db: DatabaseHelper) {
|
||||
|
||||
private val MANGA = "manga"
|
||||
private val MANGAS = "mangas"
|
||||
private val CHAPTERS = "chapters"
|
||||
private val TRACK = "sync"
|
||||
private val CATEGORIES = "categories"
|
||||
|
||||
@Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN")
|
||||
private val gson = GsonBuilder()
|
||||
.registerTypeAdapter(java.lang.Integer::class.java, IntegerSerializer())
|
||||
.registerTypeAdapter(java.lang.Boolean::class.java, BooleanSerializer())
|
||||
.registerTypeAdapter(java.lang.Long::class.java, LongSerializer())
|
||||
.setExclusionStrategies(IdExclusion())
|
||||
.create()
|
||||
class BackupManager(val context: Context, version: Int = CURRENT_VERSION) {
|
||||
|
||||
/**
|
||||
* Backups the data of the application to a file.
|
||||
*
|
||||
* @param file the file where the backup will be saved.
|
||||
* @throws IOException if there's any IO error.
|
||||
* Database.
|
||||
*/
|
||||
@Throws(IOException::class)
|
||||
fun backupToFile(file: File) {
|
||||
val root = backupToJson()
|
||||
internal val databaseHelper: DatabaseHelper by injectLazy()
|
||||
|
||||
FileWriter(file).use {
|
||||
gson.toJson(root, it)
|
||||
/**
|
||||
* Source manager.
|
||||
*/
|
||||
internal val sourceManager: SourceManager by injectLazy()
|
||||
|
||||
/**
|
||||
* Version of parser
|
||||
*/
|
||||
var version: Int = version
|
||||
private set
|
||||
|
||||
/**
|
||||
* Json Parser
|
||||
*/
|
||||
var parser: Gson = initParser()
|
||||
|
||||
/**
|
||||
* Preferences
|
||||
*/
|
||||
private val preferences: PreferencesHelper by injectLazy()
|
||||
|
||||
/**
|
||||
* Set version of parser
|
||||
*
|
||||
* @param version version of parser
|
||||
*/
|
||||
internal fun setVersion(version: Int) {
|
||||
this.version = version
|
||||
parser = initParser()
|
||||
}
|
||||
|
||||
private fun initParser(): Gson {
|
||||
return when (version) {
|
||||
1 -> GsonBuilder().create()
|
||||
2 -> GsonBuilder()
|
||||
.registerTypeAdapter<MangaImpl>(MangaTypeAdapter.build())
|
||||
.registerTypeHierarchyAdapter<ChapterImpl>(ChapterTypeAdapter.build())
|
||||
.registerTypeAdapter<CategoryImpl>(CategoryTypeAdapter.build())
|
||||
.registerTypeAdapter<DHistory>(HistoryTypeAdapter.build())
|
||||
.registerTypeHierarchyAdapter<TrackImpl>(TrackTypeAdapter.build())
|
||||
.create()
|
||||
else -> throw Exception("Json version unknown")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a JSON object containing the backup of the app's data.
|
||||
* Backup the categories of library
|
||||
*
|
||||
* @return the backup as a JSON object.
|
||||
* @param root root of categories json
|
||||
*/
|
||||
fun backupToJson(): JsonObject {
|
||||
val root = JsonObject()
|
||||
|
||||
// Backup library mangas and its dependencies
|
||||
val mangaEntries = JsonArray()
|
||||
root.add(MANGAS, mangaEntries)
|
||||
for (manga in db.getFavoriteMangas().executeAsBlocking()) {
|
||||
mangaEntries.add(backupManga(manga))
|
||||
}
|
||||
|
||||
// Backup categories
|
||||
val categoryEntries = JsonArray()
|
||||
root.add(CATEGORIES, categoryEntries)
|
||||
for (category in db.getCategories().executeAsBlocking()) {
|
||||
categoryEntries.add(backupCategory(category))
|
||||
}
|
||||
|
||||
return root
|
||||
internal fun backupCategories(root: JsonArray) {
|
||||
val categories = databaseHelper.getCategories().executeAsBlocking()
|
||||
categories.forEach { root.add(parser.toJsonTree(it)) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Backups a manga and its related data (chapters, categories this manga is in, sync...).
|
||||
* Convert a manga to Json
|
||||
*
|
||||
* @param manga the manga to backup.
|
||||
* @return a JSON object containing all the data of the manga.
|
||||
* @param manga manga that gets converted
|
||||
* @return [JsonElement] containing manga information
|
||||
*/
|
||||
private fun backupManga(manga: Manga): JsonObject {
|
||||
internal fun backupMangaObject(manga: Manga, options: Int): JsonElement {
|
||||
// Entry for this manga
|
||||
val entry = JsonObject()
|
||||
|
||||
// Backup manga fields
|
||||
entry.add(MANGA, gson.toJsonTree(manga))
|
||||
entry[MANGA] = parser.toJsonTree(manga)
|
||||
|
||||
// Backup all the chapters
|
||||
val chapters = db.getChapters(manga).executeAsBlocking()
|
||||
if (!chapters.isEmpty()) {
|
||||
entry.add(CHAPTERS, gson.toJsonTree(chapters))
|
||||
}
|
||||
|
||||
// Backup tracks
|
||||
val tracks = db.getTracks(manga).executeAsBlocking()
|
||||
if (!tracks.isEmpty()) {
|
||||
entry.add(TRACK, gson.toJsonTree(tracks))
|
||||
}
|
||||
|
||||
// Backup categories for this manga
|
||||
val categoriesForManga = db.getCategoriesForManga(manga).executeAsBlocking()
|
||||
if (!categoriesForManga.isEmpty()) {
|
||||
val categoriesNames = ArrayList<String>()
|
||||
for (category in categoriesForManga) {
|
||||
categoriesNames.add(category.name)
|
||||
// Check if user wants chapter information in backup
|
||||
if (options and BACKUP_CHAPTER_MASK == BACKUP_CHAPTER) {
|
||||
// Backup all the chapters
|
||||
val chapters = databaseHelper.getChapters(manga).executeAsBlocking()
|
||||
if (!chapters.isEmpty()) {
|
||||
val chaptersJson = parser.toJsonTree(chapters)
|
||||
if (chaptersJson.asJsonArray.size() > 0) {
|
||||
entry[CHAPTERS] = chaptersJson
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if user wants category information in backup
|
||||
if (options and BACKUP_CATEGORY_MASK == BACKUP_CATEGORY) {
|
||||
// Backup categories for this manga
|
||||
val categoriesForManga = databaseHelper.getCategoriesForManga(manga).executeAsBlocking()
|
||||
if (!categoriesForManga.isEmpty()) {
|
||||
val categoriesNames = categoriesForManga.map { it.name }
|
||||
entry[CATEGORIES] = parser.toJsonTree(categoriesNames)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if user wants track information in backup
|
||||
if (options and BACKUP_TRACK_MASK == BACKUP_TRACK) {
|
||||
val tracks = databaseHelper.getTracks(manga).executeAsBlocking()
|
||||
if (!tracks.isEmpty()) {
|
||||
entry[TRACK] = parser.toJsonTree(tracks)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if user wants history information in backup
|
||||
if (options and BACKUP_HISTORY_MASK == BACKUP_HISTORY) {
|
||||
val historyForManga = databaseHelper.getHistoryByMangaId(manga.id!!).executeAsBlocking()
|
||||
if (!historyForManga.isEmpty()) {
|
||||
val historyData = historyForManga.mapNotNull { history ->
|
||||
val url = databaseHelper.getChapter(history.chapter_id).executeAsBlocking()?.url
|
||||
url?.let { DHistory(url, history.last_read) }
|
||||
}
|
||||
val historyJson = parser.toJsonTree(historyData)
|
||||
if (historyJson.asJsonArray.size() > 0) {
|
||||
entry[HISTORY] = historyJson
|
||||
}
|
||||
}
|
||||
entry.add(CATEGORIES, gson.toJsonTree(categoriesNames))
|
||||
}
|
||||
|
||||
return entry
|
||||
}
|
||||
|
||||
/**
|
||||
* Backups a category.
|
||||
*
|
||||
* @param category the category to backup.
|
||||
* @return a JSON object containing the data of the category.
|
||||
*/
|
||||
private fun backupCategory(category: Category): JsonElement {
|
||||
return gson.toJsonTree(category)
|
||||
fun restoreMangaNoFetch(manga: Manga, dbManga: Manga) {
|
||||
manga.id = dbManga.id
|
||||
manga.copyFrom(dbManga)
|
||||
manga.favorite = true
|
||||
insertManga(manga)
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores a backup from a file.
|
||||
* [Observable] that fetches manga information
|
||||
*
|
||||
* @param file the file containing the backup.
|
||||
* @throws IOException if there's any IO error.
|
||||
* @param source source of manga
|
||||
* @param manga manga that needs updating
|
||||
* @return [Observable] that contains manga
|
||||
*/
|
||||
@Throws(IOException::class)
|
||||
fun restoreFromFile(file: File) {
|
||||
JsonReader(FileReader(file)).use {
|
||||
val root = JsonParser().parse(it).asJsonObject
|
||||
restoreFromJson(root)
|
||||
}
|
||||
fun restoreMangaFetchObservable(source: Source, manga: Manga): Observable<Manga> {
|
||||
return source.fetchMangaDetails(manga)
|
||||
.map { networkManga ->
|
||||
manga.copyFrom(networkManga)
|
||||
manga.favorite = true
|
||||
manga.initialized = true
|
||||
manga.id = insertManga(manga)
|
||||
manga
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores a backup from an input stream.
|
||||
* [Observable] that fetches chapter information
|
||||
*
|
||||
* @param stream the stream containing the backup.
|
||||
* @throws IOException if there's any IO error.
|
||||
* @param source source of manga
|
||||
* @param manga manga that needs updating
|
||||
* @return [Observable] that contains manga
|
||||
*/
|
||||
@Throws(IOException::class)
|
||||
fun restoreFromStream(stream: InputStream) {
|
||||
JsonReader(InputStreamReader(stream)).use {
|
||||
val root = JsonParser().parse(it).asJsonObject
|
||||
restoreFromJson(root)
|
||||
}
|
||||
fun restoreChapterFetchObservable(source: Source, manga: Manga, chapters: List<Chapter>): Observable<Pair<List<Chapter>, List<Chapter>>> {
|
||||
return source.fetchChapterList(manga)
|
||||
.map { syncChaptersWithSource(databaseHelper, it, manga, source) }
|
||||
.doOnNext {
|
||||
if (it.first.isNotEmpty()) {
|
||||
chapters.forEach { it.manga_id = manga.id }
|
||||
insertChapters(chapters)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores a backup from a JSON object. Everything executes in a single transaction so that
|
||||
* nothing is modified if there's an error.
|
||||
* Restore the categories from Json
|
||||
*
|
||||
* @param root the root of the JSON.
|
||||
* @param jsonCategories array containing categories
|
||||
*/
|
||||
fun restoreFromJson(root: JsonObject) {
|
||||
db.inTransaction {
|
||||
// Restore categories
|
||||
root.get(CATEGORIES)?.let {
|
||||
restoreCategories(it.asJsonArray)
|
||||
}
|
||||
|
||||
// Restore mangas
|
||||
root.get(MANGAS)?.let {
|
||||
restoreMangas(it.asJsonArray)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores the categories.
|
||||
*
|
||||
* @param jsonCategories the categories of the json.
|
||||
*/
|
||||
private fun restoreCategories(jsonCategories: JsonArray) {
|
||||
internal fun restoreCategories(jsonCategories: JsonArray) {
|
||||
// Get categories from file and from db
|
||||
val dbCategories = db.getCategories().executeAsBlocking()
|
||||
val backupCategories = gson.fromJson<List<CategoryImpl>>(jsonCategories)
|
||||
val dbCategories = databaseHelper.getCategories().executeAsBlocking()
|
||||
val backupCategories = parser.fromJson<List<CategoryImpl>>(jsonCategories)
|
||||
|
||||
// Iterate over them
|
||||
for (category in backupCategories) {
|
||||
backupCategories.forEach { category ->
|
||||
// Used to know if the category is already in the db
|
||||
var found = false
|
||||
for (dbCategory in dbCategories) {
|
||||
@ -214,102 +224,20 @@ class BackupManager(private val db: DatabaseHelper) {
|
||||
if (!found) {
|
||||
// Let the db assign the id
|
||||
category.id = null
|
||||
val result = db.insertCategory(category).executeAsBlocking()
|
||||
val result = databaseHelper.insertCategory(category).executeAsBlocking()
|
||||
category.id = result.insertedId()?.toInt()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores all the mangas and its related data.
|
||||
*
|
||||
* @param jsonMangas the mangas and its related data (chapters, sync, categories) from the json.
|
||||
*/
|
||||
private fun restoreMangas(jsonMangas: JsonArray) {
|
||||
for (backupManga in jsonMangas) {
|
||||
// Map every entry to objects
|
||||
val element = backupManga.asJsonObject
|
||||
val manga = gson.fromJson(element.get(MANGA), MangaImpl::class.java)
|
||||
val chapters = gson.fromJson<List<ChapterImpl>>(element.get(CHAPTERS) ?: JsonArray())
|
||||
val tracks = gson.fromJson<List<TrackImpl>>(element.get(TRACK) ?: JsonArray())
|
||||
val categories = gson.fromJson<List<String>>(element.get(CATEGORIES) ?: JsonArray())
|
||||
|
||||
// Restore everything related to this manga
|
||||
restoreManga(manga)
|
||||
restoreChaptersForManga(manga, chapters)
|
||||
restoreSyncForManga(manga, tracks)
|
||||
restoreCategoriesForManga(manga, categories)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores a manga.
|
||||
*
|
||||
* @param manga the manga to restore.
|
||||
*/
|
||||
private fun restoreManga(manga: Manga) {
|
||||
// Try to find existing manga in db
|
||||
val dbManga = db.getManga(manga.url, manga.source).executeAsBlocking()
|
||||
if (dbManga == null) {
|
||||
// Let the db assign the id
|
||||
manga.id = null
|
||||
val result = db.insertManga(manga).executeAsBlocking()
|
||||
manga.id = result.insertedId()
|
||||
} else {
|
||||
// If it exists already, we copy only the values related to the source from the db
|
||||
// (they can be up to date). Local values (flags) are kept from the backup.
|
||||
manga.id = dbManga.id
|
||||
manga.copyFrom(dbManga)
|
||||
manga.favorite = true
|
||||
db.insertManga(manga).executeAsBlocking()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores the chapters of a manga.
|
||||
*
|
||||
* @param manga the manga whose chapters have to be restored.
|
||||
* @param chapters the chapters to restore.
|
||||
*/
|
||||
private fun restoreChaptersForManga(manga: Manga, chapters: List<Chapter>) {
|
||||
// Fix foreign keys with the current manga id
|
||||
for (chapter in chapters) {
|
||||
chapter.manga_id = manga.id
|
||||
}
|
||||
|
||||
val dbChapters = db.getChapters(manga).executeAsBlocking()
|
||||
val chaptersToUpdate = ArrayList<Chapter>()
|
||||
for (backupChapter in chapters) {
|
||||
// Try to find existing chapter in db
|
||||
val pos = dbChapters.indexOf(backupChapter)
|
||||
if (pos != -1) {
|
||||
// The chapter is already in the db, only update its fields
|
||||
val dbChapter = dbChapters[pos]
|
||||
// If one of them was read, the chapter will be marked as read
|
||||
dbChapter.read = backupChapter.read || dbChapter.read
|
||||
dbChapter.last_page_read = Math.max(backupChapter.last_page_read, dbChapter.last_page_read)
|
||||
chaptersToUpdate.add(dbChapter)
|
||||
} else {
|
||||
// Insert new chapter. Let the db assign the id
|
||||
backupChapter.id = null
|
||||
chaptersToUpdate.add(backupChapter)
|
||||
}
|
||||
}
|
||||
|
||||
// Update database
|
||||
if (!chaptersToUpdate.isEmpty()) {
|
||||
db.insertChapters(chaptersToUpdate).executeAsBlocking()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores the categories a manga is in.
|
||||
*
|
||||
* @param manga the manga whose categories have to be restored.
|
||||
* @param categories the categories to restore.
|
||||
*/
|
||||
private fun restoreCategoriesForManga(manga: Manga, categories: List<String>) {
|
||||
val dbCategories = db.getCategories().executeAsBlocking()
|
||||
internal fun restoreCategoriesForManga(manga: Manga, categories: List<String>) {
|
||||
val dbCategories = databaseHelper.getCategories().executeAsBlocking()
|
||||
val mangaCategoriesToUpdate = ArrayList<MangaCategory>()
|
||||
for (backupCategoryStr in categories) {
|
||||
for (dbCategory in dbCategories) {
|
||||
@ -324,45 +252,151 @@ class BackupManager(private val db: DatabaseHelper) {
|
||||
if (!mangaCategoriesToUpdate.isEmpty()) {
|
||||
val mangaAsList = ArrayList<Manga>()
|
||||
mangaAsList.add(manga)
|
||||
db.deleteOldMangasCategories(mangaAsList).executeAsBlocking()
|
||||
db.insertMangasCategories(mangaCategoriesToUpdate).executeAsBlocking()
|
||||
databaseHelper.deleteOldMangasCategories(mangaAsList).executeAsBlocking()
|
||||
databaseHelper.insertMangasCategories(mangaCategoriesToUpdate).executeAsBlocking()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore history from Json
|
||||
*
|
||||
* @param history list containing history to be restored
|
||||
*/
|
||||
internal fun restoreHistoryForManga(history: List<DHistory>) {
|
||||
// List containing history to be updated
|
||||
val historyToBeUpdated = ArrayList<History>()
|
||||
for ((url, lastRead) in history) {
|
||||
val dbHistory = databaseHelper.getHistoryByChapterUrl(url).executeAsBlocking()
|
||||
// Check if history already in database and update
|
||||
if (dbHistory != null) {
|
||||
dbHistory.apply {
|
||||
last_read = Math.max(lastRead, dbHistory.last_read)
|
||||
}
|
||||
historyToBeUpdated.add(dbHistory)
|
||||
} else {
|
||||
// If not in database create
|
||||
databaseHelper.getChapter(url).executeAsBlocking()?.let {
|
||||
val historyToAdd = History.create(it).apply {
|
||||
last_read = lastRead
|
||||
}
|
||||
historyToBeUpdated.add(historyToAdd)
|
||||
}
|
||||
}
|
||||
}
|
||||
databaseHelper.updateHistoryLastRead(historyToBeUpdated).executeAsBlocking()
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores the sync of a manga.
|
||||
*
|
||||
* @param manga the manga whose sync have to be restored.
|
||||
* @param tracks the track list to restore.
|
||||
*/
|
||||
private fun restoreSyncForManga(manga: Manga, tracks: List<Track>) {
|
||||
internal fun restoreTrackForManga(manga: Manga, tracks: List<Track>) {
|
||||
// Fix foreign keys with the current manga id
|
||||
for (track in tracks) {
|
||||
track.manga_id = manga.id!!
|
||||
}
|
||||
tracks.map { it.manga_id = manga.id!! }
|
||||
|
||||
val dbTracks = db.getTracks(manga).executeAsBlocking()
|
||||
// Get tracks from database
|
||||
val dbTracks = databaseHelper.getTracks(manga).executeAsBlocking()
|
||||
val trackToUpdate = ArrayList<Track>()
|
||||
for (backupTrack in tracks) {
|
||||
// Try to find existing chapter in db
|
||||
val pos = dbTracks.indexOf(backupTrack)
|
||||
if (pos != -1) {
|
||||
// The sync is already in the db, only update its fields
|
||||
val dbSync = dbTracks[pos]
|
||||
// Mark the max chapter as read and nothing else
|
||||
dbSync.last_chapter_read = Math.max(backupTrack.last_chapter_read, dbSync.last_chapter_read)
|
||||
trackToUpdate.add(dbSync)
|
||||
} else {
|
||||
|
||||
for (track in tracks) {
|
||||
var isInDatabase = false
|
||||
for (dbTrack in dbTracks) {
|
||||
if (track.sync_id == dbTrack.sync_id) {
|
||||
// The sync is already in the db, only update its fields
|
||||
if (track.remote_id != dbTrack.remote_id) {
|
||||
dbTrack.remote_id = track.remote_id
|
||||
}
|
||||
dbTrack.last_chapter_read = Math.max(dbTrack.last_chapter_read, track.last_chapter_read)
|
||||
isInDatabase = true
|
||||
trackToUpdate.add(dbTrack)
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!isInDatabase) {
|
||||
// Insert new sync. Let the db assign the id
|
||||
backupTrack.id = null
|
||||
trackToUpdate.add(backupTrack)
|
||||
track.id = null
|
||||
trackToUpdate.add(track)
|
||||
}
|
||||
}
|
||||
|
||||
// Update database
|
||||
if (!trackToUpdate.isEmpty()) {
|
||||
db.insertTracks(trackToUpdate).executeAsBlocking()
|
||||
databaseHelper.insertTracks(trackToUpdate).executeAsBlocking()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore the chapters for manga if chapters already in database
|
||||
*
|
||||
* @param manga manga of chapters
|
||||
* @param chapters list containing chapters that get restored
|
||||
* @return boolean answering if chapter fetch is not needed
|
||||
*/
|
||||
internal fun restoreChaptersForManga(manga: Manga, chapters: List<Chapter>): Boolean {
|
||||
val dbChapters = databaseHelper.getChapters(manga).executeAsBlocking()
|
||||
|
||||
// Return if fetch is needed
|
||||
if (dbChapters.isEmpty() || dbChapters.size < chapters.size)
|
||||
return false
|
||||
|
||||
for (chapter in chapters) {
|
||||
val pos = dbChapters.indexOf(chapter)
|
||||
if (pos != -1) {
|
||||
val dbChapter = dbChapters[pos]
|
||||
chapter.id = dbChapter.id
|
||||
chapter.copyFrom(dbChapter)
|
||||
break
|
||||
}
|
||||
}
|
||||
// Filter the chapters that couldn't be found.
|
||||
chapters.filter { it.id != null }
|
||||
chapters.map { it.manga_id = manga.id }
|
||||
|
||||
insertChapters(chapters)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns manga
|
||||
*
|
||||
* @return [Manga], null if not found
|
||||
*/
|
||||
internal fun getMangaFromDatabase(manga: Manga): Manga? {
|
||||
return databaseHelper.getManga(manga.url, manga.source).executeAsBlocking()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns list containing manga from library
|
||||
*
|
||||
* @return [Manga] from library
|
||||
*/
|
||||
internal fun getFavoriteManga(): List<Manga> {
|
||||
return databaseHelper.getFavoriteMangas().executeAsBlocking()
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts manga and returns id
|
||||
*
|
||||
* @return id of [Manga], null if not found
|
||||
*/
|
||||
internal fun insertManga(manga: Manga): Long? {
|
||||
return databaseHelper.insertManga(manga).executeAsBlocking().insertedId()
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts list of chapters
|
||||
*/
|
||||
internal fun insertChapters(chapters: List<Chapter>) {
|
||||
databaseHelper.updateChaptersBackup(chapters).executeAsBlocking()
|
||||
}
|
||||
|
||||
/**
|
||||
* Return number of backups.
|
||||
*
|
||||
* @return number of backups selected by user
|
||||
*/
|
||||
fun numberOfBackups(): Int {
|
||||
return preferences.numberOfBackups().getOrDefault()
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,414 @@
|
||||
package eu.kanade.tachiyomi.data.backup
|
||||
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.IBinder
|
||||
import android.os.PowerManager
|
||||
import com.github.salomonbrys.kotson.fromJson
|
||||
import com.google.gson.JsonArray
|
||||
import com.google.gson.JsonParser
|
||||
import com.google.gson.stream.JsonReader
|
||||
import com.hippo.unifile.UniFile
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.backup.models.Backup.CATEGORIES
|
||||
import eu.kanade.tachiyomi.data.backup.models.Backup.CHAPTERS
|
||||
import eu.kanade.tachiyomi.data.backup.models.Backup.HISTORY
|
||||
import eu.kanade.tachiyomi.data.backup.models.Backup.MANGA
|
||||
import eu.kanade.tachiyomi.data.backup.models.Backup.MANGAS
|
||||
import eu.kanade.tachiyomi.data.backup.models.Backup.TRACK
|
||||
import eu.kanade.tachiyomi.data.backup.models.Backup.VERSION
|
||||
import eu.kanade.tachiyomi.data.backup.models.DHistory
|
||||
import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
||||
import eu.kanade.tachiyomi.data.database.models.*
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.ui.setting.SettingsBackupFragment
|
||||
import eu.kanade.tachiyomi.util.AndroidComponentUtil
|
||||
import eu.kanade.tachiyomi.util.chop
|
||||
import eu.kanade.tachiyomi.util.sendLocalBroadcast
|
||||
import rx.Observable
|
||||
import rx.Subscription
|
||||
import rx.schedulers.Schedulers
|
||||
import timber.log.Timber
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.Executors
|
||||
import eu.kanade.tachiyomi.BuildConfig.APPLICATION_ID as ID
|
||||
|
||||
/**
|
||||
* Restores backup from json file
|
||||
*/
|
||||
class BackupRestoreService : Service() {
|
||||
|
||||
companion object {
|
||||
// Name of service
|
||||
private const val NAME = "BackupRestoreService"
|
||||
|
||||
// Uri as string
|
||||
private const val EXTRA_URI = "$ID.$NAME.EXTRA_URI"
|
||||
|
||||
/**
|
||||
* Returns the status of the service.
|
||||
*
|
||||
* @param context the application context.
|
||||
* @return true if the service is running, false otherwise.
|
||||
*/
|
||||
fun isRunning(context: Context): Boolean {
|
||||
return AndroidComponentUtil.isServiceRunning(context, BackupRestoreService::class.java)
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a service to restore a backup from Json
|
||||
*
|
||||
* @param context context of application
|
||||
* @param uri path of Uri
|
||||
*/
|
||||
fun start(context: Context, uri: String) {
|
||||
if (!isRunning(context)) {
|
||||
val intent = Intent(context, BackupRestoreService::class.java).apply {
|
||||
putExtra(EXTRA_URI, uri)
|
||||
}
|
||||
context.startService(intent)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the service.
|
||||
*
|
||||
* @param context the application context.
|
||||
*/
|
||||
fun stop(context: Context) {
|
||||
context.stopService(Intent(context, BackupRestoreService::class.java))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wake lock that will be held until the service is destroyed.
|
||||
*/
|
||||
private lateinit var wakeLock: PowerManager.WakeLock
|
||||
|
||||
/**
|
||||
* Subscription where the update is done.
|
||||
*/
|
||||
private var subscription: Subscription? = null
|
||||
|
||||
/**
|
||||
* The progress of a backup restore
|
||||
*/
|
||||
private var restoreProgress = 0
|
||||
|
||||
/**
|
||||
* Amount of manga in Json file (needed for restore)
|
||||
*/
|
||||
private var restoreAmount = 0
|
||||
|
||||
/**
|
||||
* List containing errors
|
||||
*/
|
||||
private val errors = mutableListOf<Pair<Date, String>>()
|
||||
|
||||
/**
|
||||
* Backup manager
|
||||
*/
|
||||
private lateinit var backupManager: BackupManager
|
||||
|
||||
/**
|
||||
* Database
|
||||
*/
|
||||
private val db: DatabaseHelper by injectLazy()
|
||||
|
||||
lateinit var executor: ExecutorService
|
||||
|
||||
/**
|
||||
* Method called when the service is created. It injects dependencies and acquire the wake lock.
|
||||
*/
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).newWakeLock(
|
||||
PowerManager.PARTIAL_WAKE_LOCK, "BackupRestoreService:WakeLock")
|
||||
wakeLock.acquire()
|
||||
executor = Executors.newSingleThreadExecutor()
|
||||
}
|
||||
|
||||
/**
|
||||
* Method called when the service is destroyed. It destroys the running subscription and
|
||||
* releases the wake lock.
|
||||
*/
|
||||
override fun onDestroy() {
|
||||
subscription?.unsubscribe()
|
||||
executor.shutdown() // must be called after unsubscribe
|
||||
if (wakeLock.isHeld) {
|
||||
wakeLock.release()
|
||||
}
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
/**
|
||||
* This method needs to be implemented, but it's not used/needed.
|
||||
*/
|
||||
override fun onBind(intent: Intent): IBinder? {
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Method called when the service receives an intent.
|
||||
*
|
||||
* @param intent the start intent from.
|
||||
* @param flags the flags of the command.
|
||||
* @param startId the start id of this command.
|
||||
* @return the start value of the command.
|
||||
*/
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (intent == null) return Service.START_NOT_STICKY
|
||||
|
||||
val file = UniFile.fromUri(this, Uri.parse(intent.getStringExtra(EXTRA_URI)))
|
||||
|
||||
// Unsubscribe from any previous subscription if needed.
|
||||
subscription?.unsubscribe()
|
||||
|
||||
subscription = Observable.using(
|
||||
{ db.lowLevel().beginTransaction() },
|
||||
{ getRestoreObservable(file).doOnNext{ db.lowLevel().setTransactionSuccessful() } },
|
||||
{ executor.execute { db.lowLevel().endTransaction() } })
|
||||
.doAfterTerminate { stopSelf(startId) }
|
||||
.subscribeOn(Schedulers.from(executor))
|
||||
.subscribe()
|
||||
|
||||
return Service.START_NOT_STICKY
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an [Observable] containing restore process.
|
||||
*
|
||||
* @param file restore file
|
||||
* @return [Observable<Manga>]
|
||||
*/
|
||||
private fun getRestoreObservable(file: UniFile): Observable<List<Manga>> {
|
||||
val startTime = System.currentTimeMillis()
|
||||
|
||||
val reader = JsonReader(file.openInputStream().bufferedReader())
|
||||
val json = JsonParser().parse(reader).asJsonObject
|
||||
|
||||
// Get parser version
|
||||
val version = json.get(VERSION)?.asInt ?: 1
|
||||
|
||||
// Initialize manager
|
||||
backupManager = BackupManager(this, version)
|
||||
|
||||
val mangasJson = json.get(MANGAS).asJsonArray
|
||||
|
||||
restoreAmount = mangasJson.size() + 1 // +1 for categories
|
||||
restoreProgress = 0
|
||||
errors.clear()
|
||||
|
||||
// Restore categories
|
||||
json.get(CATEGORIES)?.let {
|
||||
backupManager.restoreCategories(it.asJsonArray)
|
||||
restoreProgress += 1
|
||||
showRestoreProgress(restoreProgress, restoreAmount, "Categories added", errors.size)
|
||||
}
|
||||
|
||||
return Observable.from(mangasJson)
|
||||
.concatMap {
|
||||
val obj = it.asJsonObject
|
||||
val manga = backupManager.parser.fromJson<MangaImpl>(obj.get(MANGA))
|
||||
val chapters = backupManager.parser.fromJson<List<ChapterImpl>>(obj.get(CHAPTERS) ?: JsonArray())
|
||||
val categories = backupManager.parser.fromJson<List<String>>(obj.get(CATEGORIES) ?: JsonArray())
|
||||
val history = backupManager.parser.fromJson<List<DHistory>>(obj.get(HISTORY) ?: JsonArray())
|
||||
val tracks = backupManager.parser.fromJson<List<TrackImpl>>(obj.get(TRACK) ?: JsonArray())
|
||||
|
||||
val observable = getMangaRestoreObservable(manga, chapters, categories, history, tracks)
|
||||
if (observable != null) {
|
||||
observable
|
||||
} else {
|
||||
errors.add(Date() to "${manga.title} - ${getString(R.string.source_not_found)}")
|
||||
restoreProgress += 1
|
||||
val content = getString(R.string.dialog_restoring_source_not_found, manga.title.chop(15))
|
||||
showRestoreProgress(restoreProgress, restoreAmount, manga.title, errors.size, content)
|
||||
Observable.just(manga)
|
||||
}
|
||||
}
|
||||
.toList()
|
||||
.doOnNext {
|
||||
val endTime = System.currentTimeMillis()
|
||||
val time = endTime - startTime
|
||||
val logFile = writeErrorLog()
|
||||
val completeIntent = Intent(SettingsBackupFragment.INTENT_FILTER).apply {
|
||||
putExtra(SettingsBackupFragment.EXTRA_TIME, time)
|
||||
putExtra(SettingsBackupFragment.EXTRA_ERRORS, errors.size)
|
||||
putExtra(SettingsBackupFragment.EXTRA_ERROR_FILE_PATH, logFile.parent)
|
||||
putExtra(SettingsBackupFragment.EXTRA_ERROR_FILE, logFile.name)
|
||||
putExtra(SettingsBackupFragment.ACTION, SettingsBackupFragment.ACTION_RESTORE_COMPLETED_DIALOG)
|
||||
}
|
||||
sendLocalBroadcast(completeIntent)
|
||||
|
||||
}
|
||||
.doOnError { error ->
|
||||
Timber.e(error)
|
||||
writeErrorLog()
|
||||
val errorIntent = Intent(SettingsBackupFragment.INTENT_FILTER).apply {
|
||||
putExtra(SettingsBackupFragment.ACTION, SettingsBackupFragment.ACTION_ERROR_RESTORE_DIALOG)
|
||||
putExtra(SettingsBackupFragment.EXTRA_ERROR_MESSAGE, error.message)
|
||||
}
|
||||
sendLocalBroadcast(errorIntent)
|
||||
}
|
||||
.onErrorReturn { emptyList() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Write errors to error log
|
||||
*/
|
||||
private fun writeErrorLog(): File {
|
||||
try {
|
||||
if (errors.isNotEmpty()) {
|
||||
val destFile = File(externalCacheDir, "tachiyomi_restore.log")
|
||||
val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.getDefault())
|
||||
|
||||
destFile.bufferedWriter().use { out ->
|
||||
errors.forEach { (date, message) ->
|
||||
out.write("[${sdf.format(date)}] $message\n")
|
||||
}
|
||||
}
|
||||
return destFile
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Empty
|
||||
}
|
||||
return File("")
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a manga restore observable
|
||||
*
|
||||
* @param manga manga data from json
|
||||
* @param chapters chapters data from json
|
||||
* @param categories categories data from json
|
||||
* @param history history data from json
|
||||
* @param tracks tracking data from json
|
||||
* @return [Observable] containing manga restore information
|
||||
*/
|
||||
private fun getMangaRestoreObservable(manga: Manga, chapters: List<Chapter>,
|
||||
categories: List<String>, history: List<DHistory>,
|
||||
tracks: List<Track>): Observable<Manga>? {
|
||||
// Get source
|
||||
val source = backupManager.sourceManager.get(manga.source) ?: return null
|
||||
val dbManga = backupManager.getMangaFromDatabase(manga)
|
||||
|
||||
if (dbManga == null) {
|
||||
// Manga not in database
|
||||
return mangaFetchObservable(source, manga, chapters, categories, history, tracks)
|
||||
} else { // Manga in database
|
||||
// Copy information from manga already in database
|
||||
backupManager.restoreMangaNoFetch(manga, dbManga)
|
||||
// Fetch rest of manga information
|
||||
return mangaNoFetchObservable(source, manga, chapters, categories, history, tracks)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [Observable] that fetches manga information
|
||||
*
|
||||
* @param manga manga that needs updating
|
||||
* @param chapters chapters of manga that needs updating
|
||||
* @param categories categories that need updating
|
||||
*/
|
||||
private fun mangaFetchObservable(source: Source, manga: Manga, chapters: List<Chapter>,
|
||||
categories: List<String>, history: List<DHistory>,
|
||||
tracks: List<Track>): Observable<Manga> {
|
||||
return backupManager.restoreMangaFetchObservable(source, manga)
|
||||
.onErrorReturn {
|
||||
errors.add(Date() to "${manga.title} - ${it.message}")
|
||||
manga
|
||||
}
|
||||
.filter { it.id != null }
|
||||
.flatMap { manga ->
|
||||
chapterFetchObservable(source, manga, chapters)
|
||||
// Convert to the manga that contains new chapters.
|
||||
.map { manga }
|
||||
}
|
||||
.doOnNext {
|
||||
// Restore categories
|
||||
backupManager.restoreCategoriesForManga(it, categories)
|
||||
|
||||
// Restore history
|
||||
backupManager.restoreHistoryForManga(history)
|
||||
|
||||
// Restore tracking
|
||||
backupManager.restoreTrackForManga(it, tracks)
|
||||
}
|
||||
.doOnCompleted {
|
||||
restoreProgress += 1
|
||||
showRestoreProgress(restoreProgress, restoreAmount, manga.title, errors.size)
|
||||
}
|
||||
}
|
||||
|
||||
private fun mangaNoFetchObservable(source: Source, backupManga: Manga, chapters: List<Chapter>,
|
||||
categories: List<String>, history: List<DHistory>,
|
||||
tracks: List<Track>): Observable<Manga> {
|
||||
|
||||
return Observable.just(backupManga)
|
||||
.flatMap { manga ->
|
||||
if (!backupManager.restoreChaptersForManga(manga, chapters)) {
|
||||
chapterFetchObservable(source, manga, chapters)
|
||||
.map { manga }
|
||||
} else {
|
||||
Observable.just(manga)
|
||||
}
|
||||
}
|
||||
.doOnNext {
|
||||
// Restore categories
|
||||
backupManager.restoreCategoriesForManga(it, categories)
|
||||
|
||||
// Restore history
|
||||
backupManager.restoreHistoryForManga(history)
|
||||
|
||||
// Restore tracking
|
||||
backupManager.restoreTrackForManga(it, tracks)
|
||||
}
|
||||
.doOnCompleted {
|
||||
restoreProgress += 1
|
||||
showRestoreProgress(restoreProgress, restoreAmount, backupManga.title, errors.size)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [Observable] that fetches chapter information
|
||||
*
|
||||
* @param source source of manga
|
||||
* @param manga manga that needs updating
|
||||
* @return [Observable] that contains manga
|
||||
*/
|
||||
private fun chapterFetchObservable(source: Source, manga: Manga, chapters: List<Chapter>): Observable<Pair<List<Chapter>, List<Chapter>>> {
|
||||
return backupManager.restoreChapterFetchObservable(source, manga, chapters)
|
||||
// If there's any error, return empty update and continue.
|
||||
.onErrorReturn {
|
||||
errors.add(Date() to "${manga.title} - ${it.message}")
|
||||
Pair(emptyList<Chapter>(), emptyList<Chapter>())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Called to update dialog in [SettingsBackupFragment]
|
||||
*
|
||||
* @param progress restore progress
|
||||
* @param amount total restoreAmount of manga
|
||||
* @param title title of restored manga
|
||||
*/
|
||||
private fun showRestoreProgress(progress: Int, amount: Int, title: String, errors: Int,
|
||||
content: String = getString(R.string.dialog_restoring_backup, title.chop(15))) {
|
||||
val intent = Intent(SettingsBackupFragment.INTENT_FILTER).apply {
|
||||
putExtra(SettingsBackupFragment.EXTRA_PROGRESS, progress)
|
||||
putExtra(SettingsBackupFragment.EXTRA_AMOUNT, amount)
|
||||
putExtra(SettingsBackupFragment.EXTRA_CONTENT, content)
|
||||
putExtra(SettingsBackupFragment.EXTRA_ERRORS, errors)
|
||||
putExtra(SettingsBackupFragment.ACTION, SettingsBackupFragment.ACTION_SET_PROGRESS_DIALOG)
|
||||
}
|
||||
sendLocalBroadcast(intent)
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,23 @@
|
||||
package eu.kanade.tachiyomi.data.backup.models
|
||||
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* Json values
|
||||
*/
|
||||
object Backup {
|
||||
const val CURRENT_VERSION = 2
|
||||
const val MANGA = "manga"
|
||||
const val MANGAS = "mangas"
|
||||
const val TRACK = "track"
|
||||
const val CHAPTERS = "chapters"
|
||||
const val CATEGORIES = "categories"
|
||||
const val HISTORY = "history"
|
||||
const val VERSION = "version"
|
||||
|
||||
fun getDefaultFilename(): String {
|
||||
val date = SimpleDateFormat("yyyy-MM-dd_HH-mm", Locale.getDefault()).format(Date())
|
||||
return "tachiyomi_$date.json"
|
||||
}
|
||||
}
|
@ -0,0 +1,3 @@
|
||||
package eu.kanade.tachiyomi.data.backup.models
|
||||
|
||||
data class DHistory(val url: String,val lastRead: Long)
|
@ -1,16 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.backup.serializer
|
||||
|
||||
import com.google.gson.JsonElement
|
||||
import com.google.gson.JsonPrimitive
|
||||
import com.google.gson.JsonSerializationContext
|
||||
import com.google.gson.JsonSerializer
|
||||
import java.lang.reflect.Type
|
||||
|
||||
class BooleanSerializer : JsonSerializer<Boolean> {
|
||||
|
||||
override fun serialize(value: Boolean?, type: Type, context: JsonSerializationContext): JsonElement? {
|
||||
if (value != null && value != false)
|
||||
return JsonPrimitive(value)
|
||||
return null
|
||||
}
|
||||
}
|
@ -0,0 +1,31 @@
|
||||
package eu.kanade.tachiyomi.data.backup.serializer
|
||||
|
||||
import com.github.salomonbrys.kotson.typeAdapter
|
||||
import com.google.gson.TypeAdapter
|
||||
import eu.kanade.tachiyomi.data.database.models.CategoryImpl
|
||||
|
||||
/**
|
||||
* JSON Serializer used to write / read [CategoryImpl] to / from json
|
||||
*/
|
||||
object CategoryTypeAdapter {
|
||||
|
||||
fun build(): TypeAdapter<CategoryImpl> {
|
||||
return typeAdapter {
|
||||
write {
|
||||
beginArray()
|
||||
value(it.name)
|
||||
value(it.order)
|
||||
endArray()
|
||||
}
|
||||
|
||||
read {
|
||||
beginArray()
|
||||
val category = CategoryImpl()
|
||||
category.name = nextString()
|
||||
category.order = nextInt()
|
||||
endArray()
|
||||
category
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,61 @@
|
||||
package eu.kanade.tachiyomi.data.backup.serializer
|
||||
|
||||
import com.github.salomonbrys.kotson.typeAdapter
|
||||
import com.google.gson.TypeAdapter
|
||||
import com.google.gson.stream.JsonToken
|
||||
import eu.kanade.tachiyomi.data.database.models.ChapterImpl
|
||||
|
||||
/**
|
||||
* JSON Serializer used to write / read [ChapterImpl] to / from json
|
||||
*/
|
||||
object ChapterTypeAdapter {
|
||||
|
||||
private const val URL = "u"
|
||||
private const val READ = "r"
|
||||
private const val BOOKMARK = "b"
|
||||
private const val LAST_READ = "l"
|
||||
|
||||
fun build(): TypeAdapter<ChapterImpl> {
|
||||
return typeAdapter {
|
||||
write {
|
||||
if (it.read || it.bookmark || it.last_page_read != 0) {
|
||||
beginObject()
|
||||
name(URL)
|
||||
value(it.url)
|
||||
if (it.read) {
|
||||
name(READ)
|
||||
value(1)
|
||||
}
|
||||
if (it.bookmark) {
|
||||
name(BOOKMARK)
|
||||
value(1)
|
||||
}
|
||||
if (it.last_page_read != 0) {
|
||||
name(LAST_READ)
|
||||
value(it.last_page_read)
|
||||
}
|
||||
endObject()
|
||||
}
|
||||
}
|
||||
|
||||
read {
|
||||
val chapter = ChapterImpl()
|
||||
beginObject()
|
||||
while (hasNext()) {
|
||||
if (peek() == JsonToken.NAME) {
|
||||
val name = nextName()
|
||||
|
||||
when (name) {
|
||||
URL -> chapter.url = nextString()
|
||||
READ -> chapter.read = nextInt() == 1
|
||||
BOOKMARK -> chapter.bookmark = nextInt() == 1
|
||||
LAST_READ -> chapter.last_page_read = nextInt()
|
||||
}
|
||||
}
|
||||
}
|
||||
endObject()
|
||||
chapter
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,32 @@
|
||||
package eu.kanade.tachiyomi.data.backup.serializer
|
||||
|
||||
import com.github.salomonbrys.kotson.typeAdapter
|
||||
import com.google.gson.TypeAdapter
|
||||
import eu.kanade.tachiyomi.data.backup.models.DHistory
|
||||
|
||||
/**
|
||||
* JSON Serializer used to write / read [DHistory] to / from json
|
||||
*/
|
||||
object HistoryTypeAdapter {
|
||||
|
||||
fun build(): TypeAdapter<DHistory> {
|
||||
return typeAdapter {
|
||||
write {
|
||||
if (it.lastRead != 0L) {
|
||||
beginArray()
|
||||
value(it.url)
|
||||
value(it.lastRead)
|
||||
endArray()
|
||||
}
|
||||
}
|
||||
|
||||
read {
|
||||
beginArray()
|
||||
val url = nextString()
|
||||
val lastRead = nextLong()
|
||||
endArray()
|
||||
DHistory(url, lastRead)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,27 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.backup.serializer
|
||||
|
||||
import com.google.gson.ExclusionStrategy
|
||||
import com.google.gson.FieldAttributes
|
||||
import eu.kanade.tachiyomi.data.database.models.CategoryImpl
|
||||
import eu.kanade.tachiyomi.data.database.models.ChapterImpl
|
||||
import eu.kanade.tachiyomi.data.database.models.MangaImpl
|
||||
import eu.kanade.tachiyomi.data.database.models.TrackImpl
|
||||
|
||||
class IdExclusion : ExclusionStrategy {
|
||||
|
||||
private val categoryExclusions = listOf("id")
|
||||
private val mangaExclusions = listOf("id")
|
||||
private val chapterExclusions = listOf("id", "manga_id")
|
||||
private val syncExclusions = listOf("id", "manga_id", "update")
|
||||
|
||||
override fun shouldSkipField(f: FieldAttributes) = when (f.declaringClass) {
|
||||
MangaImpl::class.java -> mangaExclusions.contains(f.name)
|
||||
ChapterImpl::class.java -> chapterExclusions.contains(f.name)
|
||||
TrackImpl::class.java -> syncExclusions.contains(f.name)
|
||||
CategoryImpl::class.java -> categoryExclusions.contains(f.name)
|
||||
else -> false
|
||||
}
|
||||
|
||||
override fun shouldSkipClass(clazz: Class<*>) = false
|
||||
|
||||
}
|
@ -1,17 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.backup.serializer
|
||||
|
||||
import com.google.gson.JsonElement
|
||||
import com.google.gson.JsonPrimitive
|
||||
import com.google.gson.JsonSerializationContext
|
||||
import com.google.gson.JsonSerializer
|
||||
|
||||
import java.lang.reflect.Type
|
||||
|
||||
class IntegerSerializer : JsonSerializer<Int> {
|
||||
|
||||
override fun serialize(value: Int?, type: Type, context: JsonSerializationContext): JsonElement? {
|
||||
if (value != null && value !== 0)
|
||||
return JsonPrimitive(value)
|
||||
return null
|
||||
}
|
||||
}
|
@ -1,16 +0,0 @@
|
||||
package eu.kanade.tachiyomi.data.backup.serializer
|
||||
|
||||
import com.google.gson.JsonElement
|
||||
import com.google.gson.JsonPrimitive
|
||||
import com.google.gson.JsonSerializationContext
|
||||
import com.google.gson.JsonSerializer
|
||||
import java.lang.reflect.Type
|
||||
|
||||
class LongSerializer : JsonSerializer<Long> {
|
||||
|
||||
override fun serialize(value: Long?, type: Type, context: JsonSerializationContext): JsonElement? {
|
||||
if (value != null && value !== 0L)
|
||||
return JsonPrimitive(value)
|
||||
return null
|
||||
}
|
||||
}
|
@ -0,0 +1,37 @@
|
||||
package eu.kanade.tachiyomi.data.backup.serializer
|
||||
|
||||
import com.github.salomonbrys.kotson.typeAdapter
|
||||
import com.google.gson.TypeAdapter
|
||||
import eu.kanade.tachiyomi.data.database.models.MangaImpl
|
||||
|
||||
/**
|
||||
* JSON Serializer used to write / read [MangaImpl] to / from json
|
||||
*/
|
||||
object MangaTypeAdapter {
|
||||
|
||||
fun build(): TypeAdapter<MangaImpl> {
|
||||
return typeAdapter {
|
||||
write {
|
||||
beginArray()
|
||||
value(it.url)
|
||||
value(it.title)
|
||||
value(it.source)
|
||||
value(it.viewer)
|
||||
value(it.chapter_flags)
|
||||
endArray()
|
||||
}
|
||||
|
||||
read {
|
||||
beginArray()
|
||||
val manga = MangaImpl()
|
||||
manga.url = nextString()
|
||||
manga.title = nextString()
|
||||
manga.source = nextLong()
|
||||
manga.viewer = nextInt()
|
||||
manga.chapter_flags = nextInt()
|
||||
endArray()
|
||||
manga
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,53 @@
|
||||
package eu.kanade.tachiyomi.data.backup.serializer
|
||||
|
||||
import com.github.salomonbrys.kotson.typeAdapter
|
||||
import com.google.gson.TypeAdapter
|
||||
import com.google.gson.stream.JsonToken
|
||||
import eu.kanade.tachiyomi.data.database.models.TrackImpl
|
||||
|
||||
/**
|
||||
* JSON Serializer used to write / read [TrackImpl] to / from json
|
||||
*/
|
||||
object TrackTypeAdapter {
|
||||
|
||||
private const val SYNC = "s"
|
||||
private const val REMOTE = "r"
|
||||
private const val TITLE = "t"
|
||||
private const val LAST_READ = "l"
|
||||
|
||||
fun build(): TypeAdapter<TrackImpl> {
|
||||
return typeAdapter {
|
||||
write {
|
||||
beginObject()
|
||||
name(TITLE)
|
||||
value(it.title)
|
||||
name(SYNC)
|
||||
value(it.sync_id)
|
||||
name(REMOTE)
|
||||
value(it.remote_id)
|
||||
name(LAST_READ)
|
||||
value(it.last_chapter_read)
|
||||
endObject()
|
||||
}
|
||||
|
||||
read {
|
||||
val track = TrackImpl()
|
||||
beginObject()
|
||||
while (hasNext()) {
|
||||
if (peek() == JsonToken.NAME) {
|
||||
val name = nextName()
|
||||
|
||||
when (name) {
|
||||
TITLE -> track.title = nextString()
|
||||
SYNC -> track.sync_id = nextInt()
|
||||
REMOTE -> track.remote_id = nextInt()
|
||||
LAST_READ -> track.last_chapter_read = nextInt()
|
||||
}
|
||||
}
|
||||
}
|
||||
endObject()
|
||||
track
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -24,4 +24,6 @@ open class DatabaseHelper(context: Context)
|
||||
|
||||
inline fun inTransaction(block: () -> Unit) = db.inTransaction(block)
|
||||
|
||||
fun lowLevel() = db.lowLevel()
|
||||
|
||||
}
|
||||
|
@ -10,6 +10,7 @@ import com.pushtorefresh.storio.sqlite.queries.DeleteQuery
|
||||
import com.pushtorefresh.storio.sqlite.queries.InsertQuery
|
||||
import com.pushtorefresh.storio.sqlite.queries.UpdateQuery
|
||||
import eu.kanade.tachiyomi.data.database.models.History
|
||||
import eu.kanade.tachiyomi.data.database.models.HistoryImpl
|
||||
import eu.kanade.tachiyomi.data.database.tables.HistoryTable.COL_CHAPTER_ID
|
||||
import eu.kanade.tachiyomi.data.database.tables.HistoryTable.COL_ID
|
||||
import eu.kanade.tachiyomi.data.database.tables.HistoryTable.COL_LAST_READ
|
||||
@ -44,7 +45,7 @@ open class HistoryPutResolver : DefaultPutResolver<History>() {
|
||||
|
||||
class HistoryGetResolver : DefaultGetResolver<History>() {
|
||||
|
||||
override fun mapFromCursor(cursor: Cursor): History = History().apply {
|
||||
override fun mapFromCursor(cursor: Cursor): History = HistoryImpl().apply {
|
||||
id = cursor.getLong(cursor.getColumnIndex(COL_ID))
|
||||
chapter_id = cursor.getLong(cursor.getColumnIndex(COL_CHAPTER_ID))
|
||||
last_read = cursor.getLong(cursor.getColumnIndex(COL_LAST_READ))
|
||||
|
@ -5,27 +5,27 @@ import java.io.Serializable
|
||||
/**
|
||||
* Object containing the history statistics of a chapter
|
||||
*/
|
||||
class History : Serializable {
|
||||
interface History : Serializable {
|
||||
|
||||
/**
|
||||
* Id of history object.
|
||||
*/
|
||||
var id: Long? = null
|
||||
var id: Long?
|
||||
|
||||
/**
|
||||
* Chapter id of history object.
|
||||
*/
|
||||
var chapter_id: Long = 0
|
||||
var chapter_id: Long
|
||||
|
||||
/**
|
||||
* Last time chapter was read in time long format
|
||||
*/
|
||||
var last_read: Long = 0
|
||||
var last_read: Long
|
||||
|
||||
/**
|
||||
* Total time chapter was read - todo not yet implemented
|
||||
*/
|
||||
var time_read: Long = 0
|
||||
var time_read: Long
|
||||
|
||||
companion object {
|
||||
|
||||
@ -35,10 +35,8 @@ class History : Serializable {
|
||||
* @param chapter chapter object
|
||||
* @return history object
|
||||
*/
|
||||
fun create(chapter: Chapter): History {
|
||||
val history = History()
|
||||
history.chapter_id = chapter.id!!
|
||||
return history
|
||||
fun create(chapter: Chapter): History = HistoryImpl().apply {
|
||||
this.chapter_id = chapter.id!!
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,27 @@
|
||||
package eu.kanade.tachiyomi.data.database.models
|
||||
|
||||
/**
|
||||
* Object containing the history statistics of a chapter
|
||||
*/
|
||||
class HistoryImpl : History {
|
||||
|
||||
/**
|
||||
* Id of history object.
|
||||
*/
|
||||
override var id: Long? = null
|
||||
|
||||
/**
|
||||
* Chapter id of history object.
|
||||
*/
|
||||
override var chapter_id: Long = 0
|
||||
|
||||
/**
|
||||
* Last time chapter was read in time long format
|
||||
*/
|
||||
override var last_read: Long = 0
|
||||
|
||||
/**
|
||||
* Total time chapter was read - todo not yet implemented
|
||||
*/
|
||||
override var time_read: Long = 0
|
||||
}
|
@ -6,6 +6,7 @@ import eu.kanade.tachiyomi.data.database.DbProvider
|
||||
import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.database.models.MangaChapter
|
||||
import eu.kanade.tachiyomi.data.database.resolvers.ChapterBackupPutResolver
|
||||
import eu.kanade.tachiyomi.data.database.resolvers.ChapterProgressPutResolver
|
||||
import eu.kanade.tachiyomi.data.database.resolvers.ChapterSourceOrderPutResolver
|
||||
import eu.kanade.tachiyomi.data.database.resolvers.MangaChapterGetResolver
|
||||
@ -42,6 +43,16 @@ interface ChapterQueries : DbProvider {
|
||||
.build())
|
||||
.prepare()
|
||||
|
||||
fun getChapter(url: String) = db.get()
|
||||
.`object`(Chapter::class.java)
|
||||
.withQuery(Query.builder()
|
||||
.table(ChapterTable.TABLE)
|
||||
.where("${ChapterTable.COL_URL} = ?")
|
||||
.whereArgs(url)
|
||||
.build())
|
||||
.prepare()
|
||||
|
||||
|
||||
fun insertChapter(chapter: Chapter) = db.put().`object`(chapter).prepare()
|
||||
|
||||
fun insertChapters(chapters: List<Chapter>) = db.put().objects(chapters).prepare()
|
||||
@ -50,6 +61,11 @@ interface ChapterQueries : DbProvider {
|
||||
|
||||
fun deleteChapters(chapters: List<Chapter>) = db.delete().objects(chapters).prepare()
|
||||
|
||||
fun updateChaptersBackup(chapters: List<Chapter>) = db.put()
|
||||
.objects(chapters)
|
||||
.withPutResolver(ChapterBackupPutResolver())
|
||||
.prepare()
|
||||
|
||||
fun updateChapterProgress(chapter: Chapter) = db.put()
|
||||
.`object`(chapter)
|
||||
.withPutResolver(ChapterProgressPutResolver())
|
||||
|
@ -1,5 +1,6 @@
|
||||
package eu.kanade.tachiyomi.data.database.queries
|
||||
|
||||
import com.pushtorefresh.storio.sqlite.queries.DeleteQuery
|
||||
import com.pushtorefresh.storio.sqlite.queries.RawQuery
|
||||
import eu.kanade.tachiyomi.data.database.DbProvider
|
||||
import eu.kanade.tachiyomi.data.database.models.History
|
||||
@ -40,6 +41,15 @@ interface HistoryQueries : DbProvider {
|
||||
.build())
|
||||
.prepare()
|
||||
|
||||
fun getHistoryByChapterUrl(chapterUrl: String) = db.get()
|
||||
.`object`(History::class.java)
|
||||
.withQuery(RawQuery.builder()
|
||||
.query(getHistoryByChapterUrl())
|
||||
.args(chapterUrl)
|
||||
.observesTables(HistoryTable.TABLE)
|
||||
.build())
|
||||
.prepare()
|
||||
|
||||
/**
|
||||
* Updates the history last read.
|
||||
* Inserts history object if not yet in database
|
||||
@ -59,4 +69,18 @@ interface HistoryQueries : DbProvider {
|
||||
.objects(historyList)
|
||||
.withPutResolver(HistoryLastReadPutResolver())
|
||||
.prepare()
|
||||
|
||||
fun deleteHistory() = db.delete()
|
||||
.byQuery(DeleteQuery.builder()
|
||||
.table(HistoryTable.TABLE)
|
||||
.build())
|
||||
.prepare()
|
||||
|
||||
fun deleteHistoryNoLastRead() = db.delete()
|
||||
.byQuery(DeleteQuery.builder()
|
||||
.table(HistoryTable.TABLE)
|
||||
.where("${HistoryTable.COL_LAST_READ} = ?")
|
||||
.whereArgs(0)
|
||||
.build())
|
||||
.prepare()
|
||||
}
|
||||
|
@ -84,6 +84,12 @@ interface MangaQueries : DbProvider {
|
||||
.build())
|
||||
.prepare()
|
||||
|
||||
fun deleteMangas() = db.delete()
|
||||
.byQuery(DeleteQuery.builder()
|
||||
.table(MangaTable.TABLE)
|
||||
.build())
|
||||
.prepare()
|
||||
|
||||
fun getLastReadManga() = db.get()
|
||||
.listOfObjects(Manga::class.java)
|
||||
.withQuery(RawQuery.builder()
|
||||
|
@ -73,6 +73,14 @@ fun getHistoryByMangaId() = """
|
||||
WHERE ${Chapter.TABLE}.${Chapter.COL_MANGA_ID} = ? AND ${History.TABLE}.${History.COL_CHAPTER_ID} = ${Chapter.TABLE}.${Chapter.COL_ID}
|
||||
"""
|
||||
|
||||
fun getHistoryByChapterUrl() = """
|
||||
SELECT ${History.TABLE}.*
|
||||
FROM ${History.TABLE}
|
||||
JOIN ${Chapter.TABLE}
|
||||
ON ${History.TABLE}.${History.COL_CHAPTER_ID} = ${Chapter.TABLE}.${Chapter.COL_ID}
|
||||
WHERE ${Chapter.TABLE}.${Chapter.COL_URL} = ? AND ${History.TABLE}.${History.COL_CHAPTER_ID} = ${Chapter.TABLE}.${Chapter.COL_ID}
|
||||
"""
|
||||
|
||||
fun getLastReadMangaQuery() = """
|
||||
SELECT ${Manga.TABLE}.*, MAX(${History.TABLE}.${History.COL_LAST_READ}) AS max
|
||||
FROM ${Manga.TABLE}
|
||||
|
@ -0,0 +1,35 @@
|
||||
package eu.kanade.tachiyomi.data.database.resolvers
|
||||
|
||||
import android.content.ContentValues
|
||||
import com.pushtorefresh.storio.sqlite.StorIOSQLite
|
||||
import com.pushtorefresh.storio.sqlite.operations.put.PutResolver
|
||||
import com.pushtorefresh.storio.sqlite.operations.put.PutResult
|
||||
import com.pushtorefresh.storio.sqlite.queries.UpdateQuery
|
||||
import eu.kanade.tachiyomi.data.database.inTransactionReturn
|
||||
import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||
import eu.kanade.tachiyomi.data.database.tables.ChapterTable
|
||||
|
||||
class ChapterBackupPutResolver : PutResolver<Chapter>() {
|
||||
|
||||
override fun performPut(db: StorIOSQLite, chapter: Chapter) = db.inTransactionReturn {
|
||||
val updateQuery = mapToUpdateQuery(chapter)
|
||||
val contentValues = mapToContentValues(chapter)
|
||||
|
||||
val numberOfRowsUpdated = db.lowLevel().update(updateQuery, contentValues)
|
||||
PutResult.newUpdateResult(numberOfRowsUpdated, updateQuery.table())
|
||||
}
|
||||
|
||||
fun mapToUpdateQuery(chapter: Chapter) = UpdateQuery.builder()
|
||||
.table(ChapterTable.TABLE)
|
||||
.where("${ChapterTable.COL_URL} = ?")
|
||||
.whereArgs(chapter.url)
|
||||
.build()
|
||||
|
||||
fun mapToContentValues(chapter: Chapter) = ContentValues(3).apply {
|
||||
put(ChapterTable.COL_READ, chapter.read)
|
||||
put(ChapterTable.COL_BOOKMARK, chapter.bookmark)
|
||||
put(ChapterTable.COL_LAST_PAGE_READ, chapter.last_page_read)
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -3,8 +3,6 @@ package eu.kanade.tachiyomi.data.notification
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.support.v4.content.FileProvider
|
||||
import eu.kanade.tachiyomi.BuildConfig
|
||||
import eu.kanade.tachiyomi.ui.download.DownloadActivity
|
||||
import eu.kanade.tachiyomi.util.getUriCompat
|
||||
import java.io.File
|
||||
@ -33,7 +31,7 @@ object NotificationHandler {
|
||||
*/
|
||||
internal fun openImagePendingActivity(context: Context, file: File): PendingIntent {
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
val uri = FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".provider", file)
|
||||
val uri = file.getUriCompat(context)
|
||||
setDataAndType(uri, "image/*")
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
}
|
||||
|
@ -14,7 +14,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
|
||||
}
|
||||
|
@ -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)
|
||||
|
||||
}
|
||||
|
@ -9,6 +9,7 @@ import eu.kanade.tachiyomi.data.track.TrackService
|
||||
import rx.Completable
|
||||
import rx.Observable
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.text.DecimalFormat
|
||||
|
||||
class Kitsu(private val context: Context, id: Int) : TrackService(id) {
|
||||
|
||||
@ -55,11 +56,17 @@ class Kitsu(private val context: Context, id: Int) : TrackService(id) {
|
||||
}
|
||||
|
||||
override fun getScoreList(): List<String> {
|
||||
return IntRange(0, 10).map { (it.toFloat() / 2).toString() }
|
||||
val df = DecimalFormat("0.#")
|
||||
return listOf("0") + IntRange(2, 20).map { df.format(it / 2f) }
|
||||
}
|
||||
|
||||
override fun indexToScore(index: Int): Float {
|
||||
return if (index > 0) (index + 1) / 2f else 0f
|
||||
}
|
||||
|
||||
override fun displayScore(track: Track): String {
|
||||
return track.toKitsuScore()
|
||||
val df = DecimalFormat("0.#")
|
||||
return df.format(track.score)
|
||||
}
|
||||
|
||||
override fun add(track: Track): Observable<Track> {
|
||||
|
@ -1,6 +1,7 @@
|
||||
package eu.kanade.tachiyomi.data.track.kitsu
|
||||
|
||||
import com.github.salomonbrys.kotson.*
|
||||
import com.google.gson.GsonBuilder
|
||||
import com.google.gson.JsonObject
|
||||
import eu.kanade.tachiyomi.data.database.models.Track
|
||||
import eu.kanade.tachiyomi.network.POST
|
||||
@ -17,7 +18,7 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
|
||||
private val rest = Retrofit.Builder()
|
||||
.baseUrl(baseUrl)
|
||||
.client(client.newBuilder().addInterceptor(interceptor).build())
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.addConverterFactory(GsonConverterFactory.create(GsonBuilder().serializeNulls().create()))
|
||||
.addCallAdapterFactory(RxJavaCallAdapterFactory.create())
|
||||
.build()
|
||||
.create(KitsuApi.Rest::class.java)
|
||||
@ -65,7 +66,7 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
|
||||
"attributes" to jsonObject(
|
||||
"status" to track.toKitsuStatus(),
|
||||
"progress" to track.last_chapter_read,
|
||||
"rating" to track.toKitsuScore()
|
||||
"ratingTwenty" to track.toKitsuScore()
|
||||
)
|
||||
)
|
||||
// @formatter:on
|
||||
|
@ -23,13 +23,13 @@ open class KitsuManga(obj: JsonObject) {
|
||||
class KitsuLibManga(obj: JsonObject, manga: JsonObject) : KitsuManga(manga) {
|
||||
val remoteId by obj.byInt("id")
|
||||
val status by obj["attributes"].byString
|
||||
val rating = obj["attributes"].obj.get("rating").nullString
|
||||
val ratingTwenty = obj["attributes"].obj.get("ratingTwenty").nullString
|
||||
val progress by obj["attributes"].byInt
|
||||
|
||||
override fun toTrack() = super.toTrack().apply {
|
||||
remote_id = remoteId
|
||||
status = toTrackStatus()
|
||||
score = rating?.let { it.toFloat() * 2 } ?: 0f
|
||||
score = ratingTwenty?.let { it.toInt() / 2f } ?: 0f
|
||||
last_chapter_read = progress
|
||||
}
|
||||
|
||||
@ -53,6 +53,6 @@ fun Track.toKitsuStatus() = when (status) {
|
||||
else -> throw Exception("Unknown status")
|
||||
}
|
||||
|
||||
fun Track.toKitsuScore(): String {
|
||||
return if (score > 0) (score / 2).toString() else ""
|
||||
fun Track.toKitsuScore(): String? {
|
||||
return if (score > 0) (score * 2).toInt().toString() else null
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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()
|
||||
|
||||
|
@ -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.
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
|
@ -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")))
|
||||
|
@ -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 {
|
||||
|
@ -1,163 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.backup
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.Dialog
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import com.afollestad.materialdialogs.MaterialDialog
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.ui.base.activity.ActivityMixin
|
||||
import eu.kanade.tachiyomi.ui.base.fragment.BaseRxFragment
|
||||
import eu.kanade.tachiyomi.util.toast
|
||||
import kotlinx.android.synthetic.main.fragment_backup.*
|
||||
import nucleus.factory.RequiresPresenter
|
||||
import rx.Observable
|
||||
import rx.android.schedulers.AndroidSchedulers
|
||||
import rx.internal.util.SubscriptionList
|
||||
import rx.schedulers.Schedulers
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* Fragment to create and restore backups of the application's data.
|
||||
* Uses R.layout.fragment_backup.
|
||||
*/
|
||||
@RequiresPresenter(BackupPresenter::class)
|
||||
class BackupFragment : BaseRxFragment<BackupPresenter>() {
|
||||
|
||||
private var backupDialog: Dialog? = null
|
||||
private var restoreDialog: Dialog? = null
|
||||
|
||||
private lateinit var subscriptions: SubscriptionList
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedState: Bundle?): View {
|
||||
return inflater.inflate(R.layout.fragment_backup, container, false)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedState: Bundle?) {
|
||||
setToolbarTitle(getString(R.string.label_backup))
|
||||
|
||||
(activity as ActivityMixin).requestPermissionsOnMarshmallow()
|
||||
subscriptions = SubscriptionList()
|
||||
|
||||
backup_button.setOnClickListener {
|
||||
val today = SimpleDateFormat("yyyy-MM-dd").format(Date())
|
||||
val file = File(activity.externalCacheDir, "tachiyomi-$today.json")
|
||||
presenter.createBackup(file)
|
||||
|
||||
backupDialog = MaterialDialog.Builder(activity)
|
||||
.content(R.string.backup_please_wait)
|
||||
.progress(true, 0)
|
||||
.show()
|
||||
}
|
||||
|
||||
restore_button.setOnClickListener {
|
||||
val intent = Intent(Intent.ACTION_GET_CONTENT)
|
||||
intent.addCategory(Intent.CATEGORY_OPENABLE)
|
||||
intent.type = "application/*"
|
||||
val chooser = Intent.createChooser(intent, getString(R.string.file_select_backup))
|
||||
startActivityForResult(chooser, REQUEST_BACKUP_OPEN)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
subscriptions.unsubscribe()
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
/**
|
||||
* Called from the presenter when the backup is completed.
|
||||
*
|
||||
* @param file the file where the backup is saved.
|
||||
*/
|
||||
fun onBackupCompleted(file: File) {
|
||||
dismissBackupDialog()
|
||||
val intent = Intent(Intent.ACTION_SEND)
|
||||
intent.type = "application/json"
|
||||
intent.putExtra(Intent.EXTRA_STREAM, Uri.parse("file://" + file))
|
||||
startActivity(Intent.createChooser(intent, ""))
|
||||
}
|
||||
|
||||
/**
|
||||
* Called from the presenter when the restore is completed.
|
||||
*/
|
||||
fun onRestoreCompleted() {
|
||||
dismissRestoreDialog()
|
||||
context.toast(R.string.backup_completed)
|
||||
}
|
||||
|
||||
/**
|
||||
* Called from the presenter when there's an error doing the backup.
|
||||
* @param error the exception thrown.
|
||||
*/
|
||||
fun onBackupError(error: Throwable) {
|
||||
dismissBackupDialog()
|
||||
context.toast(error.message)
|
||||
}
|
||||
|
||||
/**
|
||||
* Called from the presenter when there's an error restoring the backup.
|
||||
* @param error the exception thrown.
|
||||
*/
|
||||
fun onRestoreError(error: Throwable) {
|
||||
dismissRestoreDialog()
|
||||
context.toast(error.message)
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
if (data != null && resultCode == Activity.RESULT_OK && requestCode == REQUEST_BACKUP_OPEN) {
|
||||
restoreDialog = MaterialDialog.Builder(activity)
|
||||
.content(R.string.restore_please_wait)
|
||||
.progress(true, 0)
|
||||
.show()
|
||||
|
||||
// When using cloud services, we have to open the input stream in a background thread.
|
||||
Observable.fromCallable { context.contentResolver.openInputStream(data.data) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe({
|
||||
presenter.restoreBackup(it)
|
||||
}, { error ->
|
||||
context.toast(error.message)
|
||||
Timber.e(error)
|
||||
})
|
||||
.apply { subscriptions.add(this) }
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismisses the backup dialog.
|
||||
*/
|
||||
fun dismissBackupDialog() {
|
||||
backupDialog?.let {
|
||||
it.dismiss()
|
||||
backupDialog = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismisses the restore dialog.
|
||||
*/
|
||||
fun dismissRestoreDialog() {
|
||||
restoreDialog?.let {
|
||||
it.dismiss()
|
||||
restoreDialog = null
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private val REQUEST_BACKUP_OPEN = 102
|
||||
|
||||
fun newInstance(): BackupFragment {
|
||||
return BackupFragment()
|
||||
}
|
||||
}
|
||||
}
|
@ -1,94 +0,0 @@
|
||||
package eu.kanade.tachiyomi.ui.backup
|
||||
|
||||
import android.os.Bundle
|
||||
import eu.kanade.tachiyomi.data.backup.BackupManager
|
||||
import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
||||
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
|
||||
import eu.kanade.tachiyomi.util.isNullOrUnsubscribed
|
||||
import rx.Observable
|
||||
import rx.Subscription
|
||||
import rx.android.schedulers.AndroidSchedulers
|
||||
import rx.schedulers.Schedulers
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
|
||||
/**
|
||||
* Presenter of [BackupFragment].
|
||||
*/
|
||||
class BackupPresenter : BasePresenter<BackupFragment>() {
|
||||
|
||||
/**
|
||||
* Database.
|
||||
*/
|
||||
val db: DatabaseHelper by injectLazy()
|
||||
|
||||
/**
|
||||
* Backup manager.
|
||||
*/
|
||||
private lateinit var backupManager: BackupManager
|
||||
|
||||
/**
|
||||
* Subscription where the backup is restored.
|
||||
*/
|
||||
private var restoreSubscription: Subscription? = null
|
||||
|
||||
/**
|
||||
* Subscription where the backup is created.
|
||||
*/
|
||||
private var backupSubscription: Subscription? = null
|
||||
|
||||
override fun onCreate(savedState: Bundle?) {
|
||||
super.onCreate(savedState)
|
||||
backupManager = BackupManager(db)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a backup and saves it to a file.
|
||||
*
|
||||
* @param file the path where the file will be saved.
|
||||
*/
|
||||
fun createBackup(file: File) {
|
||||
if (backupSubscription.isNullOrUnsubscribed()) {
|
||||
backupSubscription = getBackupObservable(file)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribeFirst(
|
||||
{ view, result -> view.onBackupCompleted(file) },
|
||||
BackupFragment::onBackupError)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores a backup from a stream.
|
||||
*
|
||||
* @param stream the input stream of the backup file.
|
||||
*/
|
||||
fun restoreBackup(stream: InputStream) {
|
||||
if (restoreSubscription.isNullOrUnsubscribed()) {
|
||||
restoreSubscription = getRestoreObservable(stream)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribeFirst(
|
||||
{ view, result -> view.onRestoreCompleted() },
|
||||
BackupFragment::onRestoreError)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the observable to save a backup.
|
||||
*/
|
||||
private fun getBackupObservable(file: File) = Observable.fromCallable {
|
||||
backupManager.backupToFile(file)
|
||||
true
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the observable to restore a backup.
|
||||
*/
|
||||
private fun getRestoreObservable(stream: InputStream) = Observable.fromCallable {
|
||||
backupManager.restoreFromStream(stream)
|
||||
true
|
||||
}
|
||||
|
||||
}
|
@ -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
|
||||
}
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
@ -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
|
||||
|
||||
}
|
@ -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())
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -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
|
||||
|
@ -6,6 +6,7 @@ import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.support.customtabs.CustomTabsIntent
|
||||
import android.view.*
|
||||
import android.widget.Toast
|
||||
import com.afollestad.materialdialogs.MaterialDialog
|
||||
import com.bumptech.glide.BitmapRequestBuilder
|
||||
import com.bumptech.glide.BitmapTypeRequest
|
||||
@ -14,6 +15,7 @@ import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||
import com.bumptech.glide.load.resource.bitmap.CenterCrop
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
@ -31,6 +33,7 @@ import nucleus.factory.RequiresPresenter
|
||||
import rx.Observable
|
||||
import rx.android.schedulers.AndroidSchedulers
|
||||
import rx.schedulers.Schedulers
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
/**
|
||||
* Fragment that shows manga information.
|
||||
@ -52,6 +55,11 @@ class MangaInfoFragment : BaseRxFragment<MangaInfoPresenter>() {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Preferences helper.
|
||||
*/
|
||||
private val preferences: PreferencesHelper by injectLazy()
|
||||
|
||||
override fun onCreate(savedState: Bundle?) {
|
||||
super.onCreate(savedState)
|
||||
setHasOptionsMenu(true)
|
||||
@ -63,7 +71,19 @@ class MangaInfoFragment : BaseRxFragment<MangaInfoPresenter>() {
|
||||
|
||||
override fun onViewCreated(view: View?, savedState: Bundle?) {
|
||||
// Set onclickListener to toggle favorite when FAB clicked.
|
||||
fab_favorite.setOnClickListener { toggleFavorite() }
|
||||
fab_favorite.setOnClickListener {
|
||||
if(!presenter.manga.favorite) {
|
||||
val defaultCategory = presenter.getCategories().find { it.id == preferences.defaultCategory()}
|
||||
if(defaultCategory == null) {
|
||||
onFabClick()
|
||||
} else {
|
||||
toggleFavorite()
|
||||
presenter.moveMangaToCategory(defaultCategory, presenter.manga)
|
||||
}
|
||||
} else {
|
||||
toggleFavorite()
|
||||
}
|
||||
}
|
||||
|
||||
// Set SwipeRefresh to refresh manga data.
|
||||
swipe_refresh.setOnRefreshListener { fetchMangaFromSource() }
|
||||
@ -334,4 +354,40 @@ class MangaInfoFragment : BaseRxFragment<MangaInfoPresenter>() {
|
||||
swipe_refresh.isRefreshing = value
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the fab is clicked.
|
||||
*/
|
||||
private fun onFabClick() {
|
||||
val categories = presenter.getCategories()
|
||||
|
||||
MaterialDialog.Builder(activity)
|
||||
.title(R.string.action_move_category)
|
||||
.items(categories.map { it.name })
|
||||
.itemsCallbackMultiChoice(presenter.getMangaCategoryIds(presenter.manga)) { dialog, position, text ->
|
||||
if (position.contains(0) && position.count() > 1) {
|
||||
dialog.setSelectedIndices(position.filter {it > 0}.toTypedArray())
|
||||
Toast.makeText(dialog.context, R.string.invalid_combination, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
.alwaysCallMultiChoiceCallback()
|
||||
.positiveText(android.R.string.ok)
|
||||
.negativeText(android.R.string.cancel)
|
||||
.onPositive { dialog, _ ->
|
||||
val selectedCategories = dialog.selectedIndices?.map { categories[it] } ?: emptyList()
|
||||
|
||||
if(!selectedCategories.isEmpty()) {
|
||||
if(!presenter.manga.favorite) {
|
||||
toggleFavorite()
|
||||
}
|
||||
presenter.moveMangaToCategories(selectedCategories.filter { it.id != 0}, presenter.manga)
|
||||
} else {
|
||||
toggleFavorite()
|
||||
}
|
||||
}
|
||||
.build()
|
||||
.show()
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -3,7 +3,9 @@ package eu.kanade.tachiyomi.ui.manga.info
|
||||
import android.os.Bundle
|
||||
import eu.kanade.tachiyomi.data.cache.CoverCache
|
||||
import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
||||
import eu.kanade.tachiyomi.data.database.models.Category
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.database.models.MangaCategory
|
||||
import eu.kanade.tachiyomi.data.download.DownloadManager
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.SourceManager
|
||||
@ -76,9 +78,12 @@ class MangaInfoPresenter : BasePresenter<MangaInfoFragment>() {
|
||||
?.subscribeLatestCache(MangaInfoFragment::setChapterCount)
|
||||
|
||||
// Update favorite status
|
||||
SharedData.get(MangaFavoriteEvent::class.java)?.observable
|
||||
?.observeOn(AndroidSchedulers.mainThread())
|
||||
?.subscribe { setFavorite(it) }
|
||||
SharedData.get(MangaFavoriteEvent::class.java)?.let {
|
||||
it.observable
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe { setFavorite(it) }
|
||||
.apply { add(this) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -148,4 +153,49 @@ class MangaInfoPresenter : BasePresenter<MangaInfoFragment>() {
|
||||
downloadManager.findMangaDir(source, manga)?.delete()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default, and user categories.
|
||||
*
|
||||
* @return List of categories, default plus user categories
|
||||
*/
|
||||
fun getCategories(): List<Category> {
|
||||
return arrayListOf(Category.createDefault()) + db.getCategories().executeAsBlocking()
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the category id's the manga is in, if the manga is not in a category, returns the default id.
|
||||
*
|
||||
* @param manga the manga to get categories from.
|
||||
* @return Array of category ids the manga is in, if none returns default id
|
||||
*/
|
||||
fun getMangaCategoryIds(manga: Manga): Array<Int?> {
|
||||
val categories = db.getCategoriesForManga(manga).executeAsBlocking()
|
||||
if(categories.isEmpty()) {
|
||||
return arrayListOf(Category.createDefault().id).toTypedArray()
|
||||
}
|
||||
return categories.map { it.id }.toTypedArray()
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the given manga to categories.
|
||||
*
|
||||
* @param categories the selected categories.
|
||||
* @param manga the manga to move.
|
||||
*/
|
||||
fun moveMangaToCategories(categories: List<Category>, manga: Manga) {
|
||||
val mc = categories.map { MangaCategory.create(manga, it) }
|
||||
|
||||
db.setMangaCategories(mc, arrayListOf(manga))
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the given manga to the category.
|
||||
*
|
||||
* @param category the selected category.
|
||||
* @param manga the manga to move.
|
||||
*/
|
||||
fun moveMangaToCategory(category: Category, manga: Manga) {
|
||||
moveMangaToCategories(arrayListOf(category), manga)
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -76,10 +76,10 @@ class WebtoonHolder(private val view: View, private val adapter: WebtoonAdapter)
|
||||
})
|
||||
}
|
||||
|
||||
view.progress_container.minimumHeight = view.resources.displayMetrics.heightPixels * 2
|
||||
view.progress_container.minimumHeight = webtoonReader.screenHeight
|
||||
|
||||
view.setOnTouchListener(adapter.touchListener)
|
||||
view.retry_button.setOnTouchListener { v, event ->
|
||||
view.retry_button.setOnTouchListener { _, event ->
|
||||
if (event.action == MotionEvent.ACTION_UP) {
|
||||
readerActivity.presenter.retryPage(page)
|
||||
}
|
||||
|
@ -1,7 +1,9 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.viewer.webtoon
|
||||
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.support.v7.widget.RecyclerView
|
||||
import android.util.DisplayMetrics
|
||||
import android.view.*
|
||||
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
|
||||
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
@ -64,6 +66,19 @@ class WebtoonReader : BaseReader() {
|
||||
|
||||
private var scrollDistance: Int = 0
|
||||
|
||||
val screenHeight by lazy {
|
||||
val display = activity.windowManager.defaultDisplay
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
|
||||
val metrics = DisplayMetrics()
|
||||
display.getRealMetrics(metrics)
|
||||
metrics.heightPixels
|
||||
} else {
|
||||
val field = Display::class.java.getMethod("getRawHeight")
|
||||
field.invoke(display) as Int
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedState: Bundle?): View? {
|
||||
adapter = WebtoonAdapter(this)
|
||||
|
||||
@ -72,9 +87,6 @@ class WebtoonReader : BaseReader() {
|
||||
|
||||
layoutManager = PreCachingLayoutManager(activity)
|
||||
layoutManager.extraLayoutSpace = screenHeight / 2
|
||||
if (savedState != null) {
|
||||
layoutManager.scrollToPositionWithOffset(savedState.getInt(SAVED_POSITION), 0)
|
||||
}
|
||||
|
||||
recycler = RecyclerView(activity).apply {
|
||||
layoutParams = ViewGroup.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
|
||||
@ -107,6 +119,26 @@ class WebtoonReader : BaseReader() {
|
||||
return recycler
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Uses two ways to scroll to the last page read.
|
||||
*/
|
||||
private fun scrollToLastPageRead(page: Int) {
|
||||
// Scrolls to the correct page initially, but isn't reliable beyond that.
|
||||
recycler.addOnLayoutChangeListener(object: View.OnLayoutChangeListener {
|
||||
override fun onLayoutChange(p0: View?, p1: Int, p2: Int, p3: Int, p4: Int, p5: Int, p6: Int, p7: Int, p8: Int) {
|
||||
if(pages.isEmpty()) {
|
||||
setActivePage(page)
|
||||
} else {
|
||||
recycler.removeOnLayoutChangeListener(this)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Scrolls to the correct page after app has been in use, but can't do it the very first time.
|
||||
recycler.post { setActivePage(page) }
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
subscriptions.unsubscribe()
|
||||
super.onDestroyView()
|
||||
@ -150,6 +182,7 @@ class WebtoonReader : BaseReader() {
|
||||
// Make sure the view is already initialized.
|
||||
if (view != null) {
|
||||
setPagesOnAdapter()
|
||||
scrollToLastPageRead(this.currentPage)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -65,6 +65,7 @@ class SettingsActivity : BaseActivity(),
|
||||
"downloads_screen" -> SettingsDownloadsFragment.newInstance(key)
|
||||
"sources_screen" -> SettingsSourcesFragment.newInstance(key)
|
||||
"tracking_screen" -> SettingsTrackingFragment.newInstance(key)
|
||||
"backup_screen" -> SettingsBackupFragment.newInstance(key)
|
||||
"advanced_screen" -> SettingsAdvancedFragment.newInstance(key)
|
||||
"about_screen" -> SettingsAboutFragment.newInstance(key)
|
||||
else -> SettingsFragment.newInstance(key)
|
||||
|
@ -108,6 +108,7 @@ class SettingsAdvancedFragment : SettingsFragment() {
|
||||
.onPositive { dialog, which ->
|
||||
(activity as SettingsActivity).parentFlags = SettingsActivity.FLAG_DATABASE_CLEARED
|
||||
db.deleteMangasNotInLibrary().executeAsBlocking()
|
||||
db.deleteHistoryNoLastRead().executeAsBlocking()
|
||||
activity.toast(R.string.clear_database_completed)
|
||||
}
|
||||
.show()
|
||||
|
@ -0,0 +1,422 @@
|
||||
package eu.kanade.tachiyomi.ui.setting
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.Dialog
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.support.v7.preference.XpPreferenceFragment
|
||||
import android.view.View
|
||||
import com.afollestad.materialdialogs.MaterialDialog
|
||||
import com.hippo.unifile.UniFile
|
||||
import com.nononsenseapps.filepicker.FilePickerActivity
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.backup.BackupCreateService
|
||||
import eu.kanade.tachiyomi.data.backup.BackupCreatorJob
|
||||
import eu.kanade.tachiyomi.data.backup.BackupRestoreService
|
||||
import eu.kanade.tachiyomi.data.backup.models.Backup
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||
import eu.kanade.tachiyomi.ui.base.activity.BaseActivity
|
||||
import eu.kanade.tachiyomi.util.*
|
||||
import eu.kanade.tachiyomi.widget.CustomLayoutPickerActivity
|
||||
import eu.kanade.tachiyomi.widget.preference.IntListPreference
|
||||
import net.xpece.android.support.preference.Preference
|
||||
import rx.subscriptions.Subscriptions
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.io.File
|
||||
import java.util.concurrent.TimeUnit
|
||||
import eu.kanade.tachiyomi.BuildConfig.APPLICATION_ID as ID
|
||||
|
||||
/**
|
||||
* Settings for [BackupCreateService] and [BackupRestoreService]
|
||||
*/
|
||||
class SettingsBackupFragment : SettingsFragment() {
|
||||
|
||||
companion object {
|
||||
const val INTENT_FILTER = "SettingsBackupFragment"
|
||||
const val ACTION_BACKUP_COMPLETED_DIALOG = "$ID.$INTENT_FILTER.ACTION_BACKUP_COMPLETED_DIALOG"
|
||||
const val ACTION_SET_PROGRESS_DIALOG = "$ID.$INTENT_FILTER.ACTION_SET_PROGRESS_DIALOG"
|
||||
const val ACTION_ERROR_BACKUP_DIALOG = "$ID.$INTENT_FILTER.ACTION_ERROR_BACKUP_DIALOG"
|
||||
const val ACTION_ERROR_RESTORE_DIALOG = "$ID.$INTENT_FILTER.ACTION_ERROR_RESTORE_DIALOG"
|
||||
const val ACTION_RESTORE_COMPLETED_DIALOG = "$ID.$INTENT_FILTER.ACTION_RESTORE_COMPLETED_DIALOG"
|
||||
const val ACTION = "$ID.$INTENT_FILTER.ACTION"
|
||||
const val EXTRA_PROGRESS = "$ID.$INTENT_FILTER.EXTRA_PROGRESS"
|
||||
const val EXTRA_AMOUNT = "$ID.$INTENT_FILTER.EXTRA_AMOUNT"
|
||||
const val EXTRA_ERRORS = "$ID.$INTENT_FILTER.EXTRA_ERRORS"
|
||||
const val EXTRA_CONTENT = "$ID.$INTENT_FILTER.EXTRA_CONTENT"
|
||||
const val EXTRA_ERROR_MESSAGE = "$ID.$INTENT_FILTER.EXTRA_ERROR_MESSAGE"
|
||||
const val EXTRA_URI = "$ID.$INTENT_FILTER.EXTRA_URI"
|
||||
const val EXTRA_TIME = "$ID.$INTENT_FILTER.EXTRA_TIME"
|
||||
const val EXTRA_ERROR_FILE_PATH = "$ID.$INTENT_FILTER.EXTRA_ERROR_FILE_PATH"
|
||||
const val EXTRA_ERROR_FILE = "$ID.$INTENT_FILTER.EXTRA_ERROR_FILE"
|
||||
|
||||
private const val BACKUP_CREATE = 201
|
||||
private const val BACKUP_RESTORE = 202
|
||||
private const val BACKUP_DIR = 203
|
||||
|
||||
fun newInstance(rootKey: String): SettingsBackupFragment {
|
||||
val args = Bundle()
|
||||
args.putString(XpPreferenceFragment.ARG_PREFERENCE_ROOT, rootKey)
|
||||
return SettingsBackupFragment().apply { arguments = args }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Preference selected to create backup
|
||||
*/
|
||||
private val createBackup: Preference by bindPref(R.string.pref_create_local_backup_key)
|
||||
|
||||
/**
|
||||
* Preference selected to restore backup
|
||||
*/
|
||||
private val restoreBackup: Preference by bindPref(R.string.pref_restore_local_backup_key)
|
||||
|
||||
/**
|
||||
* Preference which determines the frequency of automatic backups.
|
||||
*/
|
||||
private val automaticBackup: IntListPreference by bindPref(R.string.pref_backup_interval_key)
|
||||
|
||||
/**
|
||||
* Preference containing number of automatic backups
|
||||
*/
|
||||
private val backupSlots: IntListPreference by bindPref(R.string.pref_backup_slots_key)
|
||||
|
||||
/**
|
||||
* Preference containing interval of automatic backups
|
||||
*/
|
||||
private val backupDirPref: Preference by bindPref(R.string.pref_backup_directory_key)
|
||||
|
||||
/**
|
||||
* Preferences
|
||||
*/
|
||||
private val preferences: PreferencesHelper by injectLazy()
|
||||
|
||||
/**
|
||||
* Value containing information on what to backup
|
||||
*/
|
||||
private var backup_flags = 0
|
||||
|
||||
/**
|
||||
* The root directory for backups..
|
||||
*/
|
||||
private var backupDir = preferences.backupsDirectory().getOrDefault().let {
|
||||
UniFile.fromUri(context, Uri.parse(it))
|
||||
}
|
||||
|
||||
val restoreDialog: MaterialDialog by lazy {
|
||||
MaterialDialog.Builder(context)
|
||||
.title(R.string.backup)
|
||||
.content(R.string.restoring_backup)
|
||||
.progress(false, 100, true)
|
||||
.cancelable(false)
|
||||
.negativeText(R.string.action_stop)
|
||||
.onNegative { materialDialog, _ ->
|
||||
BackupRestoreService.stop(context)
|
||||
materialDialog.dismiss()
|
||||
}
|
||||
.build()
|
||||
}
|
||||
|
||||
val backupDialog: MaterialDialog by lazy {
|
||||
MaterialDialog.Builder(context)
|
||||
.title(R.string.backup)
|
||||
.content(R.string.creating_backup)
|
||||
.progress(true, 0)
|
||||
.cancelable(false)
|
||||
.build()
|
||||
}
|
||||
|
||||
private val receiver = object : BroadcastReceiver() {
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
when (intent.getStringExtra(ACTION)) {
|
||||
ACTION_BACKUP_COMPLETED_DIALOG -> {
|
||||
backupDialog.dismiss()
|
||||
val uri = Uri.parse(intent.getStringExtra(EXTRA_URI))
|
||||
val file = UniFile.fromUri(context, uri)
|
||||
MaterialDialog.Builder(this@SettingsBackupFragment.context)
|
||||
.title(getString(R.string.backup_created))
|
||||
.content(getString(R.string.file_saved, file.filePath))
|
||||
.positiveText(getString(R.string.action_close))
|
||||
.negativeText(getString(R.string.action_export))
|
||||
.onPositive { materialDialog, _ -> materialDialog.dismiss() }
|
||||
.onNegative { _, _ ->
|
||||
val sendIntent = Intent(Intent.ACTION_SEND)
|
||||
sendIntent.type = "application/json"
|
||||
sendIntent.putExtra(Intent.EXTRA_STREAM, file.uri)
|
||||
startActivity(Intent.createChooser(sendIntent, ""))
|
||||
}
|
||||
.safeShow()
|
||||
|
||||
}
|
||||
ACTION_SET_PROGRESS_DIALOG -> {
|
||||
val progress = intent.getIntExtra(EXTRA_PROGRESS, 0)
|
||||
val amount = intent.getIntExtra(EXTRA_AMOUNT, 0)
|
||||
val content = intent.getStringExtra(EXTRA_CONTENT)
|
||||
restoreDialog.setContent(content)
|
||||
restoreDialog.setProgress(progress)
|
||||
restoreDialog.maxProgress = amount
|
||||
}
|
||||
ACTION_RESTORE_COMPLETED_DIALOG -> {
|
||||
restoreDialog.dismiss()
|
||||
val time = intent.getLongExtra(EXTRA_TIME, 0)
|
||||
val errors = intent.getIntExtra(EXTRA_ERRORS, 0)
|
||||
val path = intent.getStringExtra(EXTRA_ERROR_FILE_PATH)
|
||||
val file = intent.getStringExtra(EXTRA_ERROR_FILE)
|
||||
val timeString = String.format("%02d min, %02d sec",
|
||||
TimeUnit.MILLISECONDS.toMinutes(time),
|
||||
TimeUnit.MILLISECONDS.toSeconds(time) -
|
||||
TimeUnit.MINUTES.toSeconds(TimeUnit.MILLISECONDS.toMinutes(time))
|
||||
)
|
||||
|
||||
if (errors > 0) {
|
||||
MaterialDialog.Builder(this@SettingsBackupFragment.context)
|
||||
.title(getString(R.string.restore_completed))
|
||||
.content(getString(R.string.restore_completed_content, timeString,
|
||||
if (errors > 0) "$errors" else getString(android.R.string.no)))
|
||||
.positiveText(getString(R.string.action_close))
|
||||
.negativeText(getString(R.string.action_open_log))
|
||||
.onPositive { materialDialog, _ -> materialDialog.dismiss() }
|
||||
.onNegative { materialDialog, _ ->
|
||||
if (!path.isEmpty()) {
|
||||
val destFile = File(path, file)
|
||||
val uri = destFile.getUriCompat(context)
|
||||
val sendIntent = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, "text/plain")
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
}
|
||||
startActivity(sendIntent)
|
||||
} else {
|
||||
context.toast(getString(R.string.error_opening_log))
|
||||
}
|
||||
materialDialog.dismiss()
|
||||
}
|
||||
.safeShow()
|
||||
}
|
||||
}
|
||||
ACTION_ERROR_BACKUP_DIALOG -> {
|
||||
context.toast(intent.getStringExtra(EXTRA_ERROR_MESSAGE))
|
||||
backupDialog.dismiss()
|
||||
}
|
||||
ACTION_ERROR_RESTORE_DIALOG -> {
|
||||
context.toast(intent.getStringExtra(EXTRA_ERROR_MESSAGE))
|
||||
restoreDialog.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
context.registerLocalReceiver(receiver, IntentFilter(INTENT_FILTER))
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
context.unregisterLocalReceiver(receiver)
|
||||
super.onPause()
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedState: Bundle?) {
|
||||
super.onViewCreated(view, savedState)
|
||||
|
||||
if (savedState != null) {
|
||||
if (BackupRestoreService.isRunning(context)) {
|
||||
restoreDialog.safeShow()
|
||||
}
|
||||
else if (BackupCreateService.isRunning(context)) {
|
||||
backupDialog.safeShow()
|
||||
}
|
||||
}
|
||||
|
||||
(activity as BaseActivity).requestPermissionsOnMarshmallow()
|
||||
|
||||
// Set onClickListeners
|
||||
createBackup.setOnPreferenceClickListener {
|
||||
MaterialDialog.Builder(context)
|
||||
.title(R.string.pref_create_backup)
|
||||
.content(R.string.backup_choice)
|
||||
.items(R.array.backup_options)
|
||||
.itemsCallbackMultiChoice(arrayOf(0, 1, 2, 3, 4 /*todo not hard code*/)) { _, positions, _ ->
|
||||
// TODO not very happy with global value, but putExtra doesn't work
|
||||
backup_flags = 0
|
||||
for (i in 1..positions.size - 1) {
|
||||
when (positions[i]) {
|
||||
1 -> backup_flags = backup_flags or BackupCreateService.BACKUP_CATEGORY
|
||||
2 -> backup_flags = backup_flags or BackupCreateService.BACKUP_CHAPTER
|
||||
3 -> backup_flags = backup_flags or BackupCreateService.BACKUP_TRACK
|
||||
4 -> backup_flags = backup_flags or BackupCreateService.BACKUP_HISTORY
|
||||
}
|
||||
}
|
||||
// If API lower as KitKat use custom dir picker
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) {
|
||||
// Get dirs
|
||||
val currentDir = preferences.backupsDirectory().getOrDefault()
|
||||
|
||||
val i = Intent(activity, CustomLayoutPickerActivity::class.java)
|
||||
i.putExtra(FilePickerActivity.EXTRA_ALLOW_MULTIPLE, false)
|
||||
i.putExtra(FilePickerActivity.EXTRA_ALLOW_CREATE_DIR, true)
|
||||
i.putExtra(FilePickerActivity.EXTRA_MODE, FilePickerActivity.MODE_DIR)
|
||||
i.putExtra(FilePickerActivity.EXTRA_START_PATH, currentDir)
|
||||
startActivityForResult(i, BACKUP_CREATE)
|
||||
} else {
|
||||
// Use Androids build in file creator
|
||||
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT)
|
||||
intent.addCategory(Intent.CATEGORY_OPENABLE)
|
||||
|
||||
// TODO create custom MIME data type? Will make older backups deprecated
|
||||
intent.type = "application/*"
|
||||
intent.putExtra(Intent.EXTRA_TITLE, Backup.getDefaultFilename())
|
||||
startActivityForResult(intent, BACKUP_CREATE)
|
||||
}
|
||||
true
|
||||
}
|
||||
.itemsDisabledIndices(0)
|
||||
.positiveText(getString(R.string.action_create))
|
||||
.negativeText(android.R.string.cancel)
|
||||
.safeShow()
|
||||
true
|
||||
}
|
||||
|
||||
restoreBackup.setOnPreferenceClickListener {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) {
|
||||
val intent = Intent()
|
||||
intent.type = "application/*"
|
||||
intent.action = Intent.ACTION_GET_CONTENT
|
||||
startActivityForResult(Intent.createChooser(intent, getString(R.string.file_select_backup)), BACKUP_RESTORE)
|
||||
} else {
|
||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
|
||||
intent.addCategory(Intent.CATEGORY_OPENABLE)
|
||||
intent.type = "application/*"
|
||||
startActivityForResult(intent, BACKUP_RESTORE)
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
automaticBackup.setOnPreferenceChangeListener { _, newValue ->
|
||||
// Always cancel the previous task, it seems that sometimes they are not updated.
|
||||
BackupCreatorJob.cancelTask()
|
||||
|
||||
val interval = (newValue as String).toInt()
|
||||
if (interval > 0) {
|
||||
BackupCreatorJob.setupTask(interval)
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
backupSlots.setOnPreferenceChangeListener { preference, newValue ->
|
||||
preferences.numberOfBackups().set((newValue as String).toInt())
|
||||
preference.summary = newValue
|
||||
true
|
||||
}
|
||||
|
||||
backupDirPref.setOnPreferenceClickListener {
|
||||
val currentDir = preferences.backupsDirectory().getOrDefault()
|
||||
|
||||
if (Build.VERSION.SDK_INT < 21) {
|
||||
// Custom dir selected, open directory selector
|
||||
val i = Intent(activity, CustomLayoutPickerActivity::class.java)
|
||||
i.putExtra(FilePickerActivity.EXTRA_ALLOW_MULTIPLE, false)
|
||||
i.putExtra(FilePickerActivity.EXTRA_ALLOW_CREATE_DIR, true)
|
||||
i.putExtra(FilePickerActivity.EXTRA_MODE, FilePickerActivity.MODE_DIR)
|
||||
i.putExtra(FilePickerActivity.EXTRA_START_PATH, currentDir)
|
||||
|
||||
startActivityForResult(i, BACKUP_DIR)
|
||||
} else {
|
||||
val i = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
|
||||
startActivityForResult(i, BACKUP_DIR)
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
subscriptions += preferences.backupsDirectory().asObservable()
|
||||
.subscribe { path ->
|
||||
backupDir = UniFile.fromUri(context, Uri.parse(path))
|
||||
backupDirPref.summary = backupDir.filePath ?: path
|
||||
}
|
||||
|
||||
subscriptions += preferences.backupInterval().asObservable()
|
||||
.subscribe {
|
||||
backupDirPref.isVisible = it > 0
|
||||
backupSlots.isVisible = it > 0
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
when (requestCode) {
|
||||
BACKUP_DIR -> if (data != null && resultCode == Activity.RESULT_OK) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
|
||||
val uri = Uri.fromFile(File(data.data.path))
|
||||
preferences.backupsDirectory().set(uri.toString())
|
||||
} else {
|
||||
val uri = data.data
|
||||
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or
|
||||
Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
|
||||
context.contentResolver.takePersistableUriPermission(uri, flags)
|
||||
|
||||
val file = UniFile.fromUri(context, uri)
|
||||
preferences.backupsDirectory().set(file.uri.toString())
|
||||
}
|
||||
}
|
||||
BACKUP_CREATE -> if (data != null && resultCode == Activity.RESULT_OK) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) {
|
||||
val dir = data.data.path
|
||||
val file = File(dir, Backup.getDefaultFilename())
|
||||
|
||||
backupDialog.safeShow()
|
||||
BackupCreateService.makeBackup(context, file.toURI().toString(), backup_flags)
|
||||
} else {
|
||||
val uri = data.data
|
||||
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or
|
||||
Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
|
||||
context.contentResolver.takePersistableUriPermission(uri, flags)
|
||||
val file = UniFile.fromUri(context, uri)
|
||||
|
||||
backupDialog.safeShow()
|
||||
BackupCreateService.makeBackup(context, file.uri.toString(), backup_flags)
|
||||
}
|
||||
}
|
||||
BACKUP_RESTORE -> if (data != null && resultCode == Activity.RESULT_OK) {
|
||||
val uri = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) {
|
||||
Uri.fromFile(File(data.data.path))
|
||||
} else {
|
||||
val uri = data.data
|
||||
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or
|
||||
Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
|
||||
context.contentResolver.takePersistableUriPermission(uri, flags)
|
||||
UniFile.fromUri(context, uri).uri
|
||||
}
|
||||
|
||||
MaterialDialog.Builder(context)
|
||||
.title(getString(R.string.pref_restore_backup))
|
||||
.content(getString(R.string.backup_restore_content))
|
||||
.positiveText(getString(R.string.action_restore))
|
||||
.onPositive { _, _ ->
|
||||
restoreDialog.safeShow()
|
||||
BackupRestoreService.start(context, uri.toString())
|
||||
}
|
||||
.safeShow()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun MaterialDialog.Builder.safeShow(): Dialog {
|
||||
return build().safeShow()
|
||||
}
|
||||
|
||||
fun Dialog.safeShow(): Dialog {
|
||||
subscriptions += Subscriptions.create { dismiss() }
|
||||
show()
|
||||
return this
|
||||
}
|
||||
|
||||
}
|
@ -9,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -29,6 +29,7 @@ open class SettingsFragment : XpPreferenceFragment() {
|
||||
addPreferencesFromResource(R.xml.pref_downloads)
|
||||
addPreferencesFromResource(R.xml.pref_sources)
|
||||
addPreferencesFromResource(R.xml.pref_tracking)
|
||||
addPreferencesFromResource(R.xml.pref_backup)
|
||||
addPreferencesFromResource(R.xml.pref_advanced)
|
||||
addPreferencesFromResource(R.xml.pref_about)
|
||||
|
||||
|
@ -7,6 +7,7 @@ import android.support.v7.preference.XpPreferenceFragment
|
||||
import android.view.View
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
||||
import eu.kanade.tachiyomi.data.database.models.Category
|
||||
import eu.kanade.tachiyomi.data.library.LibraryUpdateJob
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.util.LocaleHelper
|
||||
@ -46,6 +47,8 @@ class SettingsGeneralFragment : SettingsFragment(),
|
||||
|
||||
val categoryUpdate: MultiSelectListPreference by bindPref(R.string.pref_library_update_categories_key)
|
||||
|
||||
val defaultCategory: IntListPreference by bindPref(R.string.default_category_key)
|
||||
|
||||
val langPreference: ListPreference by bindPref(R.string.pref_language_key)
|
||||
|
||||
override fun onViewCreated(view: View, savedState: Bundle?) {
|
||||
@ -100,6 +103,22 @@ class SettingsGeneralFragment : SettingsFragment(),
|
||||
categoryUpdate.summary = summary
|
||||
}
|
||||
|
||||
defaultCategory.apply {
|
||||
val selectedCategory = dbCategories.find { it.id == preferences.defaultCategory()}
|
||||
value = selectedCategory?.id?.toString() ?: value
|
||||
entries += dbCategories.map { it.name }.toTypedArray()
|
||||
entryValues += dbCategories.map { it.id.toString() }.toTypedArray()
|
||||
summary = selectedCategory?.name ?: summary
|
||||
}
|
||||
|
||||
defaultCategory.setOnPreferenceChangeListener { _, newValue ->
|
||||
defaultCategory.summary = dbCategories.find {
|
||||
it.id == (newValue as String).toInt()
|
||||
}?.name ?: getString(R.string.default_category_summary)
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
themePreference.setOnPreferenceChangeListener { preference, newValue ->
|
||||
(activity as SettingsActivity).parentFlags = SettingsActivity.FLAG_THEME_CHANGED
|
||||
activity.recreate()
|
||||
|
@ -122,7 +122,7 @@ fun Context.sendLocalBroadcastSync(intent: Intent) {
|
||||
*
|
||||
* @param receiver receiver that gets registered.
|
||||
*/
|
||||
fun Context.registerLocalReceiver(receiver: BroadcastReceiver, filter: IntentFilter ){
|
||||
fun Context.registerLocalReceiver(receiver: BroadcastReceiver, filter: IntentFilter) {
|
||||
LocalBroadcastManager.getInstance(this).registerReceiver(receiver, filter)
|
||||
}
|
||||
|
||||
@ -131,7 +131,7 @@ fun Context.registerLocalReceiver(receiver: BroadcastReceiver, filter: IntentFil
|
||||
*
|
||||
* @param receiver receiver that gets unregistered.
|
||||
*/
|
||||
fun Context.unregisterLocalReceiver(receiver: BroadcastReceiver){
|
||||
fun Context.unregisterLocalReceiver(receiver: BroadcastReceiver) {
|
||||
LocalBroadcastManager.getInstance(this).unregisterReceiver(receiver)
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -19,15 +19,3 @@ fun File.getUriCompat(context: Context): Uri {
|
||||
return uri
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes file if exists
|
||||
*
|
||||
* @return success of file deletion
|
||||
*/
|
||||
fun File.deleteIfExists(): Boolean {
|
||||
if (this.exists()) {
|
||||
this.delete()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
@ -0,0 +1,33 @@
|
||||
package eu.kanade.tachiyomi.widget
|
||||
|
||||
import android.support.v7.widget.RecyclerView
|
||||
import android.view.ViewGroup
|
||||
import com.nononsenseapps.filepicker.AbstractFilePickerFragment
|
||||
import com.nononsenseapps.filepicker.FilePickerActivity
|
||||
import com.nononsenseapps.filepicker.FilePickerFragment
|
||||
import com.nononsenseapps.filepicker.LogicHandler
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.util.inflate
|
||||
import java.io.File
|
||||
|
||||
class CustomLayoutPickerActivity : FilePickerActivity() {
|
||||
|
||||
override fun getFragment(startPath: String?, mode: Int, allowMultiple: Boolean, allowCreateDir: Boolean):
|
||||
AbstractFilePickerFragment<File> {
|
||||
val fragment = CustomLayoutFilePickerFragment()
|
||||
fragment.setArgs(startPath, mode, allowMultiple, allowCreateDir)
|
||||
return fragment
|
||||
}
|
||||
}
|
||||
|
||||
class CustomLayoutFilePickerFragment : FilePickerFragment() {
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||
when (viewType) {
|
||||
LogicHandler.VIEWTYPE_DIR -> {
|
||||
val view = parent.inflate(R.layout.listitem_dir)
|
||||
return DirViewHolder(view)
|
||||
}
|
||||
else -> return super.onCreateViewHolder(parent, viewType)
|
||||
}
|
||||
}
|
||||
}
|
Before Width: | Height: | Size: 7.8 KiB |
BIN
app/src/main/res/drawable-hdpi/ic_book_white_24dp.png
Normal file
After Width: | Height: | Size: 180 B |
BIN
app/src/main/res/drawable-hdpi/tachiyomi_circle.png
Normal file
After Width: | Height: | Size: 5.1 KiB |
Before Width: | Height: | Size: 4.5 KiB |
BIN
app/src/main/res/drawable-mdpi/ic_book_white_24dp.png
Normal file
After Width: | Height: | Size: 136 B |
BIN
app/src/main/res/drawable-mdpi/tachiyomi_circle.png
Normal file
After Width: | Height: | Size: 3.1 KiB |
Before Width: | Height: | Size: 12 KiB |
BIN
app/src/main/res/drawable-xhdpi/ic_book_white_24dp.png
Normal file
After Width: | Height: | Size: 197 B |
BIN
app/src/main/res/drawable-xhdpi/tachiyomi_circle.png
Normal file
After Width: | Height: | Size: 7.0 KiB |
Before Width: | Height: | Size: 22 KiB |
BIN
app/src/main/res/drawable-xxhdpi/ic_book_white_24dp.png
Normal file
After Width: | Height: | Size: 283 B |
BIN
app/src/main/res/drawable-xxhdpi/tachiyomi_circle.png
Normal file
After Width: | Height: | Size: 11 KiB |
Before Width: | Height: | Size: 25 KiB |
BIN
app/src/main/res/drawable-xxxhdpi/ic_book_white_24dp.png
Normal file
After Width: | Height: | Size: 350 B |
BIN
app/src/main/res/drawable-xxxhdpi/tachiyomi_circle.png
Normal file
After Width: | Height: | Size: 16 KiB |
@ -1,10 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:opacity="opaque">
|
||||
<item android:drawable="@color/background_material_light"/>
|
||||
<item android:drawable="@color/material_grey_50"/>
|
||||
<item>
|
||||
<bitmap
|
||||
android:src="@drawable/application_logo_144dp"
|
||||
android:src="@drawable/branded_logo_icon"
|
||||
android:gravity="center"/>
|
||||
</item>
|
||||
</layer-list>
|
BIN
app/src/main/res/drawable/branded_logo_icon.png
Normal file
After Width: | Height: | Size: 22 KiB |
Before Width: | Height: | Size: 16 KiB |
19
app/src/main/res/drawable/sc_book_48dp.xml
Normal file
@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="48dp"
|
||||
android:height="48dp"
|
||||
android:viewportHeight="48"
|
||||
android:viewportWidth="48">
|
||||
|
||||
<path
|
||||
android:fillColor="@color/backgroundLight"
|
||||
android:pathData="M24,24m-22,0a22,22 0,1 1,44 0a22,22 0,1 1,-44 0" />
|
||||
|
||||
<group
|
||||
android:translateX="12"
|
||||
android:translateY="12">
|
||||
<path
|
||||
android:fillColor="@color/colorPrimary"
|
||||
android:pathData="M18,2H6c-1.1,0 -2,0.9 -2,2v16c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2V4c0,-1.1 -0.9,-2 -2,-2zM6,4h5v8l-2.5,-1.5L6,12V4z" />
|
||||
</group>
|
||||
</vector>
|
19
app/src/main/res/drawable/sc_explore_48dp.xml
Normal file
@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="48dp"
|
||||
android:height="48dp"
|
||||
android:viewportHeight="48"
|
||||
android:viewportWidth="48">
|
||||
|
||||
<path
|
||||
android:fillColor="@color/backgroundLight"
|
||||
android:pathData="M24,24m-22,0a22,22 0,1 1,44 0a22,22 0,1 1,-44 0" />
|
||||
|
||||
<group
|
||||
android:translateX="12"
|
||||
android:translateY="12">
|
||||
<path
|
||||
android:fillColor="@color/colorPrimary"
|
||||
android:pathData="M12,10.9c-0.61,0 -1.1,0.49 -1.1,1.1s0.49,1.1 1.1,1.1c0.61,0 1.1,-0.49 1.1,-1.1s-0.49,-1.1 -1.1,-1.1zM12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM14.19,14.19L6,18l3.81,-8.19L18,6l-3.81,8.19z" />
|
||||
</group>
|
||||
</vector>
|
19
app/src/main/res/drawable/sc_glasses_48dp.xml
Normal file
@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="48dp"
|
||||
android:height="48dp"
|
||||
android:viewportHeight="48"
|
||||
android:viewportWidth="48">
|
||||
|
||||
<path
|
||||
android:fillColor="@color/backgroundLight"
|
||||
android:pathData="M24,24m-22,0a22,22 0,1 1,44 0a22,22 0,1 1,-44 0" />
|
||||
|
||||
<group
|
||||
android:translateX="12"
|
||||
android:translateY="12">
|
||||
<path
|
||||
android:fillColor="@color/colorPrimary"
|
||||
android:pathData="M3,10C2.76,10 2.55,10.09 2.41,10.25C2.27,10.4 2.21,10.62 2.24,10.86L2.74,13.85C2.82,14.5 3.4,15 4,15H7C7.64,15 8.36,14.44 8.5,13.82L9.56,10.63C9.6,10.5 9.57,10.31 9.5,10.19C9.39,10.07 9.22,10 9,10H3M7,17H4C2.38,17 0.96,15.74 0.76,14.14L0.26,11.15C0.15,10.3 0.39,9.5 0.91,8.92C1.43,8.34 2.19,8 3,8H9C9.83,8 10.58,8.35 11.06,8.96C11.17,9.11 11.27,9.27 11.35,9.45C11.78,9.36 12.22,9.36 12.64,9.45C12.72,9.27 12.82,9.11 12.94,8.96C13.41,8.35 14.16,8 15,8H21C21.81,8 22.57,8.34 23.09,8.92C23.6,9.5 23.84,10.3 23.74,11.11L23.23,14.18C23.04,15.74 21.61,17 20,17H17C15.44,17 13.92,15.81 13.54,14.3L12.64,11.59C12.26,11.31 11.73,11.31 11.35,11.59L10.43,14.37C10.07,15.82 8.56,17 7,17M15,10C14.78,10 14.61,10.07 14.5,10.19C14.42,10.31 14.4,10.5 14.45,10.7L15.46,13.75C15.64,14.44 16.36,15 17,15H20C20.59,15 21.18,14.5 21.25,13.89L21.76,10.82C21.79,10.62 21.73,10.4 21.59,10.25C21.45,10.09 21.24,10 21,10H15Z" />
|
||||
</group>
|
||||
</vector>
|
19
app/src/main/res/drawable/sc_update_48dp.xml
Normal file
@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="48dp"
|
||||
android:height="48dp"
|
||||
android:viewportHeight="48"
|
||||
android:viewportWidth="48">
|
||||
|
||||
<path
|
||||
android:fillColor="@color/backgroundLight"
|
||||
android:pathData="M24,24m-22,0a22,22 0,1 1,44 0a22,22 0,1 1,-44 0" />
|
||||
|
||||
<group
|
||||
android:translateX="12"
|
||||
android:translateY="12">
|
||||
<path
|
||||
android:fillColor="@color/colorPrimary"
|
||||
android:pathData="M21,10.12h-6.78l2.74,-2.82c-2.73,-2.7 -7.15,-2.8 -9.88,-0.1 -2.73,2.71 -2.73,7.08 0,9.79 2.73,2.71 7.15,2.71 9.88,0C18.32,15.65 19,14.08 19,12.1h2c0,1.98 -0.88,4.55 -2.64,6.29 -3.51,3.48 -9.21,3.48 -12.72,0 -3.5,-3.47 -3.53,-9.11 -0.02,-12.58 3.51,-3.47 9.14,-3.47 12.65,0L21,3v7.12zM12.5,8v4.25l3.5,2.08 -0.72,1.21L11,13V8h1.5z" />
|
||||
</group>
|
||||
</vector>
|
@ -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"
|
||||
|
@ -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>
|
||||
|
@ -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>
|