Compare commits

...

52 Commits

Author SHA1 Message Date
len
9bcde69ee0 Release 0.4.2 2017-01-01 21:00:52 +01:00
len
beca2b429c Minor changes 2017-01-01 20:54:41 +01:00
len
3a1699f0b3 Fix #373 and a few crashes 2016-12-31 16:19:32 +01:00
len
a7192e866f Locale fix. Kotlin update to 1.0.6 2016-12-27 20:18:38 +01:00
len
dc882b4dce Make clear error codes are from HTTP 2016-12-26 18:12:15 +01:00
len
77b4de3941 Minor changes 2016-12-26 17:21:17 +01:00
len
006d17aac7 Fix locale not applied outside activities 2016-12-26 16:56:19 +01:00
len
1a3a1db4ff Remove Language class. App's language and hidden languages settings were reset 2016-12-26 15:44:59 +01:00
len
97fa659283 Kitsu fixes 2016-12-24 11:32:45 +01:00
f1d84ccb49 Add "Completed" filter; fix Mangahere; fix Mangafox (#604)
* Add "Compled" filter to all english sources; fix Mangahere manga title extraction; fix Mangafox search.

* update Mangasee

* update Batoto
2016-12-24 00:08:49 +01:00
1f14240251 Translated some strings to Italian (#602)
* Translated some strings to Italian

* Added missing strings and fixed a couple of errors
2016-12-24 00:08:27 +01:00
len
ea6fed6ecf Exclude novels from Kitsu results 2016-12-23 16:58:36 +01:00
len
d09eca7833 Anilist/Kitsu Fixes 2016-12-23 16:15:09 +01:00
2c6f64c5ae Refresh option in the library updates tab (#606)
* Solves #550

* Make sure only refresh can only happen when pulling down at top of update library list

* Removed unused import
2016-12-23 15:58:53 +01:00
ec87e4359b Drawerfix/readme update (#601)
* Fixed back button on navigational drawers

* Removing an unused import

* Cleaned up code

* little clean up
2016-12-23 15:56:10 +01:00
len
2b63bae989 Show login errors 2016-12-22 22:11:17 +01:00
len
82f4e3157a Minor changes 2016-12-22 21:57:15 +01:00
len
725ceab00b Hide API implementation from MAL service. Reorder methods and minor changes 2016-12-22 21:17:47 +01:00
len
ba428c401d Fix Kitsu refresh method 2016-12-22 16:34:34 +01:00
len
510669ee2c Fix wrong anilist decimal scores 2016-12-22 16:22:08 +01:00
len
8d749df290 Score formatting. Hide API from Anilist/Kitsu services. 2016-12-21 22:39:46 +01:00
len
091c0c0c71 Fix system language setting always using english 2016-12-21 00:42:46 +01:00
7fdd2cacd7 Fixed updater on Android N. Closes #592 (#595) 2016-12-21 00:34:31 +01:00
2241a0b2de Using title instead of text for Mangahere titles (#591)
Fixes #571
The text on the popular manga page of Mangahere contains escaped HTML characters. The title attributes of the links do not contain them.
2016-12-20 20:37:45 +01:00
len
d21a93123b Dependency updates 2016-12-20 18:58:21 +01:00
len
e542a8d8e2 Fix tab gravity 2016-12-20 16:54:56 +01:00
94ee4e7fb5 Experimental Anilist and Kitsu support (#586)
* Tracking tab with anilist support

* Rename MangaSync to Track

* Rename variables and methods to track

* Kitsu implementation

* Variables refactoring

* Travis fix?
2016-12-18 22:56:28 +01:00
len
e3d430eb5e Fix #587 2016-12-18 22:31:20 +01:00
len
fd76255cf6 Release 0.4.1 2016-12-18 21:05:33 +01:00
len
d180631877 Add ripple effect to filter nav view 2016-12-18 20:29:46 +01:00
len
1977e21363 Fix method conflicts 2016-12-18 16:59:06 +01:00
len
e1a3ee1b81 Bugfixes 2016-12-18 16:35:39 +01:00
cc43d9daed fixes wrong getBroadcast calls from imageNotification (#585) 2016-12-18 15:15:44 +01:00
len
79705df499 Apply material design guidelines to categories 2016-12-18 13:08:56 +01:00
len
36bbb906c1 Library sort change doesn't trigger filtering 2016-12-15 18:51:12 +01:00
len
816cc17ed3 Fix #577. Fix language not applied in reader activity. 2016-12-14 22:33:24 +01:00
len
97e3b5d2ab Add unread sorting 2016-12-13 22:23:49 +01:00
79ab9d80f2 Improved last_read sorting (#576) 2016-12-13 21:36:26 +01:00
len
32511149d1 Format fixes. Move lang setting to the first entry (looks better IMO) 2016-12-13 21:07:48 +01:00
cc9fd53abb Implement language switcher (#563)
* Implement language switching using BaseActivity

* Add requested changes

* Cleanup App.kt Imports and add pref_language_key

* Acutally use @string for key

* Use string resource for language preference title
2016-12-13 20:47:46 +01:00
len
4061c7450b Better network error handling 2016-12-12 20:53:44 +01:00
len
9ad535bde6 Optimize library downloaded filter 2016-12-11 23:59:25 +01:00
b067096fc7 Add drawer to filter and sort the library (#570)
* Add additional drawer to filter and sort the library

* Tint icon when there's a filter active

* Comments and minor changes
2016-12-11 12:43:44 +01:00
len
2dd58e5f7d Ask for confirmation before changing the cover. Fixes #562 2016-12-10 23:16:46 +01:00
len
7c42ab885b Readers know how to move to each side. Fix #566 2016-12-10 14:49:56 +01:00
len
26b283d44d Fix webtoon reader touch events. #561 2016-12-10 14:01:16 +01:00
len
8c1b07c4ba Handle null directories as empty arrays 2016-12-10 12:22:44 +01:00
len
f98e0858a7 Improve download discovery performance in library updates view 2016-12-09 20:23:48 +01:00
8b60d5bfcb Add optional to automatically download new chapers (#538)
* Add optional to automatically download new chapers

* Only trigger download once
2016-12-06 17:22:03 +01:00
len
30b4c6e755 Remove some state from the library view 2016-12-04 23:58:46 +01:00
len
3d2a98451b Avoid going to db when a library filter is changed 2016-12-04 23:48:29 +01:00
aba528b227 Added option to sort library (#536)
* Initial code

* Added all sort options

* Fixes

* Removed sort by added. Some renaming

* Removed date added database calls

* Fixes
2016-12-04 20:22:12 +01:00
158 changed files with 4305 additions and 2339 deletions

View File

@ -12,11 +12,21 @@ android:
- extra-android-support
- extra-google-google_play_services
licenses:
- android-sdk-license-.+
- '.+'
jdk:
- oraclejdk8
before_script:
- chmod +x gradlew
- chmod +x gradlew
before_install:
- mkdir "$ANDROID_HOME/licenses" || true
- echo -e "\n8933bad161af4178b1185d1a37fbf41ea5269c55" > "$ANDROID_HOME/licenses/android-sdk-license"
- echo -e "\n84831b9409646a918e30573bab4c9c91346d8abd" > "$ANDROID_HOME/licenses/android-sdk-preview-license"
#Build, and run tests
script: "./gradlew clean buildStandardDebug"
sudo: false

View File

@ -38,8 +38,8 @@ android {
minSdkVersion 16
targetSdkVersion 25
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
versionCode 16
versionName "0.4.0"
versionCode 18
versionName "0.4.2"
buildConfigField "String", "COMMIT_COUNT", "\"${getCommitCount()}\""
buildConfigField "String", "COMMIT_SHA", "\"${getGitSha()}\""
@ -98,7 +98,7 @@ android {
dependencies {
// Modified dependencies
compile 'com.github.inorichi:subsampling-scale-image-view:f687b74'
compile 'com.github.inorichi:subsampling-scale-image-view:c4db85c'
// Android support library
final support_library_version = '25.0.1'
@ -110,14 +110,16 @@ 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-beta4'
compile 'com.android.support:multidex:1.0.1'
// ReactiveX
compile 'io.reactivex:rxandroid:1.2.1'
compile 'io.reactivex:rxjava:1.2.3'
compile 'io.reactivex:rxjava:1.2.4'
compile 'com.jakewharton.rxrelay:rxrelay:1.2.0'
compile 'com.f2prateek.rx.preferences:rx-preferences:1.0.2'
compile 'com.github.pwittchen:reactivenetwork:0.6.0'
compile 'com.github.pwittchen:reactivenetwork:0.7.0'
// Network client
compile "com.squareup.okhttp3:okhttp:3.5.0"
@ -172,7 +174,7 @@ dependencies {
compile 'jp.wasabeef:glide-transformations:2.0.1'
// Logging
compile 'com.jakewharton.timber:timber:4.3.1'
compile 'com.jakewharton.timber:timber:4.4.0'
// Crash reports
compile 'ch.acra:acra:4.9.1'
@ -202,7 +204,7 @@ dependencies {
}
buildscript {
ext.kotlin_version = '1.0.5-2'
ext.kotlin_version = '1.0.6'
repositories {
mavenCentral()
}

View File

@ -53,6 +53,18 @@
android:label="@string/app_name"
android:theme="@style/FilePickerTheme">
</activity>
<activity
android:name=".ui.setting.AnilistLoginActivity"
android:label="Anilist">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="anilist-auth"
android:scheme="tachiyomi" />
</intent-filter>
</activity>
<provider
android:name="android.support.v4.content.FileProvider"
@ -70,7 +82,7 @@
<service android:name=".data.download.DownloadService"
android:exported="false"/>
<service android:name=".data.mangasync.UpdateMangaSyncService"
<service android:name=".data.track.TrackUpdateService"
android:exported="false"/>
<service android:name=".data.updater.UpdateDownloaderService"

View File

@ -2,10 +2,12 @@ package eu.kanade.tachiyomi
import android.app.Application
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.library.LibraryUpdateJob
import eu.kanade.tachiyomi.data.updater.UpdateCheckerJob
import eu.kanade.tachiyomi.util.LocaleHelper
import org.acra.ACRA
import org.acra.annotation.ReportsCrashes
import timber.log.Timber
@ -18,7 +20,7 @@ import uy.kohesive.injekt.registry.default.DefaultRegistrar
reportType = org.acra.sender.HttpSender.Type.JSON,
httpMethod = org.acra.sender.HttpSender.Method.PUT,
buildConfigClass = BuildConfig::class,
excludeMatchingSharedPreferencesKeys = arrayOf(".*username.*", ".*password.*")
excludeMatchingSharedPreferencesKeys = arrayOf(".*username.*", ".*password.*", ".*token.*")
)
open class App : Application() {
@ -31,6 +33,8 @@ open class App : Application() {
setupAcra()
setupJobManager()
LocaleHelper.updateConfiguration(this, resources.configuration)
}
override fun attachBaseContext(base: Context) {
@ -40,6 +44,11 @@ open class App : Application() {
}
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
LocaleHelper.updateConfiguration(this, newConfig, true)
}
protected open fun setupAcra() {
ACRA.init(this)
}

View File

@ -6,7 +6,7 @@ import eu.kanade.tachiyomi.data.cache.ChapterCache
import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.mangasync.MangaSyncManager
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.network.NetworkHelper
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.source.SourceManager
@ -32,7 +32,7 @@ class AppModule(val app: Application) : InjektModule {
addSingletonFactory { DownloadManager(app) }
addSingletonFactory { MangaSyncManager(app) }
addSingletonFactory { TrackManager(app) }
addSingletonFactory { Gson() }

View File

@ -6,4 +6,5 @@ object Constants {
const val NOTIFICATION_DOWNLOAD_CHAPTER_ID = 3
const val NOTIFICATION_DOWNLOAD_CHAPTER_ERROR_ID = 4
const val NOTIFICATION_DOWNLOAD_IMAGE_ID = 5
}

View File

@ -39,7 +39,7 @@ class BackupManager(private val db: DatabaseHelper) {
private val MANGA = "manga"
private val MANGAS = "mangas"
private val CHAPTERS = "chapters"
private val MANGA_SYNC = "sync"
private val TRACK = "sync"
private val CATEGORIES = "categories"
@Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN")
@ -109,10 +109,10 @@ class BackupManager(private val db: DatabaseHelper) {
entry.add(CHAPTERS, gson.toJsonTree(chapters))
}
// Backup manga sync
val mangaSync = db.getMangasSync(manga).executeAsBlocking()
if (!mangaSync.isEmpty()) {
entry.add(MANGA_SYNC, gson.toJsonTree(mangaSync))
// Backup tracks
val tracks = db.getTracks(manga).executeAsBlocking()
if (!tracks.isEmpty()) {
entry.add(TRACK, gson.toJsonTree(tracks))
}
// Backup categories for this manga
@ -231,13 +231,13 @@ class BackupManager(private val db: DatabaseHelper) {
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 sync = gson.fromJson<List<MangaSyncImpl>>(element.get(MANGA_SYNC) ?: 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, sync)
restoreSyncForManga(manga, tracks)
restoreCategoriesForManga(manga, categories)
}
}
@ -333,35 +333,35 @@ class BackupManager(private val db: DatabaseHelper) {
* Restores the sync of a manga.
*
* @param manga the manga whose sync have to be restored.
* @param sync the sync to restore.
* @param tracks the track list to restore.
*/
private fun restoreSyncForManga(manga: Manga, sync: List<MangaSync>) {
private fun restoreSyncForManga(manga: Manga, tracks: List<Track>) {
// Fix foreign keys with the current manga id
for (mangaSync in sync) {
mangaSync.manga_id = manga.id!!
for (track in tracks) {
track.manga_id = manga.id!!
}
val dbSyncs = db.getMangasSync(manga).executeAsBlocking()
val syncToUpdate = ArrayList<MangaSync>()
for (backupSync in sync) {
val dbTracks = db.getTracks(manga).executeAsBlocking()
val trackToUpdate = ArrayList<Track>()
for (backupTrack in tracks) {
// Try to find existing chapter in db
val pos = dbSyncs.indexOf(backupSync)
val pos = dbTracks.indexOf(backupTrack)
if (pos != -1) {
// The sync is already in the db, only update its fields
val dbSync = dbSyncs[pos]
val dbSync = dbTracks[pos]
// Mark the max chapter as read and nothing else
dbSync.last_chapter_read = Math.max(backupSync.last_chapter_read, dbSync.last_chapter_read)
syncToUpdate.add(dbSync)
dbSync.last_chapter_read = Math.max(backupTrack.last_chapter_read, dbSync.last_chapter_read)
trackToUpdate.add(dbSync)
} else {
// Insert new sync. Let the db assign the id
backupSync.id = null
syncToUpdate.add(backupSync)
backupTrack.id = null
trackToUpdate.add(backupTrack)
}
}
// Update database
if (!syncToUpdate.isEmpty()) {
db.insertMangasSync(syncToUpdate).executeAsBlocking()
if (!trackToUpdate.isEmpty()) {
db.insertTracks(trackToUpdate).executeAsBlocking()
}
}

View File

@ -5,7 +5,7 @@ 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.MangaSyncImpl
import eu.kanade.tachiyomi.data.database.models.TrackImpl
class IdExclusion : ExclusionStrategy {
@ -17,7 +17,7 @@ class IdExclusion : ExclusionStrategy {
override fun shouldSkipField(f: FieldAttributes) = when (f.declaringClass) {
MangaImpl::class.java -> mangaExclusions.contains(f.name)
ChapterImpl::class.java -> chapterExclusions.contains(f.name)
MangaSyncImpl::class.java -> syncExclusions.contains(f.name)
TrackImpl::class.java -> syncExclusions.contains(f.name)
CategoryImpl::class.java -> categoryExclusions.contains(f.name)
else -> false
}

View File

@ -10,13 +10,13 @@ import eu.kanade.tachiyomi.data.database.queries.*
* This class provides operations to manage the database through its interfaces.
*/
open class DatabaseHelper(context: Context)
: MangaQueries, ChapterQueries, MangaSyncQueries, CategoryQueries, MangaCategoryQueries, HistoryQueries {
: MangaQueries, ChapterQueries, TrackQueries, CategoryQueries, MangaCategoryQueries, HistoryQueries {
override val db = DefaultStorIOSQLite.builder()
.sqliteOpenHelper(DbOpenHelper(context))
.addTypeMapping(Manga::class.java, MangaTypeMapping())
.addTypeMapping(Chapter::class.java, ChapterTypeMapping())
.addTypeMapping(MangaSync::class.java, MangaSyncTypeMapping())
.addTypeMapping(Track::class.java, TrackTypeMapping())
.addTypeMapping(Category::class.java, CategoryTypeMapping())
.addTypeMapping(MangaCategory::class.java, MangaCategoryTypeMapping())
.addTypeMapping(History::class.java, HistoryTypeMapping())

View File

@ -23,7 +23,7 @@ class DbOpenHelper(context: Context)
override fun onCreate(db: SQLiteDatabase) = with(db) {
execSQL(MangaTable.createTableQuery)
execSQL(ChapterTable.createTableQuery)
execSQL(MangaSyncTable.createTableQuery)
execSQL(TrackTable.createTableQuery)
execSQL(CategoryTable.createTableQuery)
execSQL(MangaCategoryTable.createTableQuery)
execSQL(HistoryTable.createTableQuery)

View File

@ -9,38 +9,38 @@ import com.pushtorefresh.storio.sqlite.operations.put.DefaultPutResolver
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.MangaSync
import eu.kanade.tachiyomi.data.database.models.MangaSyncImpl
import eu.kanade.tachiyomi.data.database.tables.MangaSyncTable.COL_ID
import eu.kanade.tachiyomi.data.database.tables.MangaSyncTable.COL_LAST_CHAPTER_READ
import eu.kanade.tachiyomi.data.database.tables.MangaSyncTable.COL_MANGA_ID
import eu.kanade.tachiyomi.data.database.tables.MangaSyncTable.COL_REMOTE_ID
import eu.kanade.tachiyomi.data.database.tables.MangaSyncTable.COL_SCORE
import eu.kanade.tachiyomi.data.database.tables.MangaSyncTable.COL_STATUS
import eu.kanade.tachiyomi.data.database.tables.MangaSyncTable.COL_SYNC_ID
import eu.kanade.tachiyomi.data.database.tables.MangaSyncTable.COL_TITLE
import eu.kanade.tachiyomi.data.database.tables.MangaSyncTable.COL_TOTAL_CHAPTERS
import eu.kanade.tachiyomi.data.database.tables.MangaSyncTable.TABLE
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.database.models.TrackImpl
import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_ID
import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_LAST_CHAPTER_READ
import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_MANGA_ID
import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_REMOTE_ID
import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_SCORE
import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_STATUS
import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_SYNC_ID
import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_TITLE
import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_TOTAL_CHAPTERS
import eu.kanade.tachiyomi.data.database.tables.TrackTable.TABLE
class MangaSyncTypeMapping : SQLiteTypeMapping<MangaSync>(
MangaSyncPutResolver(),
MangaSyncGetResolver(),
MangaSyncDeleteResolver()
class TrackTypeMapping : SQLiteTypeMapping<Track>(
TrackPutResolver(),
TrackGetResolver(),
TrackDeleteResolver()
)
class MangaSyncPutResolver : DefaultPutResolver<MangaSync>() {
class TrackPutResolver : DefaultPutResolver<Track>() {
override fun mapToInsertQuery(obj: MangaSync) = InsertQuery.builder()
override fun mapToInsertQuery(obj: Track) = InsertQuery.builder()
.table(TABLE)
.build()
override fun mapToUpdateQuery(obj: MangaSync) = UpdateQuery.builder()
override fun mapToUpdateQuery(obj: Track) = UpdateQuery.builder()
.table(TABLE)
.where("$COL_ID = ?")
.whereArgs(obj.id)
.build()
override fun mapToContentValues(obj: MangaSync) = ContentValues(9).apply {
override fun mapToContentValues(obj: Track) = ContentValues(9).apply {
put(COL_ID, obj.id)
put(COL_MANGA_ID, obj.manga_id)
put(COL_SYNC_ID, obj.sync_id)
@ -53,9 +53,9 @@ class MangaSyncPutResolver : DefaultPutResolver<MangaSync>() {
}
}
class MangaSyncGetResolver : DefaultGetResolver<MangaSync>() {
class TrackGetResolver : DefaultGetResolver<Track>() {
override fun mapFromCursor(cursor: Cursor): MangaSync = MangaSyncImpl().apply {
override fun mapFromCursor(cursor: Cursor): Track = TrackImpl().apply {
id = cursor.getLong(cursor.getColumnIndex(COL_ID))
manga_id = cursor.getLong(cursor.getColumnIndex(COL_MANGA_ID))
sync_id = cursor.getInt(cursor.getColumnIndex(COL_SYNC_ID))
@ -68,9 +68,9 @@ class MangaSyncGetResolver : DefaultGetResolver<MangaSync>() {
}
}
class MangaSyncDeleteResolver : DefaultDeleteResolver<MangaSync>() {
class TrackDeleteResolver : DefaultDeleteResolver<Track>() {
override fun mapToDeleteQuery(obj: MangaSync) = DeleteQuery.builder()
override fun mapToDeleteQuery(obj: Track) = DeleteQuery.builder()
.table(TABLE)
.where("$COL_ID = ?")
.whereArgs(obj.id)

View File

@ -2,7 +2,7 @@ package eu.kanade.tachiyomi.data.database.models
import java.io.Serializable
interface MangaSync : Serializable {
interface Track : Serializable {
var id: Long?
@ -24,7 +24,7 @@ interface MangaSync : Serializable {
var update: Boolean
fun copyPersonalFrom(other: MangaSync) {
fun copyPersonalFrom(other: Track) {
last_chapter_read = other.last_chapter_read
score = other.score
status = other.status
@ -32,7 +32,7 @@ interface MangaSync : Serializable {
companion object {
fun create(serviceId: Int): MangaSync = MangaSyncImpl().apply {
fun create(serviceId: Int): Track = TrackImpl().apply {
sync_id = serviceId
}
}

View File

@ -1,6 +1,6 @@
package eu.kanade.tachiyomi.data.database.models
class MangaSyncImpl : MangaSync {
class TrackImpl : Track {
override var id: Long? = null
@ -26,11 +26,11 @@ class MangaSyncImpl : MangaSync {
if (this === other) return true
if (other == null || javaClass != other.javaClass) return false
val mangaSync = other as MangaSync
other as Track
if (manga_id != mangaSync.manga_id) return false
if (sync_id != mangaSync.sync_id) return false
return remote_id == mangaSync.remote_id
if (manga_id != other.manga_id) return false
if (sync_id != other.sync_id) return false
return remote_id == other.remote_id
}
override fun hashCode(): Int {

View File

@ -40,7 +40,6 @@ interface HistoryQueries : DbProvider {
.build())
.prepare()
/**
* Updates the history last read.
* Inserts history object if not yet in database

View File

@ -7,6 +7,7 @@ import eu.kanade.tachiyomi.data.database.DbProvider
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.resolvers.LibraryMangaGetResolver
import eu.kanade.tachiyomi.data.database.resolvers.MangaFlagsPutResolver
import eu.kanade.tachiyomi.data.database.resolvers.MangaLastUpdatedPutResolver
import eu.kanade.tachiyomi.data.database.tables.ChapterTable
import eu.kanade.tachiyomi.data.database.tables.MangaCategoryTable
import eu.kanade.tachiyomi.data.database.tables.MangaTable
@ -29,7 +30,7 @@ interface MangaQueries : DbProvider {
.withGetResolver(LibraryMangaGetResolver.INSTANCE)
.prepare()
open fun getFavoriteMangas() = db.get()
fun getFavoriteMangas() = db.get()
.listOfObjects(Manga::class.java)
.withQuery(Query.builder()
.table(MangaTable.TABLE)
@ -66,6 +67,11 @@ interface MangaQueries : DbProvider {
.withPutResolver(MangaFlagsPutResolver())
.prepare()
fun updateLastUpdated(manga: Manga) = db.put()
.`object`(manga)
.withPutResolver(MangaLastUpdatedPutResolver())
.prepare()
fun deleteManga(manga: Manga) = db.delete().`object`(manga).prepare()
fun deleteMangas(mangas: List<Manga>) = db.delete().objects(mangas).prepare()
@ -78,4 +84,11 @@ interface MangaQueries : DbProvider {
.build())
.prepare()
fun getLastReadManga() = db.get()
.listOfObjects(Manga::class.java)
.withQuery(RawQuery.builder()
.query(getLastReadMangaQuery())
.observesTables(MangaTable.TABLE)
.build())
.prepare()
}

View File

@ -1,46 +0,0 @@
package eu.kanade.tachiyomi.data.database.queries
import com.pushtorefresh.storio.sqlite.queries.DeleteQuery
import com.pushtorefresh.storio.sqlite.queries.Query
import eu.kanade.tachiyomi.data.database.DbProvider
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.models.MangaSync
import eu.kanade.tachiyomi.data.database.tables.MangaSyncTable
import eu.kanade.tachiyomi.data.mangasync.MangaSyncService
interface MangaSyncQueries : DbProvider {
fun getMangaSync(manga: Manga, sync: MangaSyncService) = db.get()
.`object`(MangaSync::class.java)
.withQuery(Query.builder()
.table(MangaSyncTable.TABLE)
.where("${MangaSyncTable.COL_MANGA_ID} = ? AND " +
"${MangaSyncTable.COL_SYNC_ID} = ?")
.whereArgs(manga.id, sync.id)
.build())
.prepare()
fun getMangasSync(manga: Manga) = db.get()
.listOfObjects(MangaSync::class.java)
.withQuery(Query.builder()
.table(MangaSyncTable.TABLE)
.where("${MangaSyncTable.COL_MANGA_ID} = ?")
.whereArgs(manga.id)
.build())
.prepare()
fun insertMangaSync(manga: MangaSync) = db.put().`object`(manga).prepare()
fun insertMangasSync(mangas: List<MangaSync>) = db.put().objects(mangas).prepare()
fun deleteMangaSync(manga: MangaSync) = db.delete().`object`(manga).prepare()
fun deleteMangaSyncForManga(manga: Manga) = db.delete()
.byQuery(DeleteQuery.builder()
.table(MangaSyncTable.TABLE)
.where("${MangaSyncTable.COL_MANGA_ID} = ?")
.whereArgs(manga.id)
.build())
.prepare()
}

View File

@ -73,6 +73,18 @@ fun getHistoryByMangaId() = """
WHERE ${Chapter.TABLE}.${Chapter.COL_MANGA_ID} = ? 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}
JOIN ${Chapter.TABLE}
ON ${Manga.TABLE}.${Manga.COL_ID} = ${Chapter.TABLE}.${Chapter.COL_MANGA_ID}
JOIN ${History.TABLE}
ON ${Chapter.TABLE}.${Chapter.COL_ID} = ${History.TABLE}.${History.COL_CHAPTER_ID}
WHERE ${Manga.TABLE}.${Manga.COL_FAVORITE} = 1
GROUP BY ${Manga.TABLE}.${Manga.COL_ID}
ORDER BY max DESC
"""
/**
* Query to get the categories for a manga.
*/

View File

@ -0,0 +1,34 @@
package eu.kanade.tachiyomi.data.database.queries
import com.pushtorefresh.storio.sqlite.queries.DeleteQuery
import com.pushtorefresh.storio.sqlite.queries.Query
import eu.kanade.tachiyomi.data.database.DbProvider
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.database.tables.TrackTable
import eu.kanade.tachiyomi.data.track.TrackService
interface TrackQueries : DbProvider {
fun getTracks(manga: Manga) = db.get()
.listOfObjects(Track::class.java)
.withQuery(Query.builder()
.table(TrackTable.TABLE)
.where("${TrackTable.COL_MANGA_ID} = ?")
.whereArgs(manga.id)
.build())
.prepare()
fun insertTrack(track: Track) = db.put().`object`(track).prepare()
fun insertTracks(tracks: List<Track>) = db.put().objects(tracks).prepare()
fun deleteTrackForManga(manga: Manga, sync: TrackService) = db.delete()
.byQuery(DeleteQuery.builder()
.table(TrackTable.TABLE)
.where("${TrackTable.COL_MANGA_ID} = ? AND ${TrackTable.COL_SYNC_ID} = ?")
.whereArgs(manga.id, sync.id)
.build())
.prepare()
}

View File

@ -0,0 +1,33 @@
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.Manga
import eu.kanade.tachiyomi.data.database.tables.MangaTable
class MangaLastUpdatedPutResolver : PutResolver<Manga>() {
override fun performPut(db: StorIOSQLite, manga: Manga) = db.inTransactionReturn {
val updateQuery = mapToUpdateQuery(manga)
val contentValues = mapToContentValues(manga)
val numberOfRowsUpdated = db.lowLevel().update(updateQuery, contentValues)
PutResult.newUpdateResult(numberOfRowsUpdated, updateQuery.table())
}
fun mapToUpdateQuery(manga: Manga) = UpdateQuery.builder()
.table(MangaTable.TABLE)
.where("${MangaTable.COL_ID} = ?")
.whereArgs(manga.id)
.build()
fun mapToContentValues(manga: Manga) = ContentValues(1).apply {
put(MangaTable.COL_LAST_UPDATE, manga.last_update)
}
}

View File

@ -1,6 +1,6 @@
package eu.kanade.tachiyomi.data.database.tables
object MangaSyncTable {
object TrackTable {
const val TABLE = "manga_sync"

View File

@ -110,6 +110,15 @@ class DownloadManager(context: Context) {
}
}
/**
* Returns the directory name for a manga.
*
* @param manga the manga to query.
*/
fun getMangaDirName(manga: Manga): String {
return provider.getMangaDirName(manga)
}
/**
* Returns the directory name for the given chapter.
*
@ -119,6 +128,15 @@ class DownloadManager(context: Context) {
return provider.getChapterDirName(chapter)
}
/**
* Returns the download directory for a source if it exists.
*
* @param source the source to query.
*/
fun findSourceDir(source: Source): UniFile? {
return provider.findSourceDir(source)
}
/**
* Returns the directory for the given manga, if it exists.
*

View File

@ -6,6 +6,7 @@ import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.data.source.Source
import eu.kanade.tachiyomi.util.DiskUtil
import uy.kohesive.injekt.injectLazy
@ -26,10 +27,13 @@ class DownloadProvider(private val context: Context) {
/**
* The root directory for downloads.
*/
private lateinit var downloadsDir: UniFile
private var downloadsDir = preferences.downloadsDirectory().getOrDefault().let {
UniFile.fromUri(context, Uri.parse(it))
}
init {
preferences.downloadsDirectory().asObservable()
.skip(1)
.subscribe { downloadsDir = UniFile.fromUri(context, Uri.parse(it)) }
}
@ -45,6 +49,15 @@ class DownloadProvider(private val context: Context) {
.createDirectory(getMangaDirName(manga))
}
/**
* Returns the download directory for a source if it exists.
*
* @param source the source to query.
*/
fun findSourceDir(source: Source): UniFile? {
return downloadsDir.findFile(getSourceDirName(source))
}
/**
* Returns the download directory for a manga if it exists.
*
@ -52,7 +65,7 @@ class DownloadProvider(private val context: Context) {
* @param manga the manga to query.
*/
fun findMangaDir(source: Source, manga: Manga): UniFile? {
val sourceDir = downloadsDir.findFile(getSourceDirName(source))
val sourceDir = findSourceDir(source)
return sourceDir?.findFile(getMangaDirName(manga))
}

View File

@ -13,7 +13,10 @@ import eu.kanade.tachiyomi.Constants
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.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.download.DownloadService
import eu.kanade.tachiyomi.data.library.LibraryUpdateService.Companion.start
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault
@ -53,6 +56,8 @@ class LibraryUpdateService : Service() {
*/
val preferences: PreferencesHelper by injectLazy()
val downloadManager: DownloadManager by injectLazy()
/**
* Wake lock that will be held until the service is destroyed.
*/
@ -243,32 +248,55 @@ class LibraryUpdateService : Service() {
// If there's any error, return empty update and continue.
.onErrorReturn {
failedUpdates.add(manga)
Pair(0, 0)
Pair(emptyList<Chapter>(), emptyList<Chapter>())
}
// Filter out mangas without new chapters (or failed).
.filter { pair -> pair.first > 0 }
.filter { pair -> pair.first.size > 0 }
.doOnNext {
if (preferences.downloadNew()) {
downloadChapters(manga, it.first)
}
}
// Convert to the manga that contains new chapters.
.map { manga }
}
// Add manga with new chapters to the list.
.doOnNext { newUpdates.add(it) }
.doOnNext { manga ->
// Set last updated time
manga.last_update = Date().time
db.updateLastUpdated(manga).executeAsBlocking()
// Add to the list
newUpdates.add(manga)
}
// Notify result of the overall update.
.doOnCompleted {
if (newUpdates.isEmpty()) {
cancelNotification()
} else {
if (preferences.downloadNew()) {
DownloadService.start(this)
}
showResultNotification(newUpdates, failedUpdates)
}
}
}
fun downloadChapters(manga: Manga, chapters: List<Chapter>) {
// we need to get the chapters from the db so we have chapter ids
val mangaChapters = db.getChapters(manga).executeAsBlocking()
val dbChapters = chapters.map {
mangaChapters.find { mangaChapter -> mangaChapter.url == it.url }!!
}
downloadManager.downloadChapters(manga, dbChapters)
}
/**
* Updates the chapters for the given manga and adds them to the database.
*
* @param manga the manga to update.
* @return a pair of the inserted and removed chapters.
*/
fun updateManga(manga: Manga): Observable<Pair<Int, Int>> {
fun updateManga(manga: Manga): Observable<Pair<List<Chapter>, List<Chapter>>> {
val source = sourceManager.get(manga.source) as? OnlineSource ?: return Observable.empty()
return source.fetchChapterList(manga)
.map { syncChaptersWithSource(db, it, manga, source) }

View File

@ -1,23 +0,0 @@
package eu.kanade.tachiyomi.data.mangasync
import android.content.Context
import eu.kanade.tachiyomi.data.mangasync.anilist.Anilist
import eu.kanade.tachiyomi.data.mangasync.myanimelist.MyAnimeList
class MangaSyncManager(private val context: Context) {
companion object {
const val MYANIMELIST = 1
const val ANILIST = 2
}
val myAnimeList = MyAnimeList(context, MYANIMELIST)
val aniList = Anilist(context, ANILIST)
// TODO enable anilist
val services = listOf(myAnimeList)
fun getService(id: Int) = services.find { it.id == id }
}

View File

@ -1,51 +0,0 @@
package eu.kanade.tachiyomi.data.mangasync
import android.content.Context
import android.support.annotation.CallSuper
import eu.kanade.tachiyomi.data.database.models.MangaSync
import eu.kanade.tachiyomi.data.network.NetworkHelper
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import okhttp3.OkHttpClient
import rx.Completable
import rx.Observable
import uy.kohesive.injekt.injectLazy
abstract class MangaSyncService(private val context: Context, val id: Int) {
val preferences: PreferencesHelper by injectLazy()
val networkService: NetworkHelper by injectLazy()
open val client: OkHttpClient
get() = networkService.client
// Name of the manga sync service to display
abstract val name: String
abstract fun login(username: String, password: String): Completable
open val isLogged: Boolean
get() = !getUsername().isEmpty() &&
!getPassword().isEmpty()
abstract fun add(manga: MangaSync): Observable<MangaSync>
abstract fun update(manga: MangaSync): Observable<MangaSync>
abstract fun bind(manga: MangaSync): Observable<MangaSync>
abstract fun getStatus(status: Int): String
fun saveCredentials(username: String, password: String) {
preferences.setMangaSyncCredentials(this, username, password)
}
@CallSuper
open fun logout() {
preferences.setMangaSyncCredentials(this, "", "")
}
fun getUsername() = preferences.mangaSyncUsername(this)
fun getPassword() = preferences.mangaSyncPassword(this)
}

View File

@ -1,132 +0,0 @@
package eu.kanade.tachiyomi.data.mangasync.anilist
import android.content.Context
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.MangaSync
import eu.kanade.tachiyomi.data.mangasync.MangaSyncService
import rx.Completable
import rx.Observable
import timber.log.Timber
class Anilist(private val context: Context, id: Int) : MangaSyncService(context, id) {
companion object {
const val READING = 1
const val COMPLETED = 2
const val ON_HOLD = 3
const val DROPPED = 4
const val PLAN_TO_READ = 5
const val DEFAULT_STATUS = READING
const val DEFAULT_SCORE = 0
}
override val name = "AniList"
private val interceptor by lazy { AnilistInterceptor(getPassword()) }
private val api by lazy {
AnilistApi.createService(networkService.client.newBuilder()
.addInterceptor(interceptor)
.build())
}
override fun login(username: String, password: String) = login(password)
fun login(authCode: String): Completable {
// Create a new api with the default client to avoid request interceptions.
return AnilistApi.createService(client)
// Request the access token from the API with the authorization code.
.requestAccessToken(authCode)
// Save the token in the interceptor.
.doOnNext { interceptor.setAuth(it) }
// Obtain the authenticated user from the API.
.zipWith(api.getCurrentUser().map { it["id"].toString() })
{ oauth, user -> Pair(user, oauth.refresh_token!!) }
// Save service credentials (username and refresh token).
.doOnNext { saveCredentials(it.first, it.second) }
// Logout on any error.
.doOnError { logout() }
.toCompletable()
}
override fun logout() {
super.logout()
interceptor.setAuth(null)
}
fun search(query: String): Observable<List<MangaSync>> {
return api.search(query, 1)
.flatMap { Observable.from(it) }
.filter { it.type != "Novel" }
.map { it.toMangaSync() }
.toList()
}
fun getList(): Observable<List<MangaSync>> {
return api.getList(getUsername())
.flatMap { Observable.from(it.flatten()) }
.map { it.toMangaSync() }
.toList()
}
override fun add(manga: MangaSync): Observable<MangaSync> {
return api.addManga(manga.remote_id, manga.last_chapter_read, manga.getAnilistStatus(),
manga.score.toInt())
.doOnNext { it.body().close() }
.doOnNext { if (!it.isSuccessful) throw Exception("Could not add manga") }
.doOnError { Timber.e(it, it.message) }
.map { manga }
}
override fun update(manga: MangaSync): Observable<MangaSync> {
if (manga.total_chapters != 0 && manga.last_chapter_read == manga.total_chapters) {
manga.status = COMPLETED
}
return api.updateManga(manga.remote_id, manga.last_chapter_read, manga.getAnilistStatus(),
manga.score.toInt())
.doOnNext { it.body().close() }
.doOnNext { if (!it.isSuccessful) throw Exception("Could not update manga") }
.doOnError { Timber.e(it, it.message) }
.map { manga }
}
override fun bind(manga: MangaSync): Observable<MangaSync> {
return getList()
.flatMap { userlist ->
manga.sync_id = id
val mangaFromList = userlist.find { it.remote_id == manga.remote_id }
if (mangaFromList != null) {
manga.copyPersonalFrom(mangaFromList)
update(manga)
} else {
// Set default fields if it's not found in the list
manga.score = DEFAULT_SCORE.toFloat()
manga.status = DEFAULT_STATUS
add(manga)
}
}
}
override fun getStatus(status: Int): String = with(context) {
when (status) {
READING -> getString(R.string.reading)
COMPLETED -> getString(R.string.completed)
ON_HOLD -> getString(R.string.on_hold)
DROPPED -> getString(R.string.dropped)
PLAN_TO_READ -> getString(R.string.plan_to_read)
else -> ""
}
}
private fun MangaSync.getAnilistStatus() = when (status) {
READING -> "reading"
COMPLETED -> "completed"
ON_HOLD -> "on-hold"
DROPPED -> "dropped"
PLAN_TO_READ -> "plan to read"
else -> throw NotImplementedError("Unknown status")
}
}

View File

@ -1,89 +0,0 @@
package eu.kanade.tachiyomi.data.mangasync.anilist
import android.net.Uri
import com.google.gson.JsonObject
import eu.kanade.tachiyomi.data.mangasync.anilist.model.ALManga
import eu.kanade.tachiyomi.data.mangasync.anilist.model.ALUserLists
import eu.kanade.tachiyomi.data.mangasync.anilist.model.OAuth
import eu.kanade.tachiyomi.data.network.POST
import okhttp3.FormBody
import okhttp3.OkHttpClient
import okhttp3.ResponseBody
import retrofit2.Response
import retrofit2.Retrofit
import retrofit2.adapter.rxjava.RxJavaCallAdapterFactory
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.http.*
import rx.Observable
interface AnilistApi {
companion object {
private const val clientId = "tachiyomi-hrtje"
private const val clientSecret = "nlGB5OmgE9YWq5dr3gIDbTQV0C"
private const val clientUrl = "tachiyomi://anilist-auth"
private const val baseUrl = "https://anilist.co/api/"
fun authUrl() = Uri.parse("${baseUrl}auth/authorize").buildUpon()
.appendQueryParameter("grant_type", "authorization_code")
.appendQueryParameter("client_id", clientId)
.appendQueryParameter("redirect_uri", clientUrl)
.appendQueryParameter("response_type", "code")
.build()
fun refreshTokenRequest(token: String) = POST("${baseUrl}auth/access_token",
body = FormBody.Builder()
.add("grant_type", "refresh_token")
.add("client_id", clientId)
.add("client_secret", clientSecret)
.add("refresh_token", token)
.build())
fun createService(client: OkHttpClient) = Retrofit.Builder()
.baseUrl(baseUrl)
.client(client)
.addConverterFactory(GsonConverterFactory.create())
.addCallAdapterFactory(RxJavaCallAdapterFactory.create())
.build()
.create(AnilistApi::class.java)
}
@FormUrlEncoded
@POST("auth/access_token")
fun requestAccessToken(
@Field("code") code: String,
@Field("grant_type") grant_type: String = "authorization_code",
@Field("client_id") client_id: String = clientId,
@Field("client_secret") client_secret: String = clientSecret,
@Field("redirect_uri") redirect_uri: String = clientUrl)
: Observable<OAuth>
@GET("user")
fun getCurrentUser(): Observable<JsonObject>
@GET("manga/search/{query}")
fun search(@Path("query") query: String, @Query("page") page: Int): Observable<List<ALManga>>
@GET("user/{username}/mangalist")
fun getList(@Path("username") username: String): Observable<ALUserLists>
@FormUrlEncoded
@PUT("mangalist")
fun addManga(
@Field("id") id: Int,
@Field("chapters_read") chapters_read: Int,
@Field("list_status") list_status: String,
@Field("score_raw") score_raw: Int)
: Observable<Response<ResponseBody>>
@FormUrlEncoded
@PUT("mangalist")
fun updateManga(
@Field("id") id: Int,
@Field("chapters_read") chapters_read: Int,
@Field("list_status") list_status: String,
@Field("score_raw") score_raw: Int)
: Observable<Response<ResponseBody>>
}

View File

@ -1,17 +0,0 @@
package eu.kanade.tachiyomi.data.mangasync.anilist.model
import eu.kanade.tachiyomi.data.database.models.MangaSync
import eu.kanade.tachiyomi.data.mangasync.MangaSyncManager
data class ALManga(
val id: Int,
val title_romaji: String,
val type: String,
val total_chapters: Int) {
fun toMangaSync() = MangaSync.create(MangaSyncManager.ANILIST).apply {
remote_id = this@ALManga.id
title = title_romaji
total_chapters = this@ALManga.total_chapters
}
}

View File

@ -1,6 +0,0 @@
package eu.kanade.tachiyomi.data.mangasync.anilist.model
data class ALUserLists(val lists: Map<String, List<ALUserManga>>) {
fun flatten() = lists.values.flatten()
}

View File

@ -1,29 +0,0 @@
package eu.kanade.tachiyomi.data.mangasync.anilist.model
import eu.kanade.tachiyomi.data.database.models.MangaSync
import eu.kanade.tachiyomi.data.mangasync.MangaSyncManager
import eu.kanade.tachiyomi.data.mangasync.anilist.Anilist
data class ALUserManga(
val id: Int,
val list_status: String,
val score_raw: Int,
val chapters_read: Int,
val manga: ALManga) {
fun toMangaSync() = MangaSync.create(MangaSyncManager.ANILIST).apply {
remote_id = manga.id
status = getMangaSyncStatus()
score = score_raw.toFloat()
last_chapter_read = chapters_read
}
fun getMangaSyncStatus() = when (list_status) {
"reading" -> Anilist.READING
"completed" -> Anilist.COMPLETED
"on-hold" -> Anilist.ON_HOLD
"dropped" -> Anilist.DROPPED
"plan to read" -> Anilist.PLAN_TO_READ
else -> throw NotImplementedError("Unknown status")
}
}

View File

@ -1,222 +0,0 @@
package eu.kanade.tachiyomi.data.mangasync.myanimelist
import android.content.Context
import android.net.Uri
import android.util.Xml
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.MangaSync
import eu.kanade.tachiyomi.data.mangasync.MangaSyncService
import eu.kanade.tachiyomi.data.network.GET
import eu.kanade.tachiyomi.data.network.POST
import eu.kanade.tachiyomi.data.network.asObservable
import eu.kanade.tachiyomi.util.selectInt
import eu.kanade.tachiyomi.util.selectText
import okhttp3.Credentials
import okhttp3.FormBody
import okhttp3.Headers
import okhttp3.RequestBody
import org.jsoup.Jsoup
import org.xmlpull.v1.XmlSerializer
import rx.Completable
import rx.Observable
import java.io.StringWriter
class MyAnimeList(private val context: Context, id: Int) : MangaSyncService(context, id) {
private lateinit var headers: Headers
companion object {
val BASE_URL = "https://myanimelist.net"
private val ENTRY_TAG = "entry"
private val CHAPTER_TAG = "chapter"
private val SCORE_TAG = "score"
private val STATUS_TAG = "status"
val READING = 1
val COMPLETED = 2
val ON_HOLD = 3
val DROPPED = 4
val PLAN_TO_READ = 6
val DEFAULT_STATUS = READING
val DEFAULT_SCORE = 0
}
init {
val username = getUsername()
val password = getPassword()
if (!username.isEmpty() && !password.isEmpty()) {
createHeaders(username, password)
}
}
override val name: String
get() = "MyAnimeList"
fun getLoginUrl() = Uri.parse(BASE_URL).buildUpon()
.appendEncodedPath("api/account/verify_credentials.xml")
.toString()
fun getSearchUrl(query: String) = Uri.parse(BASE_URL).buildUpon()
.appendEncodedPath("api/manga/search.xml")
.appendQueryParameter("q", query)
.toString()
fun getListUrl(username: String) = Uri.parse(BASE_URL).buildUpon()
.appendPath("malappinfo.php")
.appendQueryParameter("u", username)
.appendQueryParameter("status", "all")
.appendQueryParameter("type", "manga")
.toString()
fun getUpdateUrl(manga: MangaSync) = Uri.parse(BASE_URL).buildUpon()
.appendEncodedPath("api/mangalist/update")
.appendPath("${manga.remote_id}.xml")
.toString()
fun getAddUrl(manga: MangaSync) = Uri.parse(BASE_URL).buildUpon()
.appendEncodedPath("api/mangalist/add")
.appendPath("${manga.remote_id}.xml")
.toString()
override fun login(username: String, password: String): Completable {
createHeaders(username, password)
return client.newCall(GET(getLoginUrl(), headers))
.asObservable()
.doOnNext { it.close() }
.doOnNext { if (it.code() != 200) throw Exception("Login error") }
.toCompletable()
}
fun search(query: String): Observable<List<MangaSync>> {
return client.newCall(GET(getSearchUrl(query), headers))
.asObservable()
.map { Jsoup.parse(it.body().string()) }
.flatMap { Observable.from(it.select("entry")) }
.filter { it.select("type").text() != "Novel" }
.map {
MangaSync.create(id).apply {
title = it.selectText("title")!!
remote_id = it.selectInt("id")
total_chapters = it.selectInt("chapters")
}
}
.toList()
}
// MAL doesn't support score with decimals
fun getList(): Observable<List<MangaSync>> {
return networkService.forceCacheClient
.newCall(GET(getListUrl(getUsername()), headers))
.asObservable()
.map { Jsoup.parse(it.body().string()) }
.flatMap { Observable.from(it.select("manga")) }
.map {
MangaSync.create(id).apply {
title = it.selectText("series_title")!!
remote_id = it.selectInt("series_mangadb_id")
last_chapter_read = it.selectInt("my_read_chapters")
status = it.selectInt("my_status")
score = it.selectInt("my_score").toFloat()
total_chapters = it.selectInt("series_chapters")
}
}
.toList()
}
override fun update(manga: MangaSync): Observable<MangaSync> {
return Observable.defer {
if (manga.total_chapters != 0 && manga.last_chapter_read == manga.total_chapters) {
manga.status = COMPLETED
}
client.newCall(POST(getUpdateUrl(manga), headers, getMangaPostPayload(manga)))
.asObservable()
.doOnNext { it.close() }
.doOnNext { if (!it.isSuccessful) throw Exception("Could not update manga") }
.map { manga }
}
}
override fun add(manga: MangaSync): Observable<MangaSync> {
return Observable.defer {
client.newCall(POST(getAddUrl(manga), headers, getMangaPostPayload(manga)))
.asObservable()
.doOnNext { it.close() }
.doOnNext { if (!it.isSuccessful) throw Exception("Could not add manga") }
.map { manga }
}
}
private fun getMangaPostPayload(manga: MangaSync): RequestBody {
val xml = Xml.newSerializer()
val writer = StringWriter()
with(xml) {
setOutput(writer)
startDocument("UTF-8", false)
startTag("", ENTRY_TAG)
// Last chapter read
if (manga.last_chapter_read != 0) {
inTag(CHAPTER_TAG, manga.last_chapter_read.toString())
}
// Manga status in the list
inTag(STATUS_TAG, manga.status.toString())
// Manga score
inTag(SCORE_TAG, manga.score.toString())
endTag("", ENTRY_TAG)
endDocument()
}
val form = FormBody.Builder()
form.add("data", writer.toString())
return form.build()
}
fun XmlSerializer.inTag(tag: String, body: String, namespace: String = "") {
startTag(namespace, tag)
text(body)
endTag(namespace, tag)
}
override fun bind(manga: MangaSync): Observable<MangaSync> {
return getList()
.flatMap { userlist ->
manga.sync_id = id
val mangaFromList = userlist.find { it.remote_id == manga.remote_id }
if (mangaFromList != null) {
manga.copyPersonalFrom(mangaFromList)
update(manga)
} else {
// Set default fields if it's not found in the list
manga.score = DEFAULT_SCORE.toFloat()
manga.status = DEFAULT_STATUS
add(manga)
}
}
}
override fun getStatus(status: Int): String = with(context) {
when (status) {
READING -> getString(R.string.reading)
COMPLETED -> getString(R.string.completed)
ON_HOLD -> getString(R.string.on_hold)
DROPPED -> getString(R.string.dropped)
PLAN_TO_READ -> getString(R.string.plan_to_read)
else -> ""
}
}
fun createHeaders(username: String, password: String) {
val builder = Headers.Builder()
builder.add("Authorization", Credentials.basic(username, password))
builder.add("User-Agent", "api-indiv-9F93C52A963974CF674325391990191C")
headers = builder.build()
}
}

View File

@ -46,6 +46,15 @@ fun Call.asObservable(): Observable<Response> {
}
}
fun Call.asObservableSuccess(): Observable<Response> {
return asObservable().doOnNext { response ->
if (!response.isSuccessful) {
response.close()
throw Exception("HTTP error ${response.code()}")
}
}
}
fun OkHttpClient.newCallWithProgress(request: Request, listener: ProgressListener): Call {
val progressClient = newBuilder()
.cache(null)

View File

@ -51,9 +51,9 @@ class PreferenceKeys(context: Context) {
val updateOnlyNonCompleted = context.getString(R.string.pref_update_only_non_completed_key)
val autoUpdateMangaSync = context.getString(R.string.pref_auto_update_manga_sync_key)
val autoUpdateTrack = context.getString(R.string.pref_auto_update_manga_sync_key)
val askUpdateMangaSync = context.getString(R.string.pref_ask_update_manga_sync_key)
val askUpdateTrack = context.getString(R.string.pref_ask_update_manga_sync_key)
val lastUsedCatalogueSource = context.getString(R.string.pref_last_catalogue_source_key)
@ -83,18 +83,26 @@ class PreferenceKeys(context: Context) {
val filterUnread = context.getString(R.string.pref_filter_unread_key)
val librarySortingMode = context.getString(R.string.pref_library_sorting_mode_key)
val automaticUpdates = context.getString(R.string.pref_enable_automatic_updates_key)
val startScreen = context.getString(R.string.pref_start_screen_key)
val downloadNew = context.getString(R.string.pref_download_new_key)
fun sourceUsername(sourceId: Int) = "pref_source_username_$sourceId"
fun sourcePassword(sourceId: Int) = "pref_source_password_$sourceId"
fun syncUsername(syncId: Int) = "pref_mangasync_username_$syncId"
fun trackUsername(syncId: Int) = "pref_mangasync_username_$syncId"
fun syncPassword(syncId: Int) = "pref_mangasync_password_$syncId"
fun trackPassword(syncId: Int) = "pref_mangasync_password_$syncId"
fun trackToken(syncId: Int) = "track_token_$syncId"
val libraryAsList = context.getString(R.string.pref_display_library_as_list)
val lang = context.getString(R.string.pref_language_key)
}

View File

@ -7,13 +7,15 @@ import android.preference.PreferenceManager
import com.f2prateek.rx.preferences.Preference
import com.f2prateek.rx.preferences.RxSharedPreferences
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.mangasync.MangaSyncService
import eu.kanade.tachiyomi.data.source.Source
import eu.kanade.tachiyomi.data.track.TrackService
import java.io.File
fun <T> Preference<T>.getOrDefault(): T = get() ?: defaultValue()!!
class PreferencesHelper(context: Context) {
fun Preference<Boolean>.invert(): Boolean = getOrDefault().let { set(!it); !it }
class PreferencesHelper(val context: Context) {
val keys = PreferenceKeys(context)
@ -68,9 +70,9 @@ class PreferencesHelper(context: Context) {
fun updateOnlyNonCompleted() = prefs.getBoolean(keys.updateOnlyNonCompleted, false)
fun autoUpdateMangaSync() = prefs.getBoolean(keys.autoUpdateMangaSync, true)
fun autoUpdateTrack() = prefs.getBoolean(keys.autoUpdateTrack, true)
fun askUpdateMangaSync() = prefs.getBoolean(keys.askUpdateMangaSync, false)
fun askUpdateTrack() = prefs.getBoolean(keys.askUpdateTrack, false)
fun lastUsedCatalogueSource() = rxPrefs.getInteger(keys.lastUsedCatalogueSource, -1)
@ -80,7 +82,7 @@ class PreferencesHelper(context: Context) {
fun catalogueAsList() = rxPrefs.getBoolean(keys.catalogueAsList, false)
fun enabledLanguages() = rxPrefs.getStringSet(keys.enabledLanguages, setOf("EN"))
fun enabledLanguages() = rxPrefs.getStringSet(keys.enabledLanguages, setOf("en"))
fun sourceUsername(source: Source) = prefs.getString(keys.sourceUsername(source.id), "")
@ -93,17 +95,21 @@ class PreferencesHelper(context: Context) {
.apply()
}
fun mangaSyncUsername(sync: MangaSyncService) = prefs.getString(keys.syncUsername(sync.id), "")
fun trackUsername(sync: TrackService) = prefs.getString(keys.trackUsername(sync.id), "")
fun mangaSyncPassword(sync: MangaSyncService) = prefs.getString(keys.syncPassword(sync.id), "")
fun trackPassword(sync: TrackService) = prefs.getString(keys.trackPassword(sync.id), "")
fun setMangaSyncCredentials(sync: MangaSyncService, username: String, password: String) {
fun setTrackCredentials(sync: TrackService, username: String, password: String) {
prefs.edit()
.putString(keys.syncUsername(sync.id), username)
.putString(keys.syncPassword(sync.id), password)
.putString(keys.trackUsername(sync.id), username)
.putString(keys.trackPassword(sync.id), password)
.apply()
}
fun trackToken(sync: TrackService) = rxPrefs.getString(keys.trackToken(sync.id), "")
fun anilistScoreType() = rxPrefs.getInteger("anilist_score_type", 0)
fun downloadsDirectory() = rxPrefs.getString(keys.downloadsDirectory, defaultDownloadsDir.toString())
fun downloadThreads() = rxPrefs.getInteger(keys.downloadThreads, 1)
@ -126,8 +132,16 @@ class PreferencesHelper(context: Context) {
fun filterUnread() = rxPrefs.getBoolean(keys.filterUnread, false)
fun librarySortingMode() = rxPrefs.getInteger(keys.librarySortingMode, 0)
fun librarySortingAscending() = rxPrefs.getBoolean("library_sorting_ascending", true)
fun automaticUpdates() = prefs.getBoolean(keys.automaticUpdates, false)
fun hiddenCatalogues() = rxPrefs.getStringSet("hidden_catalogues", emptySet())
fun downloadNew() = prefs.getBoolean(keys.downloadNew, false)
fun lang() = prefs.getString(keys.lang, "")
}

View File

@ -1,9 +0,0 @@
package eu.kanade.tachiyomi.data.source
class Language(val code: String, val lang: String)
val DE = Language("DE", "German")
val EN = Language("EN", "English")
val RU = Language("RU", "Russian")
fun getLanguages() = listOf(DE, EN, RU)

View File

@ -6,10 +6,9 @@ import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.network.GET
import eu.kanade.tachiyomi.data.network.NetworkHelper
import eu.kanade.tachiyomi.data.network.asObservable
import eu.kanade.tachiyomi.data.network.asObservableSuccess
import eu.kanade.tachiyomi.data.network.newCallWithProgress
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.source.Language
import eu.kanade.tachiyomi.data.source.Source
import eu.kanade.tachiyomi.data.source.model.MangasPage
import eu.kanade.tachiyomi.data.source.model.Page
@ -47,9 +46,9 @@ abstract class OnlineSource() : Source {
abstract val baseUrl: String
/**
* Language of the source.
* An ISO 639-1 compliant language code (two characters in lower case).
*/
abstract val lang: Language
abstract val lang: String
/**
* Whether the source has support for latest updates.
@ -82,7 +81,7 @@ abstract class OnlineSource() : Source {
/**
* Visible name of the source.
*/
override fun toString() = "$name (${lang.code})"
override fun toString() = "$name (${lang.toUpperCase()})"
/**
* Returns an observable containing a page with a list of manga. Normally it's not needed to
@ -93,7 +92,7 @@ abstract class OnlineSource() : Source {
*/
open fun fetchPopularManga(page: MangasPage): Observable<MangasPage> = client
.newCall(popularMangaRequest(page))
.asObservable()
.asObservableSuccess()
.map { response ->
popularMangaParse(response, page)
page
@ -136,7 +135,7 @@ abstract class OnlineSource() : Source {
*/
open fun fetchSearchManga(page: MangasPage, query: String, filters: List<Filter>): Observable<MangasPage> = client
.newCall(searchMangaRequest(page, query, filters))
.asObservable()
.asObservableSuccess()
.map { response ->
searchMangaParse(response, page, query, filters)
page
@ -178,7 +177,7 @@ abstract class OnlineSource() : Source {
*/
open fun fetchLatestUpdates(page: MangasPage): Observable<MangasPage> = client
.newCall(latestUpdatesRequest(page))
.asObservable()
.asObservableSuccess()
.map { response ->
latestUpdatesParse(response, page)
page
@ -212,7 +211,7 @@ abstract class OnlineSource() : Source {
*/
override fun fetchMangaDetails(manga: Manga): Observable<Manga> = client
.newCall(mangaDetailsRequest(manga))
.asObservable()
.asObservableSuccess()
.map { response ->
Manga.create(manga.url, id).apply {
mangaDetailsParse(response, this)
@ -246,7 +245,7 @@ abstract class OnlineSource() : Source {
*/
override fun fetchChapterList(manga: Manga): Observable<List<Chapter>> = client
.newCall(chapterListRequest(manga))
.asObservable()
.asObservableSuccess()
.map { response ->
mutableListOf<Chapter>().apply {
chapterListParse(response, this)
@ -292,11 +291,8 @@ abstract class OnlineSource() : Source {
*/
open fun fetchPageListFromNetwork(chapter: Chapter): Observable<List<Page>> = client
.newCall(pageListRequest(chapter))
.asObservable()
.asObservableSuccess()
.map { response ->
if (!response.isSuccessful) {
throw Exception("Webpage sent ${response.code()} code")
}
mutableListOf<Page>().apply {
pageListParse(response, this)
if (isEmpty()) {
@ -338,7 +334,7 @@ abstract class OnlineSource() : Source {
page.status = Page.LOAD_PAGE
return client
.newCall(imageUrlRequest(page))
.asObservable()
.asObservableSuccess()
.map { imageUrlParse(it) }
.doOnError { page.status = Page.ERROR }
.onErrorReturn { null }
@ -381,13 +377,7 @@ abstract class OnlineSource() : Source {
*/
fun imageResponse(page: Page): Observable<Response> = client
.newCallWithProgress(imageRequest(page), page)
.asObservable()
.doOnNext {
if (!it.isSuccessful) {
it.close()
throw RuntimeException("Not a valid response")
}
}
.asObservableSuccess()
/**
* Returns the request for getting the source image. Override only if it's needed to override

View File

@ -4,7 +4,6 @@ import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.network.GET
import eu.kanade.tachiyomi.data.network.POST
import eu.kanade.tachiyomi.data.source.getLanguages
import eu.kanade.tachiyomi.data.source.model.MangasPage
import eu.kanade.tachiyomi.data.source.model.Page
import eu.kanade.tachiyomi.util.asJsoup
@ -27,9 +26,7 @@ class YamlOnlineSource(mappings: Map<*, *>) : OnlineSource() {
if (it.endsWith("/")) it.dropLast(1) else it
}
override val lang = map.lang.toUpperCase().let { code ->
getLanguages().find { code == it.code }!!
}
override val lang = map.lang.toLowerCase()
override val supportsLatest = map.latestupdates != null
@ -39,7 +36,7 @@ class YamlOnlineSource(mappings: Map<*, *>) : OnlineSource() {
}
override val id = map.id.let {
if (it is Int) it else (lang.code.hashCode() + 31 * it.hashCode()) and 0x7fffffff
if (it is Int) it else (lang.toUpperCase().hashCode() + 31 * it.hashCode()) and 0x7fffffff
}
override fun popularMangaRequest(page: MangasPage): Request {

View File

@ -7,8 +7,6 @@ import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.network.GET
import eu.kanade.tachiyomi.data.network.POST
import eu.kanade.tachiyomi.data.network.asObservable
import eu.kanade.tachiyomi.data.source.EN
import eu.kanade.tachiyomi.data.source.Language
import eu.kanade.tachiyomi.data.source.model.MangasPage
import eu.kanade.tachiyomi.data.source.model.Page
import eu.kanade.tachiyomi.data.source.online.LoginSource
@ -33,7 +31,7 @@ class Batoto(override val id: Int) : ParsedOnlineSource(), LoginSource {
override val baseUrl = "http://bato.to"
override val lang: Language get() = EN
override val lang = "en"
override val supportsLatest = true
@ -109,12 +107,17 @@ class Batoto(override val id: Int) : ParsedOnlineSource(), LoginSource {
override fun latestUpdatesNextPageSelector() = "#show_more_row"
override fun searchMangaInitialUrl(query: String, filters: List<Filter>) = "$baseUrl/search_ajax?name=${Uri.encode(query)}&order_cond=views&order=desc&p=1&genre_cond=and&genres=${getFilterParams(filters)}"
override fun searchMangaInitialUrl(query: String, filters: List<Filter>) = "$baseUrl/search_ajax?name=${Uri.encode(query)}&order_cond=views&order=desc&p=1${getFilterParams(filters)}"
private fun getFilterParams(filters: List<Filter>): String = filters
.map {
";i" + it.id
}.joinToString()
private fun getFilterParams(filters: List<Filter>): String {
var genres = ""
var completed = ""
for (filter in filters) {
if (filter.equals(completedFilter)) completed = "&completed=c"
else genres += ";i" + filter.id
}
return if (genres.isEmpty()) completed else "&genres=$genres&genre_cond=and$completed"
}
override fun searchMangaRequest(page: MangasPage, query: String, filters: List<Filter>): Request {
if (page.page == 1) {
@ -133,7 +136,7 @@ class Batoto(override val id: Int) : ParsedOnlineSource(), LoginSource {
}
page.nextPageUrl = document.select(searchMangaNextPageSelector()).first()?.let {
"$baseUrl/search_ajax?name=${Uri.encode(query)}&p=${page.page + 1}&order_cond=views&order=desc&genre_cond=and&genres=" + getFilterParams(filters)
"$baseUrl/search_ajax?name=${Uri.encode(query)}&order_cond=views&order=desc&p=${page.page + 1}${getFilterParams(filters)}"
}
}
@ -301,11 +304,13 @@ class Batoto(override val id: Int) : ParsedOnlineSource(), LoginSource {
}
}
private val completedFilter = Filter("completed", "Completed")
// [...document.querySelectorAll("#advanced_options div.genre_buttons")].map((el,i) => {
// const onClick=el.getAttribute('onclick');const id=onClick.substr(14,onClick.length-16);return `Filter("${id}", "${el.textContent.trim()}")`
// }).join(',\n')
// on https://bato.to/search
override fun getFilterList(): List<Filter> = listOf(
completedFilter,
Filter("40", "4-Koma"),
Filter("1", "Action"),
Filter("2", "Adventure"),

View File

@ -1,12 +1,9 @@
package eu.kanade.tachiyomi.data.source.online.english
import android.content.Context
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.network.GET
import eu.kanade.tachiyomi.data.network.POST
import eu.kanade.tachiyomi.data.source.EN
import eu.kanade.tachiyomi.data.source.Language
import eu.kanade.tachiyomi.data.source.model.MangasPage
import eu.kanade.tachiyomi.data.source.model.Page
import eu.kanade.tachiyomi.data.source.online.ParsedOnlineSource
@ -25,7 +22,7 @@ class Kissmanga(override val id: Int) : ParsedOnlineSource() {
override val baseUrl = "http://kissmanga.com"
override val lang: Language get() = EN
override val lang = "en"
override val supportsLatest = true
@ -62,10 +59,10 @@ class Kissmanga(override val id: Int) : ParsedOnlineSource() {
val form = FormBody.Builder().apply {
add("authorArtist", "")
add("mangaName", query)
add("status", "")
this@Kissmanga.filters.forEach { filter ->
add("genres", if (filter in filters) "1" else "0")
if (filter.equals(completedFilter)) add("status", if (filter in filters) filter.id else "")
else add("genres", if (filter in filters) "1" else "0")
}
}
@ -131,9 +128,11 @@ class Kissmanga(override val id: Int) : ParsedOnlineSource() {
override fun imageUrlParse(document: Document) = ""
private val completedFilter = Filter("Completed", "Completed")
// $("select[name=\"genres\"]").map((i,el) => `Filter("${i}", "${$(el).next().text().trim()}")`).get().join(',\n')
// on http://kissmanga.com/AdvanceSearch
override fun getFilterList(): List<Filter> = listOf(
completedFilter,
Filter("0", "Action"),
Filter("1", "Adult"),
Filter("2", "Adventure"),

View File

@ -2,8 +2,6 @@ package eu.kanade.tachiyomi.data.source.online.english
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.source.EN
import eu.kanade.tachiyomi.data.source.Language
import eu.kanade.tachiyomi.data.source.model.Page
import eu.kanade.tachiyomi.data.source.online.ParsedOnlineSource
import eu.kanade.tachiyomi.util.asJsoup
@ -20,7 +18,7 @@ class Mangafox(override val id: Int) : ParsedOnlineSource() {
override val baseUrl = "http://mangafox.me"
override val lang: Language get() = EN
override val lang = "en"
override val supportsLatest = true
@ -50,10 +48,10 @@ class Mangafox(override val id: Int) : ParsedOnlineSource() {
override fun searchMangaInitialUrl(query: String, filters: List<Filter>) =
"$baseUrl/search.php?name_method=cw&advopts=1&order=za&sort=views&name=$query&page=1&${filters.map { it.id + "=1" }.joinToString("&")}"
override fun searchMangaSelector() = "table#listing > tbody > tr:gt(0)"
override fun searchMangaSelector() = "div#mangalist > ul.list > li"
override fun searchMangaFromElement(element: Element, manga: Manga) {
element.select("a.series_preview").first().let {
element.select("a.title").first().let {
manga.setUrlWithoutDomain(it.attr("href"))
manga.title = it.text()
}
@ -132,6 +130,7 @@ class Mangafox(override val id: Int) : ParsedOnlineSource() {
// $('select.genres').map((i,el)=>`Filter("${$(el).attr('name')}", "${$(el).next().text().trim()}")`).get().join(',\n')
// on http://kissmanga.com/AdvanceSearch
override fun getFilterList(): List<Filter> = listOf(
Filter("is_completed", "Completed"),
Filter("genres[Action]", "Action"),
Filter("genres[Adult]", "Adult"),
Filter("genres[Adventure]", "Adventure"),

View File

@ -1,10 +1,7 @@
package eu.kanade.tachiyomi.data.source.online.english
import android.content.Context
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.source.EN
import eu.kanade.tachiyomi.data.source.Language
import eu.kanade.tachiyomi.data.source.model.Page
import eu.kanade.tachiyomi.data.source.online.ParsedOnlineSource
import org.jsoup.nodes.Document
@ -19,7 +16,7 @@ class Mangahere(override val id: Int) : ParsedOnlineSource() {
override val baseUrl = "http://www.mangahere.co"
override val lang: Language get() = EN
override val lang = "en"
override val supportsLatest = true
@ -31,13 +28,17 @@ class Mangahere(override val id: Int) : ParsedOnlineSource() {
override fun latestUpdatesSelector() = "div.directory_list > ul > li"
override fun popularMangaFromElement(element: Element, manga: Manga) {
element.select("div.title > a").first().let {
private fun mangaFromElement(query: String, element: Element, manga: Manga) {
element.select(query).first().let {
manga.setUrlWithoutDomain(it.attr("href"))
manga.title = it.text()
manga.title = if (it.hasAttr("title")) it.attr("title") else if (it.hasAttr("rel")) it.attr("rel") else it.text()
}
}
override fun popularMangaFromElement(element: Element, manga: Manga) {
mangaFromElement("div.title > a", element, manga)
}
override fun latestUpdatesFromElement(element: Element, manga: Manga) {
popularMangaFromElement(element, manga)
}
@ -51,10 +52,7 @@ class Mangahere(override val id: Int) : ParsedOnlineSource() {
override fun searchMangaSelector() = "div.result_search > dl:has(dt)"
override fun searchMangaFromElement(element: Element, manga: Manga) {
element.select("a.manga_info").first().let {
manga.setUrlWithoutDomain(it.attr("href"))
manga.title = it.text()
}
mangaFromElement("a.manga_info", element, manga)
}
override fun searchMangaNextPageSelector() = "div.next-page > a.next"
@ -136,6 +134,7 @@ class Mangahere(override val id: Int) : ParsedOnlineSource() {
// [...document.querySelectorAll("select[id^='genres'")].map((el,i) => `Filter("${el.getAttribute('name')}", "${el.nextSibling.nextSibling.textContent.trim()}")`).join(',\n')
// http://www.mangahere.co/advsearch.htm
override fun getFilterList(): List<Filter> = listOf(
Filter("is_completed", "Completed"),
Filter("genres[Action]", "Action"),
Filter("genres[Adventure]", "Adventure"),
Filter("genres[Comedy]", "Comedy"),

View File

@ -3,8 +3,6 @@ package eu.kanade.tachiyomi.data.source.online.english
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.network.POST
import eu.kanade.tachiyomi.data.source.EN
import eu.kanade.tachiyomi.data.source.Language
import eu.kanade.tachiyomi.data.source.model.MangasPage
import eu.kanade.tachiyomi.data.source.model.Page
import eu.kanade.tachiyomi.data.source.online.ParsedOnlineSource
@ -24,7 +22,7 @@ class Mangasee(override val id: Int) : ParsedOnlineSource() {
override val baseUrl = "http://mangaseeonline.net"
override val lang: Language get() = EN
override val lang = "en"
override val supportsLatest = true
@ -66,8 +64,16 @@ class Mangasee(override val id: Int) : ParsedOnlineSource() {
// Not used, overrides parent.
override fun popularMangaNextPageSelector() = ""
override fun searchMangaInitialUrl(query: String, filters: List<Filter>) =
"$baseUrl/search/request.php?sortBy=popularity&sortOrder=descending&keyword=$query&genre=${filters.map { it.id }.joinToString(",")}"
override fun searchMangaInitialUrl(query: String, filters: List<Filter>): String {
var url = "$baseUrl/search/request.php?sortBy=popularity&sortOrder=descending&keyword=$query"
var genres: String? = null
for (filter in filters) {
if (filter.equals(completedFilter)) url += "&status=Complete"
else if (genres == null) genres = filter.id
else genres += "," + filter.id
}
return if (genres == null) url else url + "&genre=$genres"
}
override fun searchMangaSelector() = "div.searchResults > div.requested > div.row"
@ -168,9 +174,11 @@ class Mangasee(override val id: Int) : ParsedOnlineSource() {
override fun imageUrlParse(document: Document): String = document.select("img.CurImage").attr("src")
private val completedFilter = Filter("Complete", "Completed")
// [...document.querySelectorAll("label.triStateCheckBox input")].map(el => `Filter("${el.getAttribute('name')}", "${el.nextSibling.textContent.trim()}")`).join(',\n')
// http://mangasee.co/advanced-search/
override fun getFilterList(): List<Filter> = listOf(
completedFilter,
Filter("Action", "Action"),
Filter("Adult", "Adult"),
Filter("Adventure", "Adventure"),
@ -250,4 +258,4 @@ class Mangasee(override val id: Int) : ParsedOnlineSource() {
}
}
}
}

View File

@ -1,11 +1,8 @@
package eu.kanade.tachiyomi.data.source.online.english
import android.content.Context
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.network.POST
import eu.kanade.tachiyomi.data.source.EN
import eu.kanade.tachiyomi.data.source.Language
import eu.kanade.tachiyomi.data.source.model.MangasPage
import eu.kanade.tachiyomi.data.source.model.Page
import eu.kanade.tachiyomi.data.source.online.OnlineSource
@ -23,7 +20,7 @@ class Readmangatoday(override val id: Int) : ParsedOnlineSource() {
override val baseUrl = "http://www.readmanga.today"
override val lang: Language get() = EN
override val lang = "en"
override val supportsLatest = true
@ -72,10 +69,12 @@ class Readmangatoday(override val id: Int) : ParsedOnlineSource() {
val builder = okhttp3.FormBody.Builder()
builder.add("manga-name", query)
builder.add("type", "all")
builder.add("status", "both")
var status = "both"
for (filter in filters) {
builder.add("include[]", filter.id)
if (filter.equals(completedFilter)) status = filter.id
else builder.add("include[]", filter.id)
}
builder.add("status", status)
return POST(page.url, headers, builder.build())
}
@ -154,9 +153,11 @@ class Readmangatoday(override val id: Int) : ParsedOnlineSource() {
override fun imageUrlParse(document: Document) = document.select("img.img-responsive-2").first().attr("src")
private val completedFilter = Filter("completed", "Completed")
// [...document.querySelectorAll("ul.manga-cat span")].map(el => `Filter("${el.getAttribute('data-id')}", "${el.nextSibling.textContent.trim()}")`).join(',\n')
// http://www.readmanga.today/advanced-search
override fun getFilterList(): List<Filter> = listOf(
completedFilter,
Filter("2", "Action"),
Filter("4", "Adventure"),
Filter("5", "Comedy"),

View File

@ -1,10 +1,7 @@
package eu.kanade.tachiyomi.data.source.online.german
import android.content.Context
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.source.DE
import eu.kanade.tachiyomi.data.source.Language
import eu.kanade.tachiyomi.data.source.model.Page
import eu.kanade.tachiyomi.data.source.online.ParsedOnlineSource
import eu.kanade.tachiyomi.util.asJsoup
@ -19,7 +16,7 @@ class WieManga(override val id: Int) : ParsedOnlineSource() {
override val baseUrl = "http://www.wiemanga.com"
override val lang: Language get() = DE
override val lang = "de"
override val supportsLatest = true

View File

@ -2,8 +2,6 @@ package eu.kanade.tachiyomi.data.source.online.russian
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.source.Language
import eu.kanade.tachiyomi.data.source.RU
import eu.kanade.tachiyomi.data.source.model.MangasPage
import eu.kanade.tachiyomi.data.source.model.Page
import eu.kanade.tachiyomi.data.source.online.ParsedOnlineSource
@ -20,7 +18,7 @@ class Mangachan(override val id: Int) : ParsedOnlineSource() {
override val baseUrl = "http://mangachan.me"
override val lang: Language get() = RU
override val lang = "ru"
override val supportsLatest = true

View File

@ -2,8 +2,6 @@ package eu.kanade.tachiyomi.data.source.online.russian
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.source.Language
import eu.kanade.tachiyomi.data.source.RU
import eu.kanade.tachiyomi.data.source.model.Page
import eu.kanade.tachiyomi.data.source.online.ParsedOnlineSource
import okhttp3.Response
@ -19,7 +17,7 @@ class Mintmanga(override val id: Int) : ParsedOnlineSource() {
override val baseUrl = "http://mintmanga.com"
override val lang: Language get() = RU
override val lang = "ru"
override val supportsLatest = true

View File

@ -2,8 +2,6 @@ package eu.kanade.tachiyomi.data.source.online.russian
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.source.Language
import eu.kanade.tachiyomi.data.source.RU
import eu.kanade.tachiyomi.data.source.model.Page
import eu.kanade.tachiyomi.data.source.online.ParsedOnlineSource
import okhttp3.Response
@ -19,7 +17,7 @@ class Readmanga(override val id: Int) : ParsedOnlineSource() {
override val baseUrl = "http://readmanga.me"
override val lang: Language get() = RU
override val lang = "ru"
override val supportsLatest = true

View File

@ -0,0 +1,28 @@
package eu.kanade.tachiyomi.data.track
import android.content.Context
import eu.kanade.tachiyomi.data.track.anilist.Anilist
import eu.kanade.tachiyomi.data.track.kitsu.Kitsu
import eu.kanade.tachiyomi.data.track.myanimelist.Myanimelist
class TrackManager(private val context: Context) {
companion object {
const val MYANIMELIST = 1
const val ANILIST = 2
const val KITSU = 3
}
val myAnimeList = Myanimelist(context, MYANIMELIST)
val aniList = Anilist(context, ANILIST)
val kitsu = Kitsu(context, KITSU)
val services = listOf(myAnimeList, aniList, kitsu)
fun getService(id: Int) = services.find { it.id == id }
fun hasLoggedServices() = services.any { it.isLogged }
}

View File

@ -0,0 +1,70 @@
package eu.kanade.tachiyomi.data.track
import android.support.annotation.CallSuper
import android.support.annotation.DrawableRes
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.network.NetworkHelper
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import okhttp3.OkHttpClient
import rx.Completable
import rx.Observable
import uy.kohesive.injekt.injectLazy
abstract class TrackService(val id: Int) {
val preferences: PreferencesHelper by injectLazy()
val networkService: NetworkHelper by injectLazy()
open val client: OkHttpClient
get() = networkService.client
// Name of the manga sync service to display
abstract val name: String
@DrawableRes
abstract fun getLogo(): Int
abstract fun getLogoColor(): Int
abstract fun getStatusList(): List<Int>
abstract fun getStatus(status: Int): String
abstract fun getScoreList(): List<String>
open fun indexToScore(index: Int): Float {
return index.toFloat()
}
abstract fun displayScore(track: Track): String
abstract fun add(track: Track): Observable<Track>
abstract fun update(track: Track): Observable<Track>
abstract fun bind(track: Track): Observable<Track>
abstract fun search(query: String): Observable<List<Track>>
abstract fun refresh(track: Track): Observable<Track>
abstract fun login(username: String, password: String): Completable
@CallSuper
open fun logout() {
preferences.setTrackCredentials(this, "", "")
}
open val isLogged: Boolean
get() = !getUsername().isEmpty() &&
!getPassword().isEmpty()
fun getUsername() = preferences.trackUsername(this)
fun getPassword() = preferences.trackPassword(this)
fun saveCredentials(username: String, password: String) {
preferences.setTrackCredentials(this, username, password)
}
}

View File

@ -1,20 +1,20 @@
package eu.kanade.tachiyomi.data.mangasync
package eu.kanade.tachiyomi.data.track
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.IBinder
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.MangaSync
import eu.kanade.tachiyomi.data.database.models.Track
import rx.Observable
import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers
import rx.subscriptions.CompositeSubscription
import uy.kohesive.injekt.injectLazy
class UpdateMangaSyncService : Service() {
class TrackUpdateService : Service() {
val syncManager: MangaSyncManager by injectLazy()
val trackManager: TrackManager by injectLazy()
val db: DatabaseHelper by injectLazy()
private lateinit var subscriptions: CompositeSubscription
@ -30,9 +30,9 @@ class UpdateMangaSyncService : Service() {
}
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
val manga = intent.getSerializableExtra(EXTRA_MANGASYNC)
if (manga != null) {
updateLastChapterRead(manga as MangaSync, startId)
val track = intent.getSerializableExtra(EXTRA_TRACK)
if (track != null) {
updateLastChapterRead(track as Track, startId)
return Service.START_REDELIVER_INTENT
} else {
stopSelf(startId)
@ -44,15 +44,15 @@ class UpdateMangaSyncService : Service() {
return null
}
private fun updateLastChapterRead(mangaSync: MangaSync, startId: Int) {
val sync = syncManager.getService(mangaSync.sync_id)
private fun updateLastChapterRead(track: Track, startId: Int) {
val sync = trackManager.getService(track.sync_id)
if (sync == null) {
stopSelf(startId)
return
}
subscriptions.add(Observable.defer { sync.update(mangaSync) }
.flatMap { db.insertMangaSync(mangaSync).asRxObservable() }
subscriptions.add(Observable.defer { sync.update(track) }
.flatMap { db.insertTrack(track).asRxObservable() }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({ stopSelf(startId) },
@ -61,12 +61,12 @@ class UpdateMangaSyncService : Service() {
companion object {
private val EXTRA_MANGASYNC = "extra_mangasync"
private val EXTRA_TRACK = "extra_track"
@JvmStatic
fun start(context: Context, mangaSync: MangaSync) {
val intent = Intent(context, UpdateMangaSyncService::class.java)
intent.putExtra(EXTRA_MANGASYNC, mangaSync)
fun start(context: Context, track: Track) {
val intent = Intent(context, TrackUpdateService::class.java)
intent.putExtra(EXTRA_TRACK, track)
context.startService(intent)
}
}

View File

@ -0,0 +1,160 @@
package eu.kanade.tachiyomi.data.track.anilist
import android.content.Context
import android.graphics.Color
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.data.track.TrackService
import rx.Completable
import rx.Observable
class Anilist(private val context: Context, id: Int) : TrackService(id) {
companion object {
const val READING = 1
const val COMPLETED = 2
const val ON_HOLD = 3
const val DROPPED = 4
const val PLAN_TO_READ = 5
const val DEFAULT_STATUS = READING
const val DEFAULT_SCORE = 0
}
override val name = "AniList"
private val interceptor by lazy { AnilistInterceptor(getPassword()) }
private val api by lazy { AnilistApi(client, interceptor) }
override fun getLogo() = R.drawable.al
override fun getLogoColor() = Color.rgb(18, 25, 35)
override fun getStatusList(): List<Int> {
return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLAN_TO_READ)
}
override fun getStatus(status: Int): String = with(context) {
when (status) {
READING -> getString(R.string.reading)
COMPLETED -> getString(R.string.completed)
ON_HOLD -> getString(R.string.on_hold)
DROPPED -> getString(R.string.dropped)
PLAN_TO_READ -> getString(R.string.plan_to_read)
else -> ""
}
}
override fun getScoreList(): List<String> {
return when (preferences.anilistScoreType().getOrDefault()) {
// 10 point
0 -> IntRange(0, 10).map(Int::toString)
// 100 point
1 -> IntRange(0, 100).map(Int::toString)
// 5 stars
2 -> IntRange(0, 5).map { "$it" }
// Smiley
3 -> listOf("-", "😦", "😐", "😊")
// 10 point decimal
4 -> IntRange(0, 100).map { (it / 10f).toString() }
else -> throw Exception("Unknown score type")
}
}
override fun indexToScore(index: Int): Float {
return when (preferences.anilistScoreType().getOrDefault()) {
// 10 point
0 -> index * 10f
// 100 point
1 -> index.toFloat()
// 5 stars
2 -> index * 20f
// Smiley
3 -> index * 30f
// 10 point decimal
4 -> index.toFloat()
else -> throw Exception("Unknown score type")
}
}
override fun displayScore(track: Track): String {
val score = track.score
return when (preferences.anilistScoreType().getOrDefault()) {
2 -> "${(score / 20).toInt()}"
3 -> when {
score == 0f -> "0"
score <= 30 -> "😦"
score <= 60 -> "😐"
else -> "😊"
}
else -> track.toAnilistScore()
}
}
override fun add(track: Track): Observable<Track> {
return api.addLibManga(track)
}
override fun update(track: Track): Observable<Track> {
if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) {
track.status = COMPLETED
}
return api.updateLibManga(track)
}
override fun bind(track: Track): Observable<Track> {
return api.findLibManga(track, getUsername())
.flatMap { remoteTrack ->
if (remoteTrack != null) {
track.copyPersonalFrom(remoteTrack)
update(track)
} else {
// Set default fields if it's not found in the list
track.score = DEFAULT_SCORE.toFloat()
track.status = DEFAULT_STATUS
add(track)
}
}
}
override fun search(query: String): Observable<List<Track>> {
return api.search(query)
}
override fun refresh(track: Track): Observable<Track> {
return api.getLibManga(track, getUsername())
.map { remoteTrack ->
track.copyPersonalFrom(remoteTrack)
track.total_chapters = remoteTrack.total_chapters
track
}
}
override fun login(username: String, password: String) = login(password)
fun login(authCode: String): Completable {
return api.login(authCode)
// Save the token in the interceptor.
.doOnNext { interceptor.setAuth(it) }
// Obtain the authenticated user from the API.
.zipWith(api.getCurrentUser().map { pair ->
preferences.anilistScoreType().set(pair.second)
pair.first
}, { oauth, user -> Pair(user, oauth.refresh_token!!) })
// Save service credentials (username and refresh token).
.doOnNext { saveCredentials(it.first, it.second) }
// Logout on any error.
.doOnError { logout() }
.toCompletable()
}
override fun logout() {
super.logout()
interceptor.setAuth(null)
}
}

View File

@ -0,0 +1,161 @@
package eu.kanade.tachiyomi.data.track.anilist
import android.net.Uri
import com.github.salomonbrys.kotson.int
import com.github.salomonbrys.kotson.string
import com.google.gson.JsonObject
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.network.POST
import okhttp3.FormBody
import okhttp3.OkHttpClient
import okhttp3.ResponseBody
import retrofit2.Response
import retrofit2.Retrofit
import retrofit2.adapter.rxjava.RxJavaCallAdapterFactory
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.http.*
import rx.Observable
class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
private val rest = restBuilder()
.client(client.newBuilder().addInterceptor(interceptor).build())
.build()
.create(Rest::class.java)
fun addLibManga(track: Track): Observable<Track> {
return rest.addLibManga(track.remote_id, track.last_chapter_read, track.toAnilistStatus())
.map { response ->
response.body().close()
if (!response.isSuccessful) {
throw Exception("Could not add manga")
}
track
}
}
fun updateLibManga(track: Track): Observable<Track> {
return rest.updateLibManga(track.remote_id, track.last_chapter_read, track.toAnilistStatus(),
track.toAnilistScore())
.map { response ->
response.body().close()
if (!response.isSuccessful) {
throw Exception("Could not update manga")
}
track
}
}
fun search(query: String): Observable<List<Track>> {
return rest.search(query, 1)
.map { list ->
list.filter { it.type != "Novel" }.map { it.toTrack() }
}
.onErrorReturn { emptyList() }
}
fun getList(username: String): Observable<List<Track>> {
return rest.getLib(username)
.map { lib ->
lib.flatten().map { it.toTrack() }
}
}
fun findLibManga(track: Track, username: String) : Observable<Track?> {
// TODO avoid getting the entire list
return getList(username)
.map { list -> list.find { it.remote_id == track.remote_id } }
}
fun getLibManga(track: Track, username: String): Observable<Track> {
return findLibManga(track, username)
.map { it ?: throw Exception("Could not find manga") }
}
fun login(authCode: String): Observable<OAuth> {
return restBuilder()
.client(client)
.build()
.create(Rest::class.java)
.requestAccessToken(authCode)
}
fun getCurrentUser(): Observable<Pair<String, Int>> {
return rest.getCurrentUser()
.map { it["id"].string to it["score_type"].int }
}
private fun restBuilder() = Retrofit.Builder()
.baseUrl(baseUrl)
.addConverterFactory(GsonConverterFactory.create())
.addCallAdapterFactory(RxJavaCallAdapterFactory.create())
private interface Rest {
@FormUrlEncoded
@POST("auth/access_token")
fun requestAccessToken(
@Field("code") code: String,
@Field("grant_type") grant_type: String = "authorization_code",
@Field("client_id") client_id: String = clientId,
@Field("client_secret") client_secret: String = clientSecret,
@Field("redirect_uri") redirect_uri: String = clientUrl
) : Observable<OAuth>
@GET("user")
fun getCurrentUser(): Observable<JsonObject>
@GET("manga/search/{query}")
fun search(
@Path("query") query: String,
@Query("page") page: Int
): Observable<List<ALManga>>
@GET("user/{username}/mangalist")
fun getLib(
@Path("username") username: String
): Observable<ALUserLists>
@FormUrlEncoded
@PUT("mangalist")
fun addLibManga(
@Field("id") id: Int,
@Field("chapters_read") chapters_read: Int,
@Field("list_status") list_status: String
) : Observable<Response<ResponseBody>>
@FormUrlEncoded
@PUT("mangalist")
fun updateLibManga(
@Field("id") id: Int,
@Field("chapters_read") chapters_read: Int,
@Field("list_status") list_status: String,
@Field("score") score_raw: String
) : Observable<Response<ResponseBody>>
}
companion object {
private const val clientId = "tachiyomi-hrtje"
private const val clientSecret = "nlGB5OmgE9YWq5dr3gIDbTQV0C"
private const val clientUrl = "tachiyomi://anilist-auth"
private const val baseUrl = "https://anilist.co/api/"
fun authUrl() = Uri.parse("${baseUrl}auth/authorize").buildUpon()
.appendQueryParameter("grant_type", "authorization_code")
.appendQueryParameter("client_id", clientId)
.appendQueryParameter("redirect_uri", clientUrl)
.appendQueryParameter("response_type", "code")
.build()
fun refreshTokenRequest(token: String) = POST("${baseUrl}auth/access_token",
body = FormBody.Builder()
.add("grant_type", "refresh_token")
.add("client_id", clientId)
.add("client_secret", clientSecret)
.add("refresh_token", token)
.build())
}
}

View File

@ -1,61 +1,60 @@
package eu.kanade.tachiyomi.data.mangasync.anilist
import com.google.gson.Gson
import eu.kanade.tachiyomi.data.mangasync.anilist.model.OAuth
import okhttp3.Interceptor
import okhttp3.Response
class AnilistInterceptor(private var refreshToken: String?) : Interceptor {
/**
* OAuth object used for authenticated requests.
*
* Anilist returns the date without milliseconds. We fix that and make the token expire 1 minute
* before its original expiration date.
*/
private var oauth: OAuth? = null
set(value) {
field = value?.copy(expires = value.expires * 1000 - 60 * 1000)
}
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()
if (refreshToken.isNullOrEmpty()) {
throw Exception("Not authenticated with Anilist")
}
// Refresh access token if null or expired.
if (oauth == null || oauth!!.isExpired()) {
val response = chain.proceed(AnilistApi.refreshTokenRequest(refreshToken!!))
oauth = if (response.isSuccessful) {
Gson().fromJson(response.body().string(), OAuth::class.java)
} else {
response.close()
null
}
}
// Throw on null auth.
if (oauth == null) {
throw Exception("Access token wasn't refreshed")
}
// Add the authorization header to the original request.
val authRequest = originalRequest.newBuilder()
.addHeader("Authorization", "Bearer ${oauth!!.access_token}")
.build()
return chain.proceed(authRequest)
}
/**
* Called when the user authenticates with Anilist for the first time. Sets the refresh token
* and the oauth object.
*/
fun setAuth(oauth: OAuth?) {
refreshToken = oauth?.refresh_token
this.oauth = oauth
}
package eu.kanade.tachiyomi.data.track.anilist
import com.google.gson.Gson
import okhttp3.Interceptor
import okhttp3.Response
class AnilistInterceptor(private var refreshToken: String?) : Interceptor {
/**
* OAuth object used for authenticated requests.
*
* Anilist returns the date without milliseconds. We fix that and make the token expire 1 minute
* before its original expiration date.
*/
private var oauth: OAuth? = null
set(value) {
field = value?.copy(expires = value.expires * 1000 - 60 * 1000)
}
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()
if (refreshToken.isNullOrEmpty()) {
throw Exception("Not authenticated with Anilist")
}
// Refresh access token if null or expired.
if (oauth == null || oauth!!.isExpired()) {
val response = chain.proceed(AnilistApi.refreshTokenRequest(refreshToken!!))
oauth = if (response.isSuccessful) {
Gson().fromJson(response.body().string(), OAuth::class.java)
} else {
response.close()
null
}
}
// Throw on null auth.
if (oauth == null) {
throw Exception("Access token wasn't refreshed")
}
// Add the authorization header to the original request.
val authRequest = originalRequest.newBuilder()
.addHeader("Authorization", "Bearer ${oauth!!.access_token}")
.build()
return chain.proceed(authRequest)
}
/**
* Called when the user authenticates with Anilist for the first time. Sets the refresh token
* and the oauth object.
*/
fun setAuth(oauth: OAuth?) {
refreshToken = oauth?.refresh_token
this.oauth = oauth
}
}

View File

@ -0,0 +1,86 @@
package eu.kanade.tachiyomi.data.track.anilist
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.data.track.TrackManager
import uy.kohesive.injekt.injectLazy
data class ALManga(
val id: Int,
val title_romaji: String,
val type: String,
val total_chapters: Int) {
fun toTrack() = Track.create(TrackManager.ANILIST).apply {
remote_id = this@ALManga.id
title = title_romaji
total_chapters = this@ALManga.total_chapters
}
}
data class ALUserManga(
val id: Int,
val list_status: String,
val score_raw: Int,
val chapters_read: Int,
val manga: ALManga) {
fun toTrack() = Track.create(TrackManager.ANILIST).apply {
remote_id = manga.id
status = toTrackStatus()
score = score_raw.toFloat()
last_chapter_read = chapters_read
}
fun toTrackStatus() = when (list_status) {
"reading" -> Anilist.READING
"completed" -> Anilist.COMPLETED
"on-hold" -> Anilist.ON_HOLD
"dropped" -> Anilist.DROPPED
"plan to read" -> Anilist.PLAN_TO_READ
else -> throw NotImplementedError("Unknown status")
}
}
data class ALUserLists(val lists: Map<String, List<ALUserManga>>) {
fun flatten() = lists.values.flatten()
}
fun Track.toAnilistStatus() = when (status) {
Anilist.READING -> "reading"
Anilist.COMPLETED -> "completed"
Anilist.ON_HOLD -> "on-hold"
Anilist.DROPPED -> "dropped"
Anilist.PLAN_TO_READ -> "plan to read"
else -> throw NotImplementedError("Unknown status")
}
private val preferences: PreferencesHelper by injectLazy()
fun Track.toAnilistScore(): String = when (preferences.anilistScoreType().getOrDefault()) {
// 10 point
0 -> (score.toInt() / 10).toString()
// 100 point
1 -> score.toInt().toString()
// 5 stars
2 -> when {
score == 0f -> "0"
score < 30 -> "1"
score < 50 -> "2"
score < 70 -> "3"
score < 90 -> "4"
else -> "5"
}
// Smiley
3 -> when {
score == 0f -> "0"
score <= 30 -> ":("
score <= 60 -> ":|"
else -> ":)"
}
// 10 point decimal
4 -> (score / 10).toString()
else -> throw Exception("Unknown score type")
}

View File

@ -1,11 +1,11 @@
package eu.kanade.tachiyomi.data.mangasync.anilist.model
data class OAuth(
val access_token: String,
val token_type: String,
val expires: Long,
val expires_in: Long,
val refresh_token: String?) {
fun isExpired() = System.currentTimeMillis() > expires
package eu.kanade.tachiyomi.data.track.anilist
data class OAuth(
val access_token: String,
val token_type: String,
val expires: Long,
val expires_in: Long,
val refresh_token: String?) {
fun isExpired() = System.currentTimeMillis() > expires
}

View File

@ -0,0 +1,136 @@
package eu.kanade.tachiyomi.data.track.kitsu
import android.content.Context
import android.graphics.Color
import com.google.gson.Gson
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.TrackService
import rx.Completable
import rx.Observable
import uy.kohesive.injekt.injectLazy
class Kitsu(private val context: Context, id: Int) : TrackService(id) {
companion object {
const val READING = 1
const val COMPLETED = 2
const val ON_HOLD = 3
const val DROPPED = 4
const val PLAN_TO_READ = 5
const val DEFAULT_STATUS = READING
const val DEFAULT_SCORE = 0f
}
override val name = "Kitsu"
private val gson: Gson by injectLazy()
private val interceptor by lazy { KitsuInterceptor(this, gson) }
private val api by lazy { KitsuApi(client, interceptor) }
override fun getLogo(): Int {
return R.drawable.kitsu
}
override fun getLogoColor(): Int {
return Color.rgb(51, 37, 50)
}
override fun getStatusList(): List<Int> {
return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLAN_TO_READ)
}
override fun getStatus(status: Int): String = with(context) {
when (status) {
READING -> getString(R.string.reading)
COMPLETED -> getString(R.string.completed)
ON_HOLD -> getString(R.string.on_hold)
DROPPED -> getString(R.string.dropped)
PLAN_TO_READ -> getString(R.string.plan_to_read)
else -> ""
}
}
override fun getScoreList(): List<String> {
return IntRange(0, 10).map { (it.toFloat() / 2).toString() }
}
override fun displayScore(track: Track): String {
return track.toKitsuScore()
}
override fun add(track: Track): Observable<Track> {
return api.addLibManga(track, getUserId())
}
override fun update(track: Track): Observable<Track> {
if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) {
track.status = COMPLETED
}
return api.updateLibManga(track)
}
override fun bind(track: Track): Observable<Track> {
return api.findLibManga(track, getUserId())
.flatMap { remoteTrack ->
if (remoteTrack != null) {
track.copyPersonalFrom(remoteTrack)
track.remote_id = remoteTrack.remote_id
update(track)
} else {
track.score = DEFAULT_SCORE
track.status = DEFAULT_STATUS
add(track)
}
}
}
override fun search(query: String): Observable<List<Track>> {
return api.search(query)
}
override fun refresh(track: Track): Observable<Track> {
return api.getLibManga(track)
.map { remoteTrack ->
track.copyPersonalFrom(remoteTrack)
track.total_chapters = remoteTrack.total_chapters
track
}
}
override fun login(username: String, password: String): Completable {
return api.login(username, password)
.doOnNext { interceptor.newAuth(it) }
.flatMap { api.getCurrentUser() }
.doOnNext { userId -> saveCredentials(username, userId) }
.doOnError { logout() }
.toCompletable()
}
override fun logout() {
super.logout()
interceptor.newAuth(null)
}
private fun getUserId(): String {
return getPassword()
}
fun saveToken(oauth: OAuth?) {
val json = gson.toJson(oauth)
preferences.trackToken(this).set(json)
}
fun restoreToken(): OAuth? {
return try {
gson.fromJson(preferences.trackToken(this).get(), OAuth::class.java)
} catch (e: Exception) {
null
}
}
}

View File

@ -0,0 +1,200 @@
package eu.kanade.tachiyomi.data.track.kitsu
import com.github.salomonbrys.kotson.*
import com.google.gson.JsonObject
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.network.POST
import okhttp3.FormBody
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.adapter.rxjava.RxJavaCallAdapterFactory
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.http.*
import rx.Observable
class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor) {
private val rest = Retrofit.Builder()
.baseUrl(baseUrl)
.client(client.newBuilder().addInterceptor(interceptor).build())
.addConverterFactory(GsonConverterFactory.create())
.addCallAdapterFactory(RxJavaCallAdapterFactory.create())
.build()
.create(KitsuApi.Rest::class.java)
fun addLibManga(track: Track, userId: String): Observable<Track> {
return Observable.defer {
// @formatter:off
val data = jsonObject(
"type" to "libraryEntries",
"attributes" to jsonObject(
"status" to track.toKitsuStatus(),
"progress" to track.last_chapter_read
),
"relationships" to jsonObject(
"user" to jsonObject(
"data" to jsonObject(
"id" to userId,
"type" to "users"
)
),
"media" to jsonObject(
"data" to jsonObject(
"id" to track.remote_id,
"type" to "manga"
)
)
)
)
// @formatter:on
rest.addLibManga(jsonObject("data" to data))
.map { json ->
track.remote_id = json["data"]["id"].int
track
}
}
}
fun updateLibManga(track: Track): Observable<Track> {
return Observable.defer {
// @formatter:off
val data = jsonObject(
"type" to "libraryEntries",
"id" to track.remote_id,
"attributes" to jsonObject(
"status" to track.toKitsuStatus(),
"progress" to track.last_chapter_read,
"rating" to track.toKitsuScore()
)
)
// @formatter:on
rest.updateLibManga(track.remote_id, jsonObject("data" to data))
.map { track }
}
}
fun search(query: String): Observable<List<Track>> {
return rest.search(query)
.map { json ->
val data = json["data"].array
data.map { KitsuManga(it.obj) }
.filter { it.type != "novel" }
.map { it.toTrack() }
}
}
fun findLibManga(track: Track, userId: String): Observable<Track?> {
return rest.findLibManga(track.remote_id, userId)
.map { json ->
val data = json["data"].array
if (data.size() > 0) {
KitsuLibManga(data[0].obj, json["included"].array[0].obj).toTrack()
} else {
null
}
}
}
fun getLibManga(track: Track): Observable<Track> {
return rest.getLibManga(track.remote_id)
.map { json ->
val data = json["data"].array
if (data.size() > 0) {
val include = json["included"].array[0].obj
KitsuLibManga(data[0].obj, include).toTrack()
} else {
throw Exception("Could not find manga")
}
}
}
fun login(username: String, password: String): Observable<OAuth> {
return Retrofit.Builder()
.baseUrl(loginUrl)
.client(client)
.addConverterFactory(GsonConverterFactory.create())
.addCallAdapterFactory(RxJavaCallAdapterFactory.create())
.build()
.create(KitsuApi.LoginRest::class.java)
.requestAccessToken(username, password)
}
fun getCurrentUser(): Observable<String> {
return rest.getCurrentUser().map { it["data"].array[0]["id"].string }
}
private interface Rest {
@Headers("Content-Type: application/vnd.api+json")
@POST("library-entries")
fun addLibManga(
@Body data: JsonObject
): Observable<JsonObject>
@Headers("Content-Type: application/vnd.api+json")
@PATCH("library-entries/{id}")
fun updateLibManga(
@Path("id") remoteId: Int,
@Body data: JsonObject
): Observable<JsonObject>
@GET("manga")
fun search(
@Query("filter[text]", encoded = true) query: String
): Observable<JsonObject>
@GET("library-entries")
fun findLibManga(
@Query("filter[media_id]", encoded = true) remoteId: Int,
@Query("filter[user_id]", encoded = true) userId: String,
@Query("page[limit]", encoded = true) limit: Int = 10000,
@Query("include") includes: String = "media"
): Observable<JsonObject>
@GET("library-entries")
fun getLibManga(
@Query("filter[id]", encoded = true) remoteId: Int,
@Query("include") includes: String = "media"
): Observable<JsonObject>
@GET("users")
fun getCurrentUser(
@Query("filter[self]", encoded = true) self: Boolean = true
): Observable<JsonObject>
}
private interface LoginRest {
@FormUrlEncoded
@POST("oauth/token")
fun requestAccessToken(
@Field("username") username: String,
@Field("password") password: String,
@Field("grant_type") grantType: String = "password",
@Field("client_id") client_id: String = clientId,
@Field("client_secret") client_secret: String = clientSecret
): Observable<OAuth>
}
companion object {
private const val clientId = "dd031b32d2f56c990b1425efe6c42ad847e7fe3ab46bf1299f05ecd856bdb7dd"
private const val clientSecret = "54d7307928f63414defd96399fc31ba847961ceaecef3a5fd93144e960c0e151"
private const val baseUrl = "https://kitsu.io/api/edge/"
private const val loginUrl = "https://kitsu.io/api/"
fun refreshTokenRequest(token: String) = POST("${loginUrl}oauth/token",
body = FormBody.Builder()
.add("grant_type", "refresh_token")
.add("client_id", clientId)
.add("client_secret", clientSecret)
.add("refresh_token", token)
.build())
}
}

View File

@ -0,0 +1,46 @@
package eu.kanade.tachiyomi.data.track.kitsu
import com.google.gson.Gson
import okhttp3.Interceptor
import okhttp3.Response
class KitsuInterceptor(val kitsu: Kitsu, val gson: Gson) : Interceptor {
/**
* OAuth object used for authenticated requests.
*/
private var oauth: OAuth? = kitsu.restoreToken()
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()
val currAuth = oauth ?: throw Exception("Not authenticated with Kitsu")
val refreshToken = currAuth.refresh_token!!
// Refresh access token if expired.
if (currAuth.isExpired()) {
val response = chain.proceed(KitsuApi.refreshTokenRequest(refreshToken))
if (response.isSuccessful) {
newAuth(gson.fromJson(response.body().string(), OAuth::class.java))
} else {
response.close()
}
}
// Add the authorization header to the original request.
val authRequest = originalRequest.newBuilder()
.addHeader("Authorization", "Bearer ${oauth!!.access_token}")
.header("Accept", "application/vnd.api+json")
.header("Content-Type", "application/vnd.api+json")
.build()
return chain.proceed(authRequest)
}
fun newAuth(oauth: OAuth?) {
this.oauth = oauth
kitsu.saveToken(oauth)
}
}

View File

@ -0,0 +1,58 @@
package eu.kanade.tachiyomi.data.track.kitsu
import android.support.annotation.CallSuper
import com.github.salomonbrys.kotson.*
import com.google.gson.JsonObject
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.TrackManager
open class KitsuManga(obj: JsonObject) {
val id by obj.byInt
val canonicalTitle by obj["attributes"].byString
val chapterCount = obj["attributes"].obj.get("chapterCount").nullInt
val type = obj["attributes"].obj.get("mangaType").nullString
@CallSuper
open fun toTrack() = Track.create(TrackManager.KITSU).apply {
remote_id = this@KitsuManga.id
title = canonicalTitle
total_chapters = chapterCount ?: 0
}
}
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 progress by obj["attributes"].byInt
override fun toTrack() = super.toTrack().apply {
remote_id = remoteId
status = toTrackStatus()
score = rating?.let { it.toFloat() * 2 } ?: 0f
last_chapter_read = progress
}
private fun toTrackStatus() = when (status) {
"current" -> Kitsu.READING
"completed" -> Kitsu.COMPLETED
"on_hold" -> Kitsu.ON_HOLD
"dropped" -> Kitsu.DROPPED
"planned" -> Kitsu.PLAN_TO_READ
else -> throw Exception("Unknown status")
}
}
fun Track.toKitsuStatus() = when (status) {
Kitsu.READING -> "current"
Kitsu.COMPLETED -> "completed"
Kitsu.ON_HOLD -> "on_hold"
Kitsu.DROPPED -> "dropped"
Kitsu.PLAN_TO_READ -> "planned"
else -> throw Exception("Unknown status")
}
fun Track.toKitsuScore(): String {
return if (score > 0) (score / 2).toString() else ""
}

View File

@ -0,0 +1,11 @@
package eu.kanade.tachiyomi.data.track.kitsu
data class OAuth(
val access_token: String,
val token_type: String,
val created_at: Long,
val expires_in: Long,
val refresh_token: String?) {
fun isExpired() = (System.currentTimeMillis() / 1000) > (created_at + expires_in - 3600)
}

View File

@ -0,0 +1,104 @@
package eu.kanade.tachiyomi.data.track.myanimelist
import android.content.Context
import android.graphics.Color
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.TrackService
import rx.Completable
import rx.Observable
class Myanimelist(private val context: Context, id: Int) : TrackService(id) {
companion object {
const val READING = 1
const val COMPLETED = 2
const val ON_HOLD = 3
const val DROPPED = 4
const val PLAN_TO_READ = 6
const val DEFAULT_STATUS = READING
const val DEFAULT_SCORE = 0
}
private val api by lazy { MyanimelistApi(client, getUsername(), getPassword()) }
override val name: String
get() = "MyAnimeList"
override fun getLogo() = R.drawable.mal
override fun getLogoColor() = Color.rgb(46, 81, 162)
override fun getStatus(status: Int): String = with(context) {
when (status) {
READING -> getString(R.string.reading)
COMPLETED -> getString(R.string.completed)
ON_HOLD -> getString(R.string.on_hold)
DROPPED -> getString(R.string.dropped)
PLAN_TO_READ -> getString(R.string.plan_to_read)
else -> ""
}
}
override fun getStatusList(): List<Int> {
return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLAN_TO_READ)
}
override fun getScoreList(): List<String> {
return IntRange(0, 10).map(Int::toString)
}
override fun displayScore(track: Track): String {
return track.score.toInt().toString()
}
override fun add(track: Track): Observable<Track> {
return api.addLibManga(track)
}
override fun update(track: Track): Observable<Track> {
if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) {
track.status = COMPLETED
}
return api.updateLibManga(track)
}
override fun bind(track: Track): Observable<Track> {
return api.findLibManga(track, getUsername())
.flatMap { remoteTrack ->
if (remoteTrack != null) {
track.copyPersonalFrom(remoteTrack)
update(track)
} else {
// Set default fields if it's not found in the list
track.score = DEFAULT_SCORE.toFloat()
track.status = DEFAULT_STATUS
add(track)
}
}
}
override fun search(query: String): Observable<List<Track>> {
return api.search(query, getUsername())
}
override fun refresh(track: Track): Observable<Track> {
return api.getLibManga(track, getUsername())
.map { remoteTrack ->
track.copyPersonalFrom(remoteTrack)
track.total_chapters = remoteTrack.total_chapters
track
}
}
override fun login(username: String, password: String): Completable {
return api.login(username, password)
.doOnNext { saveCredentials(username, password) }
.doOnError { logout() }
.toCompletable()
}
}

View File

@ -0,0 +1,187 @@
package eu.kanade.tachiyomi.data.track.myanimelist
import android.net.Uri
import android.util.Xml
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.network.GET
import eu.kanade.tachiyomi.data.network.POST
import eu.kanade.tachiyomi.data.network.asObservable
import eu.kanade.tachiyomi.data.network.asObservableSuccess
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.util.selectInt
import eu.kanade.tachiyomi.util.selectText
import okhttp3.*
import org.jsoup.Jsoup
import org.xmlpull.v1.XmlSerializer
import rx.Observable
import java.io.StringWriter
class MyanimelistApi(private val client: OkHttpClient, username: String, password: String) {
private var headers = createHeaders(username, password)
fun addLibManga(track: Track): Observable<Track> {
return Observable.defer {
client.newCall(POST(getAddUrl(track), headers, getMangaPostPayload(track)))
.asObservableSuccess()
.map { track }
}
}
fun updateLibManga(track: Track): Observable<Track> {
return Observable.defer {
client.newCall(POST(getUpdateUrl(track), headers, getMangaPostPayload(track)))
.asObservableSuccess()
.map { track }
}
}
fun search(query: String, username: String): Observable<List<Track>> {
return if (query.startsWith(PREFIX_MY)) {
val realQuery = query.substring(PREFIX_MY.length).toLowerCase().trim()
getList(username)
.flatMap { Observable.from(it) }
.filter { realQuery in it.title.toLowerCase() }
.toList()
} else {
client.newCall(GET(getSearchUrl(query), headers))
.asObservable()
.map { Jsoup.parse(it.body().string()) }
.flatMap { Observable.from(it.select("entry")) }
.filter { it.select("type").text() != "Novel" }
.map {
Track.create(TrackManager.MYANIMELIST).apply {
title = it.selectText("title")!!
remote_id = it.selectInt("id")
total_chapters = it.selectInt("chapters")
}
}
.toList()
}
}
fun getList(username: String): Observable<List<Track>> {
return client
.newCall(GET(getListUrl(username), headers))
.asObservable()
.map { Jsoup.parse(it.body().string()) }
.flatMap { Observable.from(it.select("manga")) }
.map {
Track.create(TrackManager.MYANIMELIST).apply {
title = it.selectText("series_title")!!
remote_id = it.selectInt("series_mangadb_id")
last_chapter_read = it.selectInt("my_read_chapters")
status = it.selectInt("my_status")
score = it.selectInt("my_score").toFloat()
total_chapters = it.selectInt("series_chapters")
}
}
.toList()
}
fun findLibManga(track: Track, username: String): Observable<Track?> {
return getList(username)
.map { list -> list.find { it.remote_id == track.remote_id } }
}
fun getLibManga(track: Track, username: String): Observable<Track> {
return findLibManga(track, username)
.map { it ?: throw Exception("Could not find manga") }
}
fun login(username: String, password: String): Observable<Response> {
headers = createHeaders(username, password)
return client.newCall(GET(getLoginUrl(), headers))
.asObservable()
.doOnNext { response ->
response.close()
if (response.code() != 200) throw Exception("Login error")
}
}
private fun getMangaPostPayload(track: Track): RequestBody {
val data = xml {
element(ENTRY_TAG) {
if (track.last_chapter_read != 0) {
text(CHAPTER_TAG, track.last_chapter_read.toString())
}
text(STATUS_TAG, track.status.toString())
text(SCORE_TAG, track.score.toString())
}
}
return FormBody.Builder()
.add("data", data)
.build()
}
private inline fun xml(block: XmlSerializer.() -> Unit): String {
val x = Xml.newSerializer()
val writer = StringWriter()
with(x) {
setOutput(writer)
startDocument("UTF-8", false)
block()
endDocument()
}
return writer.toString()
}
private inline fun XmlSerializer.element(tag: String, block: XmlSerializer.() -> Unit) {
startTag("", tag)
block()
endTag("", tag)
}
private fun XmlSerializer.text(tag: String, body: String) {
startTag("", tag)
text(body)
endTag("", tag)
}
fun getLoginUrl() = Uri.parse(baseUrl).buildUpon()
.appendEncodedPath("api/account/verify_credentials.xml")
.toString()
fun getSearchUrl(query: String) = Uri.parse(baseUrl).buildUpon()
.appendEncodedPath("api/manga/search.xml")
.appendQueryParameter("q", query)
.toString()
fun getListUrl(username: String) = Uri.parse(baseUrl).buildUpon()
.appendPath("malappinfo.php")
.appendQueryParameter("u", username)
.appendQueryParameter("status", "all")
.appendQueryParameter("type", "manga")
.toString()
fun getUpdateUrl(track: Track) = Uri.parse(baseUrl).buildUpon()
.appendEncodedPath("api/mangalist/update")
.appendPath("${track.remote_id}.xml")
.toString()
fun getAddUrl(track: Track) = Uri.parse(baseUrl).buildUpon()
.appendEncodedPath("api/mangalist/add")
.appendPath("${track.remote_id}.xml")
.toString()
fun createHeaders(username: String, password: String): Headers {
return Headers.Builder()
.add("Authorization", Credentials.basic(username, password))
.add("User-Agent", "api-indiv-9F93C52A963974CF674325391990191C")
.build()
}
companion object {
const val baseUrl = "https://myanimelist.net"
private val ENTRY_TAG = "entry"
private val CHAPTER_TAG = "chapter"
private val SCORE_TAG = "score"
private val STATUS_TAG = "status"
const val PREFIX_MY = "my:"
}
}

View File

@ -3,7 +3,6 @@ package eu.kanade.tachiyomi.data.updater
import android.app.IntentService
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.support.v4.app.NotificationCompat
import eu.kanade.tachiyomi.Constants.NOTIFICATION_UPDATER_ID
import eu.kanade.tachiyomi.R
@ -36,21 +35,6 @@ class UpdateDownloaderService : IntentService(UpdateDownloaderService::class.jav
}
context.startService(intent)
}
/**
* Prompt user with apk install intent
* @param context context
* @param file file of apk that is installed
*/
fun installAPK(context: Context, file: File) {
// Prompt install interface
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(Uri.fromFile(file), "application/vnd.android.package-archive")
// Without this flag android returned a intent error!
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}
context.startActivity(intent)
}
}
/**
@ -106,7 +90,7 @@ class UpdateDownloaderService : IntentService(UpdateDownloaderService::class.jav
throw Exception("Unsuccessful response")
}
val installIntent = UpdateNotificationReceiver.installApkIntent(ctx, apkFile.absolutePath)
val installIntent = UpdateNotificationReceiver.installApkIntent(ctx, apkFile)
// Prompt the user to install the new update.
NotificationCompat.Builder(this).update {

View File

@ -4,6 +4,10 @@ import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.support.v4.content.FileProvider
import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.Constants.NOTIFICATION_UPDATER_ID
import eu.kanade.tachiyomi.util.notificationManager
import java.io.File
@ -12,34 +16,14 @@ class UpdateNotificationReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
ACTION_INSTALL_APK -> {
UpdateDownloaderService.installAPK(context,
File(intent.getStringExtra(EXTRA_FILE_LOCATION)))
cancelNotification(context)
}
ACTION_DOWNLOAD_UPDATE -> UpdateDownloaderService.downloadUpdate(context,
intent.getStringExtra(UpdateDownloaderService.EXTRA_DOWNLOAD_URL))
ACTION_CANCEL_NOTIFICATION -> cancelNotification(context)
}
}
fun cancelNotification(context: Context) {
context.notificationManager.cancel(NOTIFICATION_UPDATER_ID)
}
companion object {
// Install apk action
const val ACTION_INSTALL_APK = "eu.kanade.INSTALL_APK"
// Download apk action
const val ACTION_DOWNLOAD_UPDATE = "eu.kanade.RETRY_DOWNLOAD"
// Cancel notification action
const val ACTION_CANCEL_NOTIFICATION = "eu.kanade.CANCEL_NOTIFICATION"
// Absolute path of apk file
const val EXTRA_FILE_LOCATION = "file_location"
fun cancelNotificationIntent(context: Context): PendingIntent {
val intent = Intent(context, UpdateNotificationReceiver::class.java).apply {
action = ACTION_CANCEL_NOTIFICATION
@ -47,20 +31,39 @@ class UpdateNotificationReceiver : BroadcastReceiver() {
return PendingIntent.getBroadcast(context, 0, intent, 0)
}
fun installApkIntent(context: Context, path: String): PendingIntent {
val intent = Intent(context, UpdateNotificationReceiver::class.java).apply {
action = ACTION_INSTALL_APK
putExtra(EXTRA_FILE_LOCATION, path)
/**
* Prompt user with apk install intent
*
* @param context context
* @param file file of apk that is installed
*/
fun installApkIntent(context: Context, file: File): PendingIntent {
val intent = Intent(Intent.ACTION_VIEW).apply {
val uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N)
FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".provider", file)
else Uri.fromFile(file)
setDataAndType(uri, "application/vnd.android.package-archive")
flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
}
return PendingIntent.getBroadcast(context, 0, intent, 0)
cancelNotification(context)
return PendingIntent.getActivity(context, 0, intent, 0)
}
/**
* Downloads a new update and let the user install the new version from a notification.
*
* @param context the application context.
* @param url the url to the new update.
*/
fun downloadApkIntent(context: Context, url: String): PendingIntent {
val intent = Intent(context, UpdateNotificationReceiver::class.java).apply {
action = ACTION_DOWNLOAD_UPDATE
val intent = Intent(context, UpdateDownloaderService::class.java).apply {
putExtra(UpdateDownloaderService.EXTRA_DOWNLOAD_URL, url)
}
return PendingIntent.getBroadcast(context, 0, intent, 0)
return PendingIntent.getService(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
}
fun cancelNotification(context: Context) {
context.notificationManager.cancel(NOTIFICATION_UPDATER_ID)
}
}

View File

@ -15,11 +15,17 @@ import uy.kohesive.injekt.api.get
interface ActivityMixin {
var resumed: Boolean
fun setupToolbar(toolbar: Toolbar, backNavigation: Boolean = true) {
setSupportActionBar(toolbar)
getSupportActionBar()?.setDisplayHomeAsUpEnabled(true)
if (backNavigation) {
toolbar.setNavigationOnClickListener { onBackPressed() }
toolbar.setNavigationOnClickListener {
if (resumed) {
onBackPressed()
}
}
}
}

View File

@ -1,9 +1,26 @@
package eu.kanade.tachiyomi.ui.base.activity
import android.support.v7.app.AppCompatActivity
import eu.kanade.tachiyomi.util.LocaleHelper
abstract class BaseActivity : AppCompatActivity(), ActivityMixin {
override var resumed = false
init {
LocaleHelper.updateConfiguration(this)
}
override fun getActivity() = this
override fun onResume() {
super.onResume()
resumed = true
}
override fun onPause() {
resumed = false
super.onPause()
}
}

View File

@ -3,10 +3,17 @@ package eu.kanade.tachiyomi.ui.base.activity
import android.os.Bundle
import eu.kanade.tachiyomi.App
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import eu.kanade.tachiyomi.util.LocaleHelper
import nucleus.view.NucleusAppCompatActivity
abstract class BaseRxActivity<P : BasePresenter<*>> : NucleusAppCompatActivity<P>(), ActivityMixin {
override var resumed = false
init {
LocaleHelper.updateConfiguration(this)
}
override fun onCreate(savedState: Bundle?) {
val superFactory = presenterFactory
setPresenterFactory {
@ -20,4 +27,14 @@ abstract class BaseRxActivity<P : BasePresenter<*>> : NucleusAppCompatActivity<P
override fun getActivity() = this
override fun onResume() {
super.onResume()
resumed = true
}
override fun onPause() {
resumed = false
super.onPause()
}
}

View File

@ -221,6 +221,7 @@ open class CatalogueFragment : BaseRxFragment<CataloguePresenter>(), FlexibleVie
// Setup filters button
menu.findItem(R.id.action_set_filter).apply {
icon.mutate()
if (presenter.source.filters.isEmpty()) {
isEnabled = false
icon.alpha = 128

View File

@ -6,7 +6,6 @@ import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.data.source.EN
import eu.kanade.tachiyomi.data.source.Source
import eu.kanade.tachiyomi.data.source.SourceManager
import eu.kanade.tachiyomi.data.source.model.MangasPage
@ -21,7 +20,7 @@ import rx.schedulers.Schedulers
import rx.subjects.PublishSubject
import timber.log.Timber
import uy.kohesive.injekt.injectLazy
import java.util.NoSuchElementException
import java.util.*
/**
* Presenter of [CatalogueFragment].
@ -333,13 +332,13 @@ open class CataloguePresenter : BasePresenter<CatalogueFragment>() {
// Ensure at least one language
if (languages.isEmpty()) {
languages.add(EN.code)
languages.add("en")
}
return sourceManager.getOnlineSources()
.filter { it.lang.code in languages }
.filter { it.lang in languages }
.filterNot { it.id.toString() in hiddenCatalogues }
.sortedBy { "(${it.lang.code}) ${it.name}" }
.sortedBy { "(${it.lang}) ${it.name}" }
}
/**

View File

@ -27,7 +27,9 @@ import nucleus.factory.RequiresPresenter
* UI related actions should be called from here.
*/
@RequiresPresenter(CategoryPresenter::class)
class CategoryActivity : BaseRxActivity<CategoryPresenter>(), ActionMode.Callback, FlexibleViewHolder.OnListItemClickListener, OnStartDragListener {
class CategoryActivity :
BaseRxActivity<CategoryPresenter>(),
ActionMode.Callback, FlexibleViewHolder.OnListItemClickListener, OnStartDragListener {
/**
* Object used to show actionMode toolbar.

View File

@ -1,7 +1,6 @@
package eu.kanade.tachiyomi.ui.category
import android.view.ViewGroup
import com.amulyakhare.textdrawable.util.ColorGenerator
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Category
@ -17,18 +16,10 @@ import java.util.*
* @param activity activity that created adapter
* @constructor Creates a CategoryAdapter object
*/
class CategoryAdapter(private val activity: CategoryActivity) : FlexibleAdapter<CategoryHolder, Category>(), ItemTouchHelperAdapter {
/**
* Generator used to generate circle letter icons
*/
private val generator: ColorGenerator
class CategoryAdapter(private val activity: CategoryActivity) :
FlexibleAdapter<CategoryHolder, Category>(), ItemTouchHelperAdapter {
init {
// Let generator use Material Design colors.
// Material design is love, material design is live!
generator = ColorGenerator.MATERIAL
// Set unique id's
setHasStableIds(true)
}
@ -54,7 +45,7 @@ class CategoryAdapter(private val activity: CategoryActivity) : FlexibleAdapter<
override fun onBindViewHolder(holder: CategoryHolder, position: Int) {
// Update holder values.
val category = getItem(position)
holder.onSetValues(category, generator)
holder.onSetValues(category)
//When user scrolls this bind the correct selection status
holder.itemView.isActivated = isSelected(position)

View File

@ -24,7 +24,12 @@ import kotlinx.android.synthetic.main.item_edit_categories.view.*
*
* @constructor Create CategoryHolder object
*/
class CategoryHolder(view: View, adapter: CategoryAdapter, listener: FlexibleViewHolder.OnListItemClickListener, dragListener: OnStartDragListener) : FlexibleViewHolder(view, adapter, listener) {
class CategoryHolder(
view: View,
adapter: CategoryAdapter,
listener: FlexibleViewHolder.OnListItemClickListener,
dragListener: OnStartDragListener
) : FlexibleViewHolder(view, adapter, listener) {
init {
// Create round letter image onclick to simulate long click
@ -46,29 +51,31 @@ class CategoryHolder(view: View, adapter: CategoryAdapter, listener: FlexibleVie
* Update category item values.
*
* @param category category of item.
* @param generator generator used to generate circle letter icons.
*/
fun onSetValues(category: Category, generator: ColorGenerator) {
fun onSetValues(category: Category) {
// Set capitalized title.
itemView.title.text = category.name.capitalize()
// Update circle letter image.
itemView.image.setImageDrawable(getRound(category.name.substring(0, 1).toUpperCase(), generator))
itemView.post {
itemView.image.setImageDrawable(getRound(category.name.take(1).toUpperCase()))
}
}
/**
* Returns circle letter image
*
* @param text first letter of string
* @param generator the generator used to generate circle letter image
*/
private fun getRound(text: String, generator: ColorGenerator): TextDrawable {
private fun getRound(text: String): TextDrawable {
val size = Math.min(itemView.image.width, itemView.image.height)
return TextDrawable.builder()
.beginConfig()
.width(size)
.height(size)
.textColor(Color.WHITE)
.useFont(Typeface.DEFAULT)
.toUpperCase()
.endConfig()
.buildRound(text, generator.getColor(text))
.buildRound(text, ColorGenerator.MATERIAL.getColor(text))
}
}

View File

@ -48,8 +48,9 @@ class DownloadHolder(private val view: View) : RecyclerView.ViewHolder(view) {
* Updates the progress bar of the download.
*/
fun notifyProgress() {
val pages = download.pages ?: return
if (view.download_progress.max == 1) {
view.download_progress.max = download.pages!!.size * 100
view.download_progress.max = pages.size * 100
}
view.download_progress.progress = download.totalProgress
}
@ -58,7 +59,8 @@ class DownloadHolder(private val view: View) : RecyclerView.ViewHolder(view) {
* Updates the text field of the number of downloaded pages.
*/
fun notifyDownloadedPages() {
view.download_progress_text.text = "${download.downloadedImages}/${download.pages!!.size}"
val pages = download.pages ?: return
view.download_progress_text.text = "${download.downloadedImages}/${pages.size}"
}
}

View File

@ -23,7 +23,7 @@ class LibraryCategoryAdapter(val fragment: LibraryCategoryView) :
/**
* The list of manga in this category.
*/
private var mangas: List<Manga>? = null
private var mangas: List<Manga> = emptyList()
init {
setHasStableIds(true)
@ -37,7 +37,7 @@ class LibraryCategoryAdapter(val fragment: LibraryCategoryView) :
fun setItems(list: List<Manga>) {
mItems = list
// A copy of manga that it's always unfiltered
// A copy of manga always unfiltered.
mangas = ArrayList(list)
updateDataSet(null)
}
@ -58,10 +58,8 @@ class LibraryCategoryAdapter(val fragment: LibraryCategoryView) :
* @param param the filter. Not used.
*/
override fun updateDataSet(param: String?) {
mangas?.let {
filterItems(it)
notifyDataSetChanged()
}
filterItems(mangas)
notifyDataSetChanged()
}
/**

View File

@ -3,9 +3,12 @@ package eu.kanade.tachiyomi.ui.library
import android.app.Activity
import android.content.Intent
import android.content.res.Configuration
import android.graphics.Color
import android.os.Bundle
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.view.ActionMode
import android.support.v7.widget.SearchView
import android.view.*
@ -20,6 +23,7 @@ import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.ui.base.fragment.BaseRxFragment
import eu.kanade.tachiyomi.ui.category.CategoryActivity
import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.util.inflate
import eu.kanade.tachiyomi.util.toast
import kotlinx.android.synthetic.main.activity_main.*
import kotlinx.android.synthetic.main.fragment_library.*
@ -73,22 +77,36 @@ class LibraryFragment : BaseRxFragment<LibraryPresenter>(), ActionMode.Callback
*/
private var selectedCoverManga: Manga? = null
/**
* Status of isFilterDownloaded
*/
var isFilterDownloaded = false
/**
* Status of isFilterUnread
*/
var isFilterUnread = false
/**
* Number of manga per row in grid mode.
*/
var mangaPerRow = 0
private set
/**
* Navigation view containing filter/sort/display items.
*/
private lateinit var navView: LibraryNavigationView
/**
* Drawer listener to allow swipe only for closing the drawer.
*/
private val drawerListener by lazy {
object : DrawerLayout.SimpleDrawerListener() {
override fun onDrawerClosed(drawerView: View) {
if (drawerView == navView) {
activity.drawer.setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED, navView)
}
}
override fun onDrawerOpened(drawerView: View) {
if (drawerView == navView) {
activity.drawer.setDrawerLockMode(DrawerLayout.LOCK_MODE_UNLOCKED, navView)
}
}
}
}
/**
* Subscription for the number of manga per row.
*/
@ -123,8 +141,6 @@ class LibraryFragment : BaseRxFragment<LibraryPresenter>(), ActionMode.Callback
override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState)
setHasOptionsMenu(true)
isFilterDownloaded = preferences.filterDownloaded().get() as Boolean
isFilterUnread = preferences.filterUnread().get() as Boolean
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedState: Bundle?): View? {
@ -146,7 +162,7 @@ class LibraryFragment : BaseRxFragment<LibraryPresenter>(), ActionMode.Callback
if (savedState != null) {
activeCategory = savedState.getInt(CATEGORY_KEY)
query = savedState.getString(QUERY_KEY)
presenter.searchSubject.onNext(query)
presenter.searchSubject.call(query)
if (presenter.selectedMangas.isNotEmpty()) {
createActionModeIfNeeded()
}
@ -159,6 +175,25 @@ class LibraryFragment : BaseRxFragment<LibraryPresenter>(), ActionMode.Callback
.skip(1)
// Set again the adapter to recalculate the covers height
.subscribe { reattachAdapter() }
// Inflate and prepare drawer
navView = activity.drawer.inflate(R.layout.library_drawer) as LibraryNavigationView
activity.drawer.addView(navView)
activity.drawer.addDrawerListener(drawerListener)
navView.post {
if (isAdded && !activity.drawer.isDrawerOpen(navView))
activity.drawer.setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED, navView)
}
navView.onGroupClicked = { group ->
when (group) {
is LibraryNavigationView.FilterGroup -> onFilterChanged()
is LibraryNavigationView.SortGroup -> onSortChanged()
is LibraryNavigationView.DisplayGroup -> reattachAdapter()
}
}
}
override fun onResume() {
@ -167,6 +202,8 @@ class LibraryFragment : BaseRxFragment<LibraryPresenter>(), ActionMode.Callback
}
override fun onDestroyView() {
activity.drawer.removeDrawerListener(drawerListener)
activity.drawer.removeView(navView)
numColumnsSubscription?.unsubscribe()
tabs.setupWithViewPager(null)
tabs.visibility = View.GONE
@ -182,9 +219,6 @@ class LibraryFragment : BaseRxFragment<LibraryPresenter>(), ActionMode.Callback
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.library, menu)
// Initialize search menu
val filterDownloadedItem = menu.findItem(R.id.action_filter_downloaded)
val filterUnreadItem = menu.findItem(R.id.action_filter_unread)
val searchItem = menu.findItem(R.id.action_search)
val searchView = searchItem.actionView as SearchView
@ -194,8 +228,8 @@ class LibraryFragment : BaseRxFragment<LibraryPresenter>(), ActionMode.Callback
searchView.clearFocus()
}
filterDownloadedItem.isChecked = isFilterDownloaded
filterUnreadItem.isChecked = isFilterUnread
// Mutate the filter icon because it needs to be tinted and the resource is shared.
menu.findItem(R.id.action_filter).icon.mutate()
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String): Boolean {
@ -211,35 +245,19 @@ class LibraryFragment : BaseRxFragment<LibraryPresenter>(), ActionMode.Callback
}
override fun onPrepareOptionsMenu(menu: Menu) {
val filterItem = menu.findItem(R.id.action_filter)
// Tint icon if there's a filter active
val filterColor = if (navView.hasActiveFilters()) Color.rgb(255, 238, 7) else Color.WHITE
DrawableCompat.setTint(filterItem.icon, filterColor)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.action_filter_unread -> {
// Change unread filter status.
isFilterUnread = !isFilterUnread
// Update settings.
preferences.filterUnread().set(isFilterUnread)
// Apply filter.
onFilterCheckboxChanged()
R.id.action_filter -> {
activity.drawer.openDrawer(Gravity.END)
}
R.id.action_filter_downloaded -> {
// Change downloaded filter status.
isFilterDownloaded = !isFilterDownloaded
// Update settings.
preferences.filterDownloaded().set(isFilterDownloaded)
// Apply filter.
onFilterCheckboxChanged()
}
R.id.action_filter_empty -> {
// Remove filter status.
isFilterUnread = false
isFilterDownloaded = false
// Update settings.
preferences.filterUnread().set(isFilterUnread)
preferences.filterDownloaded().set(isFilterDownloaded)
// Apply filter
onFilterCheckboxChanged()
}
R.id.action_library_display_mode -> swapDisplayMode()
R.id.action_update_library -> {
LibraryUpdateService.start(activity)
}
@ -254,19 +272,18 @@ class LibraryFragment : BaseRxFragment<LibraryPresenter>(), ActionMode.Callback
}
/**
* Applies filter change
* Called when a filter is changed.
*/
private fun onFilterCheckboxChanged() {
presenter.resubscribeLibrary()
private fun onFilterChanged() {
presenter.requestFilterUpdate()
activity.supportInvalidateOptionsMenu()
}
/**
* Swap display mode
* Called when the sorting mode is changed.
*/
private fun swapDisplayMode() {
presenter.swapDisplayMode()
reattachAdapter()
private fun onSortChanged() {
presenter.requestSortUpdate()
}
/**
@ -302,7 +319,7 @@ class LibraryFragment : BaseRxFragment<LibraryPresenter>(), ActionMode.Callback
// Notify the subject the query has changed.
if (isResumed) {
presenter.searchSubject.onNext(query)
presenter.searchSubject.call(query)
}
}
@ -330,7 +347,7 @@ class LibraryFragment : BaseRxFragment<LibraryPresenter>(), ActionMode.Callback
view_pager.post { if (isAdded) tabs.setScrollPosition(view_pager.currentItem, 0f, true) }
// Send the manga map to child fragments after the adapter is updated.
presenter.libraryMangaSubject.onNext(LibraryMangaEvent(mangaMap))
presenter.libraryMangaSubject.call(LibraryMangaEvent(mangaMap))
}
/**

View File

@ -0,0 +1,191 @@
package eu.kanade.tachiyomi.ui.library
import android.content.Context
import android.util.AttributeSet
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.widget.ExtendedNavigationView
import eu.kanade.tachiyomi.widget.ExtendedNavigationView.Item.MultiSort.Companion.SORT_ASC
import eu.kanade.tachiyomi.widget.ExtendedNavigationView.Item.MultiSort.Companion.SORT_DESC
import eu.kanade.tachiyomi.widget.ExtendedNavigationView.Item.MultiSort.Companion.SORT_NONE
import uy.kohesive.injekt.injectLazy
/**
* The navigation view shown in a drawer with the different options to show the library.
*/
class LibraryNavigationView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null)
: ExtendedNavigationView(context, attrs) {
/**
* Preferences helper.
*/
private val preferences: PreferencesHelper by injectLazy()
/**
* List of groups shown in the view.
*/
private val groups = listOf(FilterGroup(), SortGroup(), DisplayGroup())
/**
* Adapter instance.
*/
private val adapter = Adapter(groups.map { it.createItems() }.flatten())
/**
* Click listener to notify the parent fragment when an item from a group is clicked.
*/
var onGroupClicked: (Group) -> Unit = {}
init {
recycler.adapter = adapter
groups.forEach { it.initModels() }
}
/**
* Returns true if there's at least one filter from [FilterGroup] active.
*/
fun hasActiveFilters(): Boolean {
return (groups[0] as FilterGroup).items.any { it.checked }
}
/**
* Adapter of the recycler view.
*/
inner class Adapter(items: List<Item>) : ExtendedNavigationView.Adapter(items) {
override fun onItemClicked(item: Item) {
if (item is GroupedItem) {
item.group.onItemClicked(item)
onGroupClicked(item.group)
}
}
}
/**
* Filters group (unread, downloaded, ...).
*/
inner class FilterGroup : Group {
private val downloaded = Item.CheckboxGroup(R.string.action_filter_downloaded, this)
private val unread = Item.CheckboxGroup(R.string.action_filter_unread, this)
override val items = listOf(downloaded, unread)
override val header = Item.Header(R.string.action_filter)
override val footer = Item.Separator()
override fun initModels() {
downloaded.checked = preferences.filterDownloaded().getOrDefault()
unread.checked = preferences.filterUnread().getOrDefault()
}
override fun onItemClicked(item: Item) {
item as Item.CheckboxGroup
item.checked = !item.checked
when (item) {
downloaded -> preferences.filterDownloaded().set(item.checked)
unread -> preferences.filterUnread().set(item.checked)
}
adapter.notifyItemChanged(item)
}
}
/**
* Sorting group (alphabetically, by last read, ...) and ascending or descending.
*/
inner class SortGroup : Group {
private val alphabetically = Item.MultiSort(R.string.action_sort_alpha, this)
private val lastRead = Item.MultiSort(R.string.action_sort_last_read, this)
private val lastUpdated = Item.MultiSort(R.string.action_sort_last_updated, this)
private val unread = Item.MultiSort(R.string.action_filter_unread, this)
override val items = listOf(alphabetically, lastRead, lastUpdated, unread)
override val header = Item.Header(R.string.action_sort)
override val footer = Item.Separator()
override fun initModels() {
val sorting = preferences.librarySortingMode().getOrDefault()
val order = if (preferences.librarySortingAscending().getOrDefault())
SORT_ASC else SORT_DESC
alphabetically.state = if (sorting == LibrarySort.ALPHA) order else SORT_NONE
lastRead.state = if (sorting == LibrarySort.LAST_READ) order else SORT_NONE
lastUpdated.state = if (sorting == LibrarySort.LAST_UPDATED) order else SORT_NONE
unread.state = if (sorting == LibrarySort.UNREAD) order else SORT_NONE
}
override fun onItemClicked(item: Item) {
item as Item.MultiStateGroup
val prevState = item.state
item.group.items.forEach { (it as Item.MultiStateGroup).state = SORT_NONE }
item.state = when (prevState) {
SORT_NONE -> SORT_ASC
SORT_ASC -> SORT_DESC
SORT_DESC -> SORT_ASC
else -> throw Exception("Unknown state")
}
preferences.librarySortingMode().set(when (item) {
alphabetically -> LibrarySort.ALPHA
lastRead -> LibrarySort.LAST_READ
lastUpdated -> LibrarySort.LAST_UPDATED
unread -> LibrarySort.UNREAD
else -> throw Exception("Unknown sorting")
})
preferences.librarySortingAscending().set(if (item.state == SORT_ASC) true else false)
item.group.items.forEach { adapter.notifyItemChanged(it) }
}
}
/**
* Display group, to show the library as a list or a grid.
*/
inner class DisplayGroup : Group {
private val grid = Item.Radio(R.string.action_display_grid, this)
private val list = Item.Radio(R.string.action_display_list, this)
override val items = listOf(grid, list)
override val header = Item.Header(R.string.action_display)
override val footer = null
override fun initModels() {
val asList = preferences.libraryAsList().getOrDefault()
grid.checked = !asList
list.checked = asList
}
override fun onItemClicked(item: Item) {
item as Item.Radio
if (item.checked) return
item.group.items.forEach { (it as Item.Radio).checked = false }
item.checked = true
preferences.libraryAsList().set(if (item == list) true else false)
item.group.items.forEach { adapter.notifyItemChanged(it) }
}
}
}

View File

@ -2,6 +2,9 @@ package eu.kanade.tachiyomi.ui.library
import android.os.Bundle
import android.util.Pair
import com.hippo.unifile.UniFile
import com.jakewharton.rxrelay.BehaviorRelay
import com.jakewharton.rxrelay.PublishRelay
import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Category
@ -12,11 +15,12 @@ import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.data.source.SourceManager
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import eu.kanade.tachiyomi.util.combineLatest
import eu.kanade.tachiyomi.util.isNullOrUnsubscribed
import rx.Observable
import rx.Subscription
import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers
import rx.subjects.BehaviorSubject
import rx.subjects.PublishSubject
import uy.kohesive.injekt.injectLazy
import java.io.IOException
import java.io.InputStream
@ -27,6 +31,31 @@ import java.util.*
*/
class LibraryPresenter : BasePresenter<LibraryFragment>() {
/**
* Database.
*/
private val db: DatabaseHelper by injectLazy()
/**
* Preferences.
*/
private val preferences: PreferencesHelper by injectLazy()
/**
* Cover cache.
*/
private val coverCache: CoverCache by injectLazy()
/**
* Source manager.
*/
private val sourceManager: SourceManager by injectLazy()
/**
* Download manager.
*/
private val downloadManager: DownloadManager by injectLazy()
/**
* Categories of the library.
*/
@ -40,61 +69,139 @@ class LibraryPresenter : BasePresenter<LibraryFragment>() {
/**
* Search query of the library.
*/
val searchSubject: BehaviorSubject<String> = BehaviorSubject.create()
val searchSubject: BehaviorRelay<String> = BehaviorRelay.create()
/**
* Subject to notify the library's viewpager for updates.
*/
val libraryMangaSubject: BehaviorSubject<LibraryMangaEvent> = BehaviorSubject.create()
val libraryMangaSubject: BehaviorRelay<LibraryMangaEvent> = BehaviorRelay.create()
/**
* Subject to notify the UI of selection updates.
*/
val selectionSubject: PublishSubject<LibrarySelectionEvent> = PublishSubject.create()
val selectionSubject: PublishRelay<LibrarySelectionEvent> = PublishRelay.create()
/**
* Database.
* Relay used to apply the UI filters to the last emission of the library.
*/
val db: DatabaseHelper by injectLazy()
private val filterTriggerRelay = BehaviorRelay.create(Unit)
/**
* Preferences.
* Relay used to apply the selected sorting method to the last emission of the library.
*/
val preferences: PreferencesHelper by injectLazy()
private val sortTriggerRelay = BehaviorRelay.create(Unit)
/**
* Cover cache.
* Library subscription.
*/
val coverCache: CoverCache by injectLazy()
/**
* Source manager.
*/
val sourceManager: SourceManager by injectLazy()
/**
* Download manager.
*/
val downloadManager: DownloadManager by injectLazy()
companion object {
/**
* Id of the restartable that listens for library updates.
*/
const val GET_LIBRARY = 1
}
private var librarySubscription: Subscription? = null
override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState)
subscribeLibrary()
}
restartableLatestCache(GET_LIBRARY,
{ getLibraryObservable() },
{ view, pair -> view.onNextLibraryUpdate(pair.first, pair.second) })
/**
* Subscribes to library if needed.
*/
fun subscribeLibrary() {
if (librarySubscription.isNullOrUnsubscribed()) {
librarySubscription = getLibraryObservable()
.combineLatest(filterTriggerRelay.observeOn(Schedulers.io()),
{ lib, tick -> Pair(lib.first, applyFilters(lib.second)) })
.combineLatest(sortTriggerRelay.observeOn(Schedulers.io()),
{ lib, tick -> Pair(lib.first, applySort(lib.second)) })
.observeOn(AndroidSchedulers.mainThread())
.subscribeLatestCache({ view, pair ->
view.onNextLibraryUpdate(pair.first, pair.second)
})
}
}
if (savedState == null) {
start(GET_LIBRARY)
/**
* Applies library filters to the given map of manga.
*
* @param map the map to filter.
*/
private fun applyFilters(map: Map<Int, List<Manga>>): Map<Int, List<Manga>> {
// Cached list of downloaded manga directories given a source id.
val mangaDirectories = mutableMapOf<Int, Array<UniFile>>()
// Cached list of downloaded chapter directories for a manga.
val chapterDirectories = mutableMapOf<Long, Boolean>()
val filterDownloaded = preferences.filterDownloaded().getOrDefault()
val filterUnread = preferences.filterUnread().getOrDefault()
val filterFn: (Manga) -> Boolean = f@ { manga ->
// Filter out manga without source.
val source = sourceManager.get(manga.source) ?: return@f false
// Filter when there isn't unread chapters.
if (filterUnread && manga.unread == 0) {
return@f false
}
// Filter when the download directory doesn't exist or is null.
if (filterDownloaded) {
val mangaDirs = mangaDirectories.getOrPut(source.id) {
downloadManager.findSourceDir(source)?.listFiles() ?: emptyArray()
}
val mangaDirName = downloadManager.getMangaDirName(manga)
val mangaDir = mangaDirs.find { it.name == mangaDirName } ?: return@f false
val hasDirs = chapterDirectories.getOrPut(manga.id!!) {
(mangaDir.listFiles() ?: emptyArray()).isNotEmpty()
}
if (!hasDirs) {
return@f false
}
}
true
}
return map.mapValues { entry -> entry.value.filter(filterFn) }
}
/**
* Applies library sorting to the given map of manga.
*
* @param map the map to sort.
*/
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) {
var counter = 0
lastReadManga = db.getLastReadManga().executeAsBlocking()
.associate { it.id!! to counter++ }
}
val sortFn: (Manga, Manga) -> Int = { manga1, manga2 ->
when (sortingMode) {
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
manga1LastRead.compareTo(manga2LastRead)
}
LibrarySort.LAST_UPDATED -> manga2.last_update.compareTo(manga1.last_update)
LibrarySort.UNREAD -> manga1.unread.compareTo(manga2.unread)
else -> throw Exception("Unknown sorting mode")
}
}
val comparator = if (preferences.librarySortingAscending().getOrDefault())
Comparator(sortFn)
else
Collections.reverseOrder(sortFn)
return map.mapValues { entry -> entry.value.sortedWith(comparator) }
}
/**
@ -102,7 +209,7 @@ class LibraryPresenter : BasePresenter<LibraryFragment>() {
*
* @return an observable of the categories and its manga.
*/
fun getLibraryObservable(): Observable<Pair<List<Category>, Map<Int, List<Manga>>>> {
private fun getLibraryObservable(): Observable<Pair<List<Category>, Map<Int, List<Manga>>>> {
return Observable.combineLatest(getCategoriesObservable(), getLibraryMangasObservable(),
{ dbCategories, libraryManga ->
val categories = if (libraryManga.containsKey(0))
@ -113,7 +220,6 @@ class LibraryPresenter : BasePresenter<LibraryFragment>() {
this.categories = categories
Pair(categories, libraryManga)
})
.observeOn(AndroidSchedulers.mainThread())
}
/**
@ -121,7 +227,7 @@ class LibraryPresenter : BasePresenter<LibraryFragment>() {
*
* @return an observable of the categories.
*/
fun getCategoriesObservable(): Observable<List<Category>> {
private fun getCategoriesObservable(): Observable<List<Category>> {
return db.getCategories().asRxObservable()
}
@ -131,76 +237,23 @@ class LibraryPresenter : BasePresenter<LibraryFragment>() {
* @return an observable containing a map with the category id as key and a list of manga as the
* value.
*/
fun getLibraryMangasObservable(): Observable<Map<Int, List<Manga>>> {
private fun getLibraryMangasObservable(): Observable<Map<Int, List<Manga>>> {
return db.getLibraryMangas().asRxObservable()
.flatMap { mangas ->
Observable.from(mangas)
// Filter library by options
.filter { filterManga(it) }
.groupBy { it.category }
.flatMap { group -> group.toList().map { Pair(group.key, it) } }
.toMap({ it.first }, { it.second })
}
.map { list -> list.groupBy { it.category } }
}
/**
* Resubscribes to library if needed.
* Requests the library to be filtered.
*/
fun subscribeLibrary() {
if (isUnsubscribed(GET_LIBRARY)) {
start(GET_LIBRARY)
}
fun requestFilterUpdate() {
filterTriggerRelay.call(Unit)
}
/**
* Resubscribes to library.
* Requests the library to be sorted.
*/
fun resubscribeLibrary() {
start(GET_LIBRARY)
}
/**
* Filters an entry of the library.
*
* @param manga a favorite manga from the database.
* @return true if the entry is included, false otherwise.
*/
fun filterManga(manga: Manga): Boolean {
// Filter out manga without source
val source = sourceManager.get(manga.source) ?: return false
val prefFilterDownloaded = preferences.filterDownloaded().getOrDefault()
val prefFilterUnread = preferences.filterUnread().getOrDefault()
// Check if filter option is selected
if (prefFilterDownloaded || prefFilterUnread) {
// Does it have downloaded chapters.
var hasDownloaded = false
var hasUnread = false
if (prefFilterUnread) {
// Does it have unread chapters.
hasUnread = manga.unread > 0
}
if (prefFilterDownloaded) {
val mangaDir = downloadManager.findMangaDir(source, manga)
if (mangaDir != null) {
hasDownloaded = mangaDir.listFiles()?.any { it.isDirectory } ?: false
}
}
// Return correct filter status
if (prefFilterDownloaded && prefFilterUnread) {
return (hasDownloaded && hasUnread)
} else {
return (hasDownloaded || hasUnread)
}
} else {
return true
}
fun requestSortUpdate() {
sortTriggerRelay.call(Unit)
}
/**
@ -208,7 +261,7 @@ class LibraryPresenter : BasePresenter<LibraryFragment>() {
*/
fun onOpenManga() {
// Avoid further db updates for the library when it's not needed
stop(GET_LIBRARY)
librarySubscription?.let { remove(it) }
}
/**
@ -220,10 +273,10 @@ class LibraryPresenter : BasePresenter<LibraryFragment>() {
fun setSelection(manga: Manga, selected: Boolean) {
if (selected) {
selectedMangas.add(manga)
selectionSubject.onNext(LibrarySelectionEvent.Selected(manga))
selectionSubject.call(LibrarySelectionEvent.Selected(manga))
} else {
selectedMangas.remove(manga)
selectionSubject.onNext(LibrarySelectionEvent.Unselected(manga))
selectionSubject.call(LibrarySelectionEvent.Unselected(manga))
}
}
@ -232,7 +285,7 @@ class LibraryPresenter : BasePresenter<LibraryFragment>() {
*/
fun clearSelections() {
selectedMangas.clear()
selectionSubject.onNext(LibrarySelectionEvent.Cleared())
selectionSubject.call(LibrarySelectionEvent.Cleared())
}
/**
@ -240,9 +293,12 @@ class LibraryPresenter : BasePresenter<LibraryFragment>() {
*
* @param mangas the list of manga.
*/
fun getCommonCategories(mangas: List<Manga>): Collection<Category> = mangas.toSet()
.map { db.getCategoriesForManga(it).executeAsBlocking() }
.reduce { set1: Iterable<Category>, set2 -> set1.intersect(set2) }
fun getCommonCategories(mangas: List<Manga>): Collection<Category> {
if (mangas.isEmpty()) return emptyList()
return mangas.toSet()
.map { db.getCategoriesForManga(it).executeAsBlocking() }
.reduce { set1: Iterable<Category>, set2 -> set1.intersect(set2) }
}
/**
* Remove the selected manga from the library.
@ -296,12 +352,4 @@ class LibraryPresenter : BasePresenter<LibraryFragment>() {
return false
}
/**
* Changes the active display mode.
*/
fun swapDisplayMode() {
val displayAsList = preferences.libraryAsList().getOrDefault()
preferences.libraryAsList().set(!displayAsList)
}
}

View File

@ -0,0 +1,10 @@
package eu.kanade.tachiyomi.ui.library
object LibrarySort {
const val ALPHA = 0
const val LAST_READ = 1
const val LAST_UPDATED = 2
const val UNREAD = 3
}

View File

@ -93,8 +93,12 @@ class MainActivity : BaseActivity() {
override fun onBackPressed() {
val fragment = supportFragmentManager.findFragmentById(R.id.frame_container)
if (fragment != null && fragment.tag.toInt() != startScreenId) {
setSelectedDrawerItem(startScreenId)
if (drawer.isDrawerOpen(GravityCompat.START) || drawer.isDrawerOpen(GravityCompat.END)) {
drawer.closeDrawers()
} else if (fragment != null && fragment.tag.toInt() != startScreenId) {
if (resumed) {
setSelectedDrawerItem(startScreenId)
}
} else {
super.onBackPressed()
}
@ -110,6 +114,8 @@ class MainActivity : BaseActivity() {
} else if (resultCode and SettingsActivity.FLAG_THEME_CHANGED != 0) {
// Delay activity recreation to avoid fragment leaks.
nav_view.post { recreate() }
} else if (resultCode and SettingsActivity.FLAG_LANG_CHANGED != 0) {
nav_view.post { recreate() }
}
} else {
super.onActivityResult(requestCode, resultCode, data)
@ -136,4 +142,4 @@ class MainActivity : BaseActivity() {
companion object {
private const val REQUEST_OPEN_SETTINGS = 200
}
}
}

View File

@ -3,16 +3,20 @@ package eu.kanade.tachiyomi.ui.manga
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.support.graphics.drawable.VectorDrawableCompat
import android.support.v4.app.Fragment
import android.support.v4.app.FragmentManager
import android.support.v4.app.FragmentPagerAdapter
import android.widget.LinearLayout
import android.widget.TextView
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.ui.base.activity.BaseRxActivity
import eu.kanade.tachiyomi.ui.manga.chapter.ChaptersFragment
import eu.kanade.tachiyomi.ui.manga.info.MangaInfoFragment
import eu.kanade.tachiyomi.ui.manga.myanimelist.MyAnimeListFragment
import eu.kanade.tachiyomi.ui.manga.track.TrackFragment
import eu.kanade.tachiyomi.util.SharedData
import eu.kanade.tachiyomi.util.toast
import kotlinx.android.synthetic.main.activity_manga.*
import kotlinx.android.synthetic.main.toolbar.*
import nucleus.factory.RequiresPresenter
@ -27,7 +31,7 @@ class MangaActivity : BaseRxActivity<MangaPresenter>() {
const val FROM_LAUNCHER_EXTRA = "from_launcher"
const val INFO_FRAGMENT = 0
const val CHAPTERS_FRAGMENT = 1
const val MYANIMELIST_FRAGMENT = 2
const val TRACK_FRAGMENT = 2
fun newIntent(context: Context, manga: Manga, fromCatalogue: Boolean = false): Intent {
SharedData.put(MangaEvent(manga))
@ -50,12 +54,19 @@ class MangaActivity : BaseRxActivity<MangaPresenter>() {
val fromLauncher = intent.getBooleanExtra(FROM_LAUNCHER_EXTRA, false)
//Remove any current manga if we are launching from launcher
if(fromLauncher) SharedData.remove(MangaEvent::class.java)
// Remove any current manga if we are launching from launcher
if (fromLauncher) SharedData.remove(MangaEvent::class.java)
presenter.setMangaEvent(SharedData.getOrPut(MangaEvent::class.java) {
val id = intent.getLongExtra(MANGA_EXTRA, 0)
MangaEvent(presenter.db.getManga(id).executeAsBlocking()!!)
val dbManga = presenter.db.getManga(id).executeAsBlocking()
if (dbManga != null) {
MangaEvent(dbManga)
} else {
toast(R.string.manga_not_in_db)
finish()
return
}
})
setupToolbar(toolbar)
@ -63,6 +74,7 @@ class MangaActivity : BaseRxActivity<MangaPresenter>() {
fromCatalogue = intent.getBooleanExtra(FROM_CATALOGUE_EXTRA, false)
adapter = MangaDetailAdapter(supportFragmentManager, this)
view_pager.offscreenPageLimit = 3
view_pager.adapter = adapter
tabs.setupWithViewPager(view_pager)
@ -77,33 +89,50 @@ class MangaActivity : BaseRxActivity<MangaPresenter>() {
setToolbarTitle(manga.title)
}
internal class MangaDetailAdapter(fm: FragmentManager, activity: MangaActivity) : FragmentPagerAdapter(fm) {
fun setTrackingIcon(visible: Boolean) {
val tab = tabs.getTabAt(TRACK_FRAGMENT) ?: return
val drawable = if (visible)
VectorDrawableCompat.create(resources, R.drawable.ic_done_white_18dp, null)
else null
private var pageCount: Int = 0
private val tabTitles = arrayOf(activity.getString(R.string.manga_detail_tab),
activity.getString(R.string.manga_chapters_tab), "MAL")
// I had no choice but to use reflection...
val field = tab.javaClass.getDeclaredField("mView").apply { isAccessible = true }
val view = field.get(tab) as LinearLayout
val textView = view.getChildAt(1) as TextView
textView.setCompoundDrawablesWithIntrinsicBounds(null, null, drawable, null)
textView.compoundDrawablePadding = 4
}
private class MangaDetailAdapter(fm: FragmentManager, activity: MangaActivity)
: FragmentPagerAdapter(fm) {
private var tabCount = 2
private val tabTitles = listOf(
R.string.manga_detail_tab,
R.string.manga_chapters_tab,
R.string.manga_tracking_tab)
.map { activity.getString(it) }
init {
pageCount = 2
if (!activity.fromCatalogue && activity.presenter.syncManager.myAnimeList.isLogged)
pageCount++
if (!activity.fromCatalogue && activity.presenter.trackManager.hasLoggedServices())
tabCount++
}
override fun getCount(): Int {
return pageCount
return tabCount
}
override fun getItem(position: Int): Fragment? {
override fun getItem(position: Int): Fragment {
when (position) {
INFO_FRAGMENT -> return MangaInfoFragment.newInstance()
CHAPTERS_FRAGMENT -> return ChaptersFragment.newInstance()
MYANIMELIST_FRAGMENT -> return MyAnimeListFragment.newInstance()
else -> return null
TRACK_FRAGMENT -> return TrackFragment.newInstance()
else -> throw Exception("Unknown position")
}
}
override fun getPageTitle(position: Int): CharSequence {
// Generate title based on item position
return tabTitles[position]
}

View File

@ -3,7 +3,7 @@ package eu.kanade.tachiyomi.ui.manga
import android.os.Bundle
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.mangasync.MangaSyncManager
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import eu.kanade.tachiyomi.ui.manga.info.ChapterCountEvent
import eu.kanade.tachiyomi.util.SharedData
@ -22,9 +22,9 @@ class MangaPresenter : BasePresenter<MangaActivity>() {
val db: DatabaseHelper by injectLazy()
/**
* Manga sync manager.
* Tracking manager.
*/
val syncManager: MangaSyncManager by injectLazy()
val trackManager: TrackManager by injectLazy()
/**
* Manga associated with this instance.

View File

@ -118,7 +118,7 @@ class ChaptersFragment : BaseRxFragment<ChaptersPresenter>(), ActionMode.Callbac
override fun onPrepareOptionsMenu(menu: Menu) {
// Initialize menu items.
val menuFilterRead = menu.findItem(R.id.action_filter_read)
val menuFilterRead = menu.findItem(R.id.action_filter_read) ?: return
val menuFilterUnread = menu.findItem(R.id.action_filter_unread)
val menuFilterDownloaded = menu.findItem(R.id.action_filter_downloaded)
val menuFilterBookmarked = menu.findItem(R.id.action_filter_bookmarked)
@ -394,7 +394,8 @@ class ChaptersFragment : BaseRxFragment<ChaptersPresenter>(), ActionMode.Callbac
}
fun dismissDeletingDialog() {
(childFragmentManager.findFragmentByTag(DeletingChaptersDialog.TAG) as? DialogFragment)?.dismiss()
(childFragmentManager.findFragmentByTag(DeletingChaptersDialog.TAG) as? DialogFragment)
?.dismissAllowingStateLoss()
}
override fun onListItemClick(position: Int): Boolean {

View File

@ -1,124 +0,0 @@
package eu.kanade.tachiyomi.ui.manga.myanimelist
import android.app.Dialog
import android.os.Bundle
import android.support.v4.app.DialogFragment
import android.view.View
import com.afollestad.materialdialogs.MaterialDialog
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.MangaSync
import eu.kanade.tachiyomi.widget.SimpleTextWatcher
import kotlinx.android.synthetic.main.dialog_myanimelist_search.view.*
import rx.Subscription
import rx.android.schedulers.AndroidSchedulers
import rx.subjects.PublishSubject
import java.util.concurrent.TimeUnit
class MyAnimeListDialogFragment : DialogFragment() {
companion object {
fun newInstance(): MyAnimeListDialogFragment {
return MyAnimeListDialogFragment()
}
}
private lateinit var v: View
lateinit var adapter: MyAnimeListSearchAdapter
private set
lateinit var querySubject: PublishSubject<String>
private set
private var selectedItem: MangaSync? = null
private var searchSubscription: Subscription? = null
override fun onCreateDialog(savedState: Bundle?): Dialog {
val dialog = MaterialDialog.Builder(activity)
.customView(R.layout.dialog_myanimelist_search, false)
.positiveText(android.R.string.ok)
.negativeText(android.R.string.cancel)
.onPositive { dialog1, which -> onPositiveButtonClick() }
.build()
onViewCreated(dialog.view, savedState)
return dialog
}
override fun onViewCreated(view: View, savedState: Bundle?) {
v = view
// Create adapter
adapter = MyAnimeListSearchAdapter(activity)
view.myanimelist_search_results.adapter = adapter
// Set listeners
view.myanimelist_search_results.setOnItemClickListener { parent, viewList, position, id ->
selectedItem = adapter.getItem(position)
}
// Do an initial search based on the manga's title
if (savedState == null) {
val title = presenter.manga.title
view.myanimelist_search_field.append(title)
search(title)
}
querySubject = PublishSubject.create<String>()
view.myanimelist_search_field.addTextChangedListener(object : SimpleTextWatcher() {
override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {
querySubject.onNext(s.toString())
}
})
}
override fun onResume() {
super.onResume()
// Listen to text changes
searchSubscription = querySubject.debounce(1, TimeUnit.SECONDS)
.observeOn(AndroidSchedulers.mainThread())
.subscribe { search(it) }
}
override fun onPause() {
searchSubscription?.unsubscribe()
super.onPause()
}
private fun onPositiveButtonClick() {
presenter.registerManga(selectedItem)
}
private fun search(query: String) {
if (!query.isNullOrEmpty()) {
v.myanimelist_search_results.visibility = View.GONE
v.progress.visibility = View.VISIBLE
presenter.searchManga(query)
}
}
fun onSearchResults(results: List<MangaSync>) {
selectedItem = null
v.progress.visibility = View.GONE
v.myanimelist_search_results.visibility = View.VISIBLE
adapter.setItems(results)
}
fun onSearchResultsError() {
v.progress.visibility = View.GONE
v.myanimelist_search_results.visibility = View.VISIBLE
adapter.clear()
}
val malFragment: MyAnimeListFragment
get() = parentFragment as MyAnimeListFragment
val presenter: MyAnimeListPresenter
get() = malFragment.presenter
}

View File

@ -1,177 +0,0 @@
package eu.kanade.tachiyomi.ui.manga.myanimelist
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.NumberPicker
import com.afollestad.materialdialogs.MaterialDialog
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.MangaSync
import eu.kanade.tachiyomi.ui.base.fragment.BaseRxFragment
import eu.kanade.tachiyomi.util.toast
import kotlinx.android.synthetic.main.card_myanimelist_personal.*
import kotlinx.android.synthetic.main.fragment_myanimelist.*
import nucleus.factory.RequiresPresenter
import java.text.DecimalFormat
@RequiresPresenter(MyAnimeListPresenter::class)
class MyAnimeListFragment : BaseRxFragment<MyAnimeListPresenter>() {
companion object {
fun newInstance(): MyAnimeListFragment {
return MyAnimeListFragment()
}
}
private var dialog: MyAnimeListDialogFragment? = null
private val decimalFormat = DecimalFormat("#.##")
private val SEARCH_FRAGMENT_TAG = "mal_search"
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedState: Bundle?): View? {
return inflater.inflate(R.layout.fragment_myanimelist, container, false)
}
override fun onViewCreated(view: View, savedState: Bundle?) {
swipe_refresh.isEnabled = false
swipe_refresh.setOnRefreshListener { presenter.refresh() }
myanimelist_title_layout.setOnClickListener { onTitleClick() }
myanimelist_status_layout.setOnClickListener { onStatusClick() }
myanimelist_chapters_layout.setOnClickListener { onChaptersClick() }
myanimelist_score_layout.setOnClickListener { onScoreClick() }
}
@Suppress("DEPRECATION")
fun setMangaSync(mangaSync: MangaSync?) {
swipe_refresh.isEnabled = mangaSync != null
mangaSync?.let {
myanimelist_title.setTextAppearance(context, R.style.TextAppearance_Regular_Body1_Secondary)
myanimelist_title.setAllCaps(false)
myanimelist_title.text = it.title
myanimelist_chapters.text = if (it.total_chapters > 0)
"${it.last_chapter_read}/${it.total_chapters}" else "${it.last_chapter_read}/-"
myanimelist_score.text = if (it.score == 0f) "-" else decimalFormat.format(it.score)
myanimelist_status.text = presenter.myAnimeList.getStatus(it.status)
} ?: run {
myanimelist_title.setTextAppearance(context, R.style.TextAppearance_Medium_Button)
myanimelist_title.setText(R.string.action_edit)
myanimelist_chapters.text = ""
myanimelist_score.text = ""
myanimelist_status.text = ""
}
}
fun onRefreshDone() {
swipe_refresh.isRefreshing = false
}
fun onRefreshError(error: Throwable) {
swipe_refresh.isRefreshing = false
context.toast(error.message)
}
fun setSearchResults(results: List<MangaSync>) {
findSearchFragmentIfNeeded()
dialog?.onSearchResults(results)
}
fun setSearchResultsError(error: Throwable) {
findSearchFragmentIfNeeded()
context.toast(error.message)
dialog?.onSearchResultsError()
}
private fun findSearchFragmentIfNeeded() {
if (dialog == null) {
dialog = childFragmentManager.findFragmentByTag(SEARCH_FRAGMENT_TAG) as MyAnimeListDialogFragment
}
}
fun onTitleClick() {
if (dialog == null) {
dialog = MyAnimeListDialogFragment.newInstance()
}
presenter.restartSearch()
dialog?.show(childFragmentManager, SEARCH_FRAGMENT_TAG)
}
fun onStatusClick() {
if (presenter.mangaSync == null)
return
MaterialDialog.Builder(activity)
.title(R.string.status)
.items(presenter.getAllStatus())
.itemsCallbackSingleChoice(presenter.getIndexFromStatus(), { dialog, view, i, charSequence ->
presenter.setStatus(i)
myanimelist_status.text = "..."
true
})
.show()
}
fun onChaptersClick() {
if (presenter.mangaSync == null)
return
val dialog = MaterialDialog.Builder(activity)
.title(R.string.chapters)
.customView(R.layout.dialog_myanimelist_chapters, false)
.positiveText(android.R.string.ok)
.negativeText(android.R.string.cancel)
.onPositive { d, action ->
val view = d.customView
if (view != null) {
val np = view.findViewById(R.id.chapters_picker) as NumberPicker
np.clearFocus()
presenter.setLastChapterRead(np.value)
myanimelist_chapters.text = "..."
}
}
.show()
val view = dialog.customView
if (view != null) {
val np = view.findViewById(R.id.chapters_picker) as NumberPicker
// Set initial value
np.value = presenter.mangaSync!!.last_chapter_read
// Don't allow to go from 0 to 9999
np.wrapSelectorWheel = false
}
}
fun onScoreClick() {
if (presenter.mangaSync == null)
return
val dialog = MaterialDialog.Builder(activity)
.title(R.string.score)
.customView(R.layout.dialog_myanimelist_score, false)
.positiveText(android.R.string.ok)
.negativeText(android.R.string.cancel)
.onPositive { d, action ->
val view = d.customView
if (view != null) {
val np = view.findViewById(R.id.score_picker) as NumberPicker
np.clearFocus()
presenter.setScore(np.value)
myanimelist_score.text = "..."
}
}
.show()
val view = dialog.customView
if (view != null) {
val np = view.findViewById(R.id.score_picker) as NumberPicker
// Set initial value
np.value = presenter.mangaSync!!.score.toInt()
}
}
}

View File

@ -1,174 +0,0 @@
package eu.kanade.tachiyomi.ui.manga.myanimelist
import android.os.Bundle
import com.pushtorefresh.storio.sqlite.operations.put.PutResult
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.models.MangaSync
import eu.kanade.tachiyomi.data.mangasync.MangaSyncManager
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import eu.kanade.tachiyomi.ui.manga.MangaEvent
import eu.kanade.tachiyomi.util.SharedData
import eu.kanade.tachiyomi.util.toast
import rx.Observable
import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers
import timber.log.Timber
import uy.kohesive.injekt.injectLazy
class MyAnimeListPresenter : BasePresenter<MyAnimeListFragment>() {
val db: DatabaseHelper by injectLazy()
val syncManager: MangaSyncManager by injectLazy()
val myAnimeList by lazy { syncManager.myAnimeList }
lateinit var manga: Manga
private set
var mangaSync: MangaSync? = null
private set
private var query: String? = null
private val GET_MANGA_SYNC = 1
private val GET_SEARCH_RESULTS = 2
private val REFRESH = 3
private val PREFIX_MY = "my:"
override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState)
startableLatestCache(GET_MANGA_SYNC,
{ db.getMangaSync(manga, myAnimeList).asRxObservable()
.doOnNext { mangaSync = it }
.observeOn(AndroidSchedulers.mainThread()) },
{ view, mangaSync -> view.setMangaSync(mangaSync) })
startableLatestCache(GET_SEARCH_RESULTS,
{ getSearchResultsObservable() },
{ view, results -> view.setSearchResults(results) },
{ view, error -> view.setSearchResultsError(error) })
startableFirst(REFRESH,
{ getRefreshObservable() },
{ view, result -> view.onRefreshDone() },
{ view, error -> view.onRefreshError(error) })
manga = SharedData.get(MangaEvent::class.java)?.manga ?: return
start(GET_MANGA_SYNC)
}
fun getSearchResultsObservable(): Observable<List<MangaSync>> {
return query?.let { query ->
val observable: Observable<List<MangaSync>>
if (query.startsWith(PREFIX_MY)) {
val realQuery = query.substring(PREFIX_MY.length).toLowerCase().trim()
observable = myAnimeList.getList()
.flatMap { Observable.from(it) }
.filter { it.title.toLowerCase().contains(realQuery) }
.toList()
} else {
observable = myAnimeList.search(query)
}
observable.subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread())
} ?: Observable.error(Exception("Null query"))
}
fun getRefreshObservable(): Observable<PutResult> {
return mangaSync?.let { mangaSync ->
myAnimeList.getList()
.map { myList ->
myList.find { it.remote_id == mangaSync.remote_id }?.let {
mangaSync.copyPersonalFrom(it)
mangaSync.total_chapters = it.total_chapters
mangaSync
} ?: throw Exception("Could not find manga")
}
.flatMap { db.insertMangaSync(it).asRxObservable() }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
} ?: Observable.error(Exception("Not found"))
}
private fun updateRemote() {
mangaSync?.let { mangaSync ->
add(myAnimeList.update(mangaSync)
.subscribeOn(Schedulers.io())
.flatMap { db.insertMangaSync(mangaSync).asRxObservable() }
.observeOn(AndroidSchedulers.mainThread())
.subscribe({ next -> },
{ error ->
Timber.e(error)
// Restart on error to set old values
start(GET_MANGA_SYNC)
}))
}
}
fun searchManga(query: String) {
if (query.isNullOrEmpty() || query == this.query)
return
this.query = query
start(GET_SEARCH_RESULTS)
}
fun restartSearch() {
query = null
stop(GET_SEARCH_RESULTS)
}
fun registerManga(sync: MangaSync?) {
if (sync != null) {
sync.manga_id = manga.id!!
add(myAnimeList.bind(sync)
.flatMap { db.insertMangaSync(sync).asRxObservable() }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({ },
{ error -> context.toast(error.message) }))
} else {
db.deleteMangaSyncForManga(manga).executeAsBlocking()
}
}
fun getAllStatus(): List<String> {
return listOf(context.getString(R.string.reading),
context.getString(R.string.completed),
context.getString(R.string.on_hold),
context.getString(R.string.dropped),
context.getString(R.string.plan_to_read))
}
fun getIndexFromStatus(): Int {
return mangaSync?.let { mangaSync ->
if (mangaSync.status == 6) 4 else mangaSync.status - 1
} ?: 0
}
fun setStatus(index: Int) {
mangaSync?.status = if (index == 4) 6 else index + 1
updateRemote()
}
fun setScore(score: Int) {
mangaSync?.score = score.toFloat()
updateRemote()
}
fun setLastChapterRead(chapterNumber: Int) {
mangaSync?.last_chapter_read = chapterNumber
updateRemote()
}
fun refresh() {
if (mangaSync != null) {
start(REFRESH)
}
}
}

View File

@ -1,46 +0,0 @@
package eu.kanade.tachiyomi.ui.manga.myanimelist
import android.content.Context
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.MangaSync
import eu.kanade.tachiyomi.util.inflate
import kotlinx.android.synthetic.main.dialog_myanimelist_search_item.view.*
import java.util.*
class MyAnimeListSearchAdapter(context: Context) :
ArrayAdapter<MangaSync>(context, R.layout.dialog_myanimelist_search_item, ArrayList<MangaSync>()) {
override fun getView(position: Int, view: View?, parent: ViewGroup): View {
var v = view
// Get the data item for this position
val sync = getItem(position)
// Check if an existing view is being reused, otherwise inflate the view
val holder: SearchViewHolder // view lookup cache stored in tag
if (v == null) {
v = parent.inflate(R.layout.dialog_myanimelist_search_item)
holder = SearchViewHolder(v)
v.tag = holder
} else {
holder = v.tag as SearchViewHolder
}
holder.onSetValues(sync)
return v
}
fun setItems(syncs: List<MangaSync>) {
setNotifyOnChange(false)
clear()
addAll(syncs)
notifyDataSetChanged()
}
class SearchViewHolder(private val view: View) {
fun onSetValues(sync: MangaSync) {
view.myanimelist_result_title.text = sync.title
}
}
}

View File

@ -0,0 +1,33 @@
package eu.kanade.tachiyomi.ui.manga.track
import android.support.v7.widget.RecyclerView
import android.view.ViewGroup
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.util.inflate
class TrackAdapter(val fragment: TrackFragment) : RecyclerView.Adapter<TrackHolder>() {
var items = emptyList<TrackItem>()
set(value) {
if (field !== value) {
field = value
notifyDataSetChanged()
}
}
var onClickListener: (TrackItem) -> Unit = {}
override fun getItemCount(): Int {
return items.size
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TrackHolder {
val view = parent.inflate(R.layout.item_track)
return TrackHolder(view, fragment)
}
override fun onBindViewHolder(holder: TrackHolder, position: Int) {
holder.onSetValues(items[position])
}
}

View File

@ -0,0 +1,173 @@
package eu.kanade.tachiyomi.ui.manga.track
import android.os.Bundle
import android.support.v7.widget.LinearLayoutManager
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.NumberPicker
import com.afollestad.materialdialogs.MaterialDialog
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.ui.base.fragment.BaseRxFragment
import eu.kanade.tachiyomi.ui.manga.MangaActivity
import eu.kanade.tachiyomi.util.toast
import kotlinx.android.synthetic.main.fragment_track.*
import nucleus.factory.RequiresPresenter
@RequiresPresenter(TrackPresenter::class)
class TrackFragment : BaseRxFragment<TrackPresenter>() {
companion object {
fun newInstance(): TrackFragment {
return TrackFragment()
}
}
private lateinit var adapter: TrackAdapter
private var dialog: TrackSearchDialog? = null
private val searchFragmentTag: String
get() = "search_fragment"
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedState: Bundle?): View {
return inflater.inflate(R.layout.fragment_track, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
adapter = TrackAdapter(this)
recycler.layoutManager = LinearLayoutManager(context)
recycler.adapter = adapter
swipe_refresh.isEnabled = false
swipe_refresh.setOnRefreshListener { presenter.refresh() }
}
private fun findSearchFragmentIfNeeded() {
if (dialog == null) {
dialog = childFragmentManager.findFragmentByTag(searchFragmentTag) as TrackSearchDialog
}
}
fun onNextTrackings(trackings: List<TrackItem>) {
adapter.items = trackings
swipe_refresh.isEnabled = trackings.any { it.track != null }
(activity as MangaActivity).setTrackingIcon(trackings.any { it.track != null })
}
fun onSearchResults(results: List<Track>) {
if (!isResumed) return
findSearchFragmentIfNeeded()
dialog?.onSearchResults(results)
}
fun onSearchResultsError(error: Throwable) {
if (!isResumed) return
findSearchFragmentIfNeeded()
dialog?.onSearchResultsError()
}
fun onRefreshDone() {
swipe_refresh.isRefreshing = false
}
fun onRefreshError(error: Throwable) {
swipe_refresh.isRefreshing = false
context.toast(error.message)
}
fun onTitleClick(item: TrackItem) {
if (!isResumed) return
if (dialog == null) {
dialog = TrackSearchDialog.newInstance()
}
presenter.selectedService = item.service
dialog?.show(childFragmentManager, searchFragmentTag)
}
fun onStatusClick(item: TrackItem) {
if (!isResumed || item.track == null) return
val statusList = item.service.getStatusList().map { item.service.getStatus(it) }
val selectedIndex = item.service.getStatusList().indexOf(item.track.status)
MaterialDialog.Builder(context)
.title(R.string.status)
.items(statusList)
.itemsCallbackSingleChoice(selectedIndex, { dialog, view, i, charSequence ->
presenter.setStatus(item, i)
swipe_refresh.isRefreshing = true
true
})
.show()
}
fun onChaptersClick(item: TrackItem) {
if (!isResumed || item.track == null) return
val dialog = MaterialDialog.Builder(context)
.title(R.string.chapters)
.customView(R.layout.dialog_track_chapters, false)
.positiveText(android.R.string.ok)
.negativeText(android.R.string.cancel)
.onPositive { d, action ->
val view = d.customView
if (view != null) {
val np = view.findViewById(R.id.chapters_picker) as NumberPicker
np.clearFocus()
presenter.setLastChapterRead(item, np.value)
swipe_refresh.isRefreshing = true
}
}
.show()
val view = dialog.customView
if (view != null) {
val np = view.findViewById(R.id.chapters_picker) as NumberPicker
// Set initial value
np.value = item.track.last_chapter_read
// Don't allow to go from 0 to 9999
np.wrapSelectorWheel = false
}
}
fun onScoreClick(item: TrackItem) {
if (!isResumed || item.track == null) return
val dialog = MaterialDialog.Builder(activity)
.title(R.string.score)
.customView(R.layout.dialog_track_score, false)
.positiveText(android.R.string.ok)
.negativeText(android.R.string.cancel)
.onPositive { d, action ->
val view = d.customView
if (view != null) {
val np = view.findViewById(R.id.score_picker) as NumberPicker
np.clearFocus()
presenter.setScore(item, np.value)
swipe_refresh.isRefreshing = true
}
}
.show()
val view = dialog.customView
if (view != null) {
val np = view.findViewById(R.id.score_picker) as NumberPicker
val scores = item.service.getScoreList().toTypedArray()
np.maxValue = scores.size - 1
np.displayedValues = scores
// Set initial value
val displayedScore = item.service.displayScore(item.track)
if (displayedScore != "-") {
val index = scores.indexOf(displayedScore)
np.value = if (index != -1) index else 0
}
}
}
}

View File

@ -0,0 +1,42 @@
package eu.kanade.tachiyomi.ui.manga.track
import android.support.v7.widget.RecyclerView
import android.view.View
import eu.kanade.tachiyomi.R
import kotlinx.android.synthetic.main.item_track.view.*
class TrackHolder(private val view: View, private val fragment: TrackFragment)
: RecyclerView.ViewHolder(view) {
private lateinit var item: TrackItem
init {
view.title_container.setOnClickListener { fragment.onTitleClick(item) }
view.status_container.setOnClickListener { fragment.onStatusClick(item) }
view.chapters_container.setOnClickListener { fragment.onChaptersClick(item) }
view.score_container.setOnClickListener { fragment.onScoreClick(item) }
}
@Suppress("DEPRECATION")
fun onSetValues(item: TrackItem) = with(view) {
this@TrackHolder.item = item
val track = item.track
track_logo.setImageResource(item.service.getLogo())
logo.setBackgroundColor(item.service.getLogoColor())
if (track != null) {
track_title.setTextAppearance(context, R.style.TextAppearance_Regular_Body1_Secondary)
track_title.setAllCaps(false)
track_title.text = track.title
track_chapters.text = "${track.last_chapter_read}/" +
if (track.total_chapters > 0) track.total_chapters else "-"
track_status.text = item.service.getStatus(track.status)
track_score.text = if (track.score == 0f) "-" else item.service.displayScore(track)
} else {
track_title.setTextAppearance(context, R.style.TextAppearance_Medium_Button)
track_title.setText(R.string.action_edit)
track_chapters.text = ""
track_score.text = ""
track_status.text = ""
}
}
}

View File

@ -0,0 +1,8 @@
package eu.kanade.tachiyomi.ui.manga.track
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.TrackService
class TrackItem(val track: Track?, val service: TrackService) {
}

View File

@ -0,0 +1,137 @@
package eu.kanade.tachiyomi.ui.manga.track
import android.os.Bundle
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import eu.kanade.tachiyomi.ui.manga.MangaEvent
import eu.kanade.tachiyomi.util.SharedData
import eu.kanade.tachiyomi.util.toast
import rx.Observable
import rx.Subscription
import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers
import uy.kohesive.injekt.injectLazy
class TrackPresenter : BasePresenter<TrackFragment>() {
private val db: DatabaseHelper by injectLazy()
private val trackManager: TrackManager by injectLazy()
lateinit var manga: Manga
private set
private var trackList: List<TrackItem> = emptyList()
private val loggedServices by lazy { trackManager.services.filter { it.isLogged } }
var selectedService: TrackService? = null
private var trackSubscription: Subscription? = null
private var searchSubscription: Subscription? = null
private var refreshSubscription: Subscription? = null
override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState)
manga = SharedData.get(MangaEvent::class.java)?.manga ?: return
fetchTrackings()
}
fun fetchTrackings() {
trackSubscription?.let { remove(it) }
trackSubscription = db.getTracks(manga)
.asRxObservable()
.map { tracks ->
loggedServices.map { service ->
TrackItem(tracks.find { it.sync_id == service.id }, service)
}
}
.observeOn(AndroidSchedulers.mainThread())
.doOnNext { trackList = it }
.subscribeLatestCache(TrackFragment::onNextTrackings)
}
fun refresh() {
refreshSubscription?.let { remove(it) }
refreshSubscription = Observable.from(trackList)
.filter { it.track != null }
.concatMap { item ->
item.service.refresh(item.track!!)
.flatMap { db.insertTrack(it).asRxObservable() }
.map { item }
.onErrorReturn { item }
}
.toList()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeFirst({ view, result -> view.onRefreshDone() },
TrackFragment::onRefreshError)
}
fun search(query: String) {
val service = selectedService ?: return
searchSubscription?.let { remove(it) }
searchSubscription = service.search(query)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeLatestCache(TrackFragment::onSearchResults,
TrackFragment::onSearchResultsError)
}
fun registerTracking(item: Track?) {
val service = selectedService ?: return
if (item != null) {
item.manga_id = manga.id!!
add(service.bind(item)
.flatMap { db.insertTrack(item).asRxObservable() }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({ },
{ error -> context.toast(error.message) }))
} else {
db.deleteTrackForManga(manga, service).executeAsBlocking()
}
}
private fun updateRemote(track: Track, service: TrackService) {
service.update(track)
.flatMap { db.insertTrack(track).asRxObservable() }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeFirst({ view, result -> view.onRefreshDone() },
{ view, error ->
view.onRefreshError(error)
// Restart on error to set old values
fetchTrackings()
})
}
fun setStatus(item: TrackItem, index: Int) {
val track = item.track!!
track.status = item.service.getStatusList()[index]
updateRemote(track, item.service)
}
fun setScore(item: TrackItem, index: Int) {
val track = item.track!!
track.score = item.service.indexToScore(index)
updateRemote(track, item.service)
}
fun setLastChapterRead(item: TrackItem, chapterNumber: Int) {
val track = item.track!!
track.last_chapter_read = chapterNumber
updateRemote(track, item.service)
}
}

View File

@ -0,0 +1,47 @@
package eu.kanade.tachiyomi.ui.manga.track
import android.content.Context
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.util.inflate
import kotlinx.android.synthetic.main.item_track_search.view.*
import java.util.*
class TrackSearchAdapter(context: Context)
: ArrayAdapter<Track>(context, R.layout.item_track_search, ArrayList<Track>()) {
override fun getView(position: Int, view: View?, parent: ViewGroup): View {
var v = view
// Get the data item for this position
val track = getItem(position)
// Check if an existing view is being reused, otherwise inflate the view
val holder: TrackSearchHolder // view lookup cache stored in tag
if (v == null) {
v = parent.inflate(R.layout.item_track_search)
holder = TrackSearchHolder(v)
v.tag = holder
} else {
holder = v.tag as TrackSearchHolder
}
holder.onSetValues(track)
return v
}
fun setItems(syncs: List<Track>) {
setNotifyOnChange(false)
clear()
addAll(syncs)
notifyDataSetChanged()
}
class TrackSearchHolder(private val view: View) {
fun onSetValues(track: Track) {
view.track_search_title.text = track.title
}
}
}

View File

@ -0,0 +1,119 @@
package eu.kanade.tachiyomi.ui.manga.track
import android.app.Dialog
import android.os.Bundle
import android.support.v4.app.DialogFragment
import android.view.View
import com.afollestad.materialdialogs.MaterialDialog
import com.jakewharton.rxrelay.PublishRelay
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.widget.SimpleTextWatcher
import kotlinx.android.synthetic.main.dialog_track_search.view.*
import rx.Subscription
import rx.android.schedulers.AndroidSchedulers
import java.util.concurrent.TimeUnit
class TrackSearchDialog : DialogFragment() {
companion object {
fun newInstance(): TrackSearchDialog {
return TrackSearchDialog()
}
}
private lateinit var v: View
lateinit var adapter: TrackSearchAdapter
private set
private val queryRelay by lazy { PublishRelay.create<String>() }
private var searchDebounceSubscription: Subscription? = null
private var selectedItem: Track? = null
val presenter: TrackPresenter
get() = (parentFragment as TrackFragment).presenter
override fun onCreateDialog(savedState: Bundle?): Dialog {
val dialog = MaterialDialog.Builder(context)
.customView(R.layout.dialog_track_search, false)
.positiveText(android.R.string.ok)
.negativeText(android.R.string.cancel)
.onPositive { dialog1, which -> onPositiveButtonClick() }
.build()
onViewCreated(dialog.view, savedState)
return dialog
}
override fun onViewCreated(view: View, savedState: Bundle?) {
v = view
// Create adapter
adapter = TrackSearchAdapter(context)
view.track_search_list.adapter = adapter
// Set listeners
selectedItem = null
view.track_search_list.setOnItemClickListener { parent, viewList, position, id ->
selectedItem = adapter.getItem(position)
}
// Do an initial search based on the manga's title
if (savedState == null) {
val title = presenter.manga.title
view.track_search.append(title)
search(title)
}
view.track_search.addTextChangedListener(object : SimpleTextWatcher() {
override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {
queryRelay.call(s.toString())
}
})
}
override fun onResume() {
super.onResume()
// Listen to text changes
searchDebounceSubscription = queryRelay.debounce(1, TimeUnit.SECONDS)
.observeOn(AndroidSchedulers.mainThread())
.filter { it.isNotBlank() }
.subscribe { search(it) }
}
override fun onPause() {
searchDebounceSubscription?.unsubscribe()
super.onPause()
}
private fun search(query: String) {
v.progress.visibility = View.VISIBLE
v.track_search_list.visibility = View.GONE
presenter.search(query)
}
fun onSearchResults(results: List<Track>) {
selectedItem = null
v.progress.visibility = View.GONE
v.track_search_list.visibility = View.VISIBLE
adapter.setItems(results)
}
fun onSearchResultsError() {
v.progress.visibility = View.VISIBLE
v.track_search_list.visibility = View.GONE
adapter.setItems(emptyList())
}
private fun onPositiveButtonClick() {
presenter.registerTracking(selectedItem)
}
}

View File

@ -163,19 +163,19 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
}
override fun onBackPressed() {
val chapterToUpdate = presenter.getMangaSyncChapterToUpdate()
val chapterToUpdate = presenter.getTrackChapterToUpdate()
if (chapterToUpdate > 0) {
if (preferences.askUpdateMangaSync()) {
if (preferences.askUpdateTrack()) {
MaterialDialog.Builder(this)
.content(getString(R.string.confirm_update_manga_sync, chapterToUpdate))
.positiveText(android.R.string.yes)
.negativeText(android.R.string.no)
.onPositive { dialog, which -> presenter.updateMangaSyncLastChapterRead() }
.onPositive { dialog, which -> presenter.updateTrackLastChapterRead() }
.onAny { dialog1, which1 -> super.onBackPressed() }
.show()
} else {
presenter.updateMangaSyncLastChapterRead()
presenter.updateTrackLastChapterRead()
super.onBackPressed()
}
} else {
@ -189,7 +189,7 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
KeyEvent.KEYCODE_VOLUME_DOWN -> {
if (volumeKeysEnabled) {
if (event.action == KeyEvent.ACTION_UP) {
viewer?.moveToNext()
viewer?.moveDown()
}
return true
}
@ -197,7 +197,7 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
KeyEvent.KEYCODE_VOLUME_UP -> {
if (volumeKeysEnabled) {
if (event.action == KeyEvent.ACTION_UP) {
viewer?.moveToPrevious()
viewer?.moveUp()
}
return true
}
@ -210,12 +210,15 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean {
if (!isFinishing) {
when (keyCode) {
KeyEvent.KEYCODE_DPAD_RIGHT -> viewer?.moveToNext()
KeyEvent.KEYCODE_DPAD_LEFT -> viewer?.moveToPrevious()
KeyEvent.KEYCODE_DPAD_RIGHT -> viewer?.moveRight()
KeyEvent.KEYCODE_DPAD_LEFT -> viewer?.moveLeft()
KeyEvent.KEYCODE_DPAD_DOWN -> viewer?.moveDown()
KeyEvent.KEYCODE_DPAD_UP -> viewer?.moveUp()
KeyEvent.KEYCODE_MENU -> toggleMenu()
else -> return super.onKeyUp(keyCode, event)
}
}
return super.onKeyUp(keyCode, event)
return true
}
fun onChapterError(error: Throwable) {
@ -224,14 +227,14 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
toast(error.message)
}
fun onLongPress(page: Page) {
fun onLongClick(page: Page) {
MaterialDialog.Builder(this)
.title(getString(R.string.options))
.items(R.array.reader_image_options)
.itemsIds(R.array.reader_image_options_values)
.itemsCallback { materialDialog, view, i, charSequence ->
when (i) {
0 -> presenter.setCover(page)
0 -> setImageAsCover(page)
1 -> shareImage(page)
2 -> presenter.savePage(page)
}
@ -313,14 +316,14 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
val mangaViewer = if (manga.viewer == 0) preferences.defaultViewer() else manga.viewer
// Try to reuse the viewer using its tag
var fragment: BaseReader? = supportFragmentManager.findFragmentByTag(manga.viewer.toString()) as? BaseReader
var fragment = supportFragmentManager.findFragmentByTag(manga.viewer.toString()) as? BaseReader
if (fragment == null) {
// Create a new viewer
when (mangaViewer) {
RIGHT_TO_LEFT -> fragment = RightToLeftReader()
VERTICAL -> fragment = VerticalReader()
WEBTOON -> fragment = WebtoonReader()
else -> fragment = LeftToRightReader()
fragment = when (mangaViewer) {
RIGHT_TO_LEFT -> RightToLeftReader()
VERTICAL -> VerticalReader()
WEBTOON -> WebtoonReader()
else -> LeftToRightReader()
}
supportFragmentManager.beginTransaction().replace(R.id.reader, fragment, manga.viewer.toString()).commit()
@ -469,23 +472,6 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
}
}
/**
* Start a share intent that lets user share image
*
* @param page page object containing image information.
*/
fun shareImage(page: Page) {
if (page.status != Page.READY)
return
val intent = Intent(Intent.ACTION_SEND).apply {
putExtra(Intent.EXTRA_STREAM, page.uri)
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION
type = "image/*"
}
startActivity(Intent.createChooser(intent, getString(R.string.action_share)))
}
/**
* Sets the brightness of the screen. Range is [-75, 100].
* From -75 to -1 a semi-transparent black view is shown at the top with the minimum brightness.
@ -570,4 +556,39 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
}
}
/**
* Start a share intent that lets user share image
*
* @param page page object containing image information.
*/
private fun shareImage(page: Page) {
if (page.status != Page.READY)
return
val intent = Intent(Intent.ACTION_SEND).apply {
putExtra(Intent.EXTRA_STREAM, page.uri)
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION
type = "image/*"
}
startActivity(Intent.createChooser(intent, getString(R.string.action_share)))
}
/**
* Sets the given page as the cover of the manga.
*
* @param page the page containing the image to set as cover.
*/
private fun setImageAsCover(page: Page) {
if (page.status != Page.READY)
return
MaterialDialog.Builder(this)
.content(getString(R.string.confirm_set_image_as_cover))
.positiveText(android.R.string.yes)
.negativeText(android.R.string.no)
.onPositive { dialog, which -> presenter.setImageAsCover(page) }
.show()
}
}

View File

@ -10,14 +10,14 @@ import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.History
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.database.models.MangaSync
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.mangasync.MangaSyncManager
import eu.kanade.tachiyomi.data.mangasync.UpdateMangaSyncService
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.source.SourceManager
import eu.kanade.tachiyomi.data.source.model.Page
import eu.kanade.tachiyomi.data.source.online.OnlineSource
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.track.TrackUpdateService
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import eu.kanade.tachiyomi.ui.reader.notification.ImageNotifier
import eu.kanade.tachiyomi.util.DiskUtil
@ -54,9 +54,9 @@ class ReaderPresenter : BasePresenter<ReaderActivity>() {
val downloadManager: DownloadManager by injectLazy()
/**
* Sync manager.
* Tracking manager.
*/
val syncManager: MangaSyncManager by injectLazy()
val trackManager: TrackManager by injectLazy()
/**
* Source manager.
@ -124,7 +124,7 @@ class ReaderPresenter : BasePresenter<ReaderActivity>() {
/**
* List of manga services linked to the active manga, or null if auto syncing is not enabled.
*/
private var mangaSyncList: List<MangaSync>? = null
private var trackList: List<Track>? = null
/**
* Chapter loader whose job is to obtain the chapter list and initialize every page.
@ -141,6 +141,11 @@ class ReaderPresenter : BasePresenter<ReaderActivity>() {
*/
private var adjacentChaptersSubscription: Subscription? = null
/**
* Whether the active chapter has been loaded.
*/
private var chapterLoaded = false
companion object {
/**
* Id of the restartable that loads the active chapter.
@ -165,9 +170,9 @@ class ReaderPresenter : BasePresenter<ReaderActivity>() {
.subscribeLatestCache({ view, manga -> view.onMangaOpen(manga) })
// Retrieve the sync list if auto syncing is enabled.
if (prefs.autoUpdateMangaSync()) {
add(db.getMangasSync(manga).asRxSingle()
.subscribe({ mangaSyncList = it }))
if (prefs.autoUpdateTrack()) {
add(db.getTracks(manga).asRxSingle()
.subscribe({ trackList = it }))
}
restartableLatestCache(LOAD_ACTIVE_CHAPTER,
@ -211,6 +216,7 @@ class ReaderPresenter : BasePresenter<ReaderActivity>() {
return loader.loadChapter(chapter)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.doOnNext { chapterLoaded = true }
}
/**
@ -298,6 +304,7 @@ class ReaderPresenter : BasePresenter<ReaderActivity>() {
nextChapter = null
prevChapter = null
chapterLoaded = false
start(LOAD_ACTIVE_CHAPTER)
getAdjacentChapters(chapter)
}
@ -431,9 +438,9 @@ class ReaderPresenter : BasePresenter<ReaderActivity>() {
/**
* Returns the chapter to be marked as last read in sync services or 0 if no update required.
*/
fun getMangaSyncChapterToUpdate(): Int {
val mangaSyncList = mangaSyncList
if (chapter.pages == null || mangaSyncList == null || mangaSyncList.isEmpty())
fun getTrackChapterToUpdate(): Int {
val trackList = trackList
if (chapter.pages == null || trackList == null || trackList.isEmpty())
return 0
val prevChapter = prevChapter
@ -446,24 +453,24 @@ class ReaderPresenter : BasePresenter<ReaderActivity>() {
else
0
mangaSyncList.forEach { sync ->
trackList.forEach { sync ->
if (lastChapterRead > sync.last_chapter_read) {
sync.last_chapter_read = lastChapterRead
sync.update = true
}
}
return if (mangaSyncList.any { it.update }) lastChapterRead else 0
return if (trackList.any { it.update }) lastChapterRead else 0
}
/**
* Starts the service that updates the last chapter read in sync services
*/
fun updateMangaSyncLastChapterRead() {
mangaSyncList?.forEach { sync ->
val service = syncManager.getService(sync.sync_id)
fun updateTrackLastChapterRead() {
trackList?.forEach { sync ->
val service = trackManager.getService(sync.sync_id)
if (service != null && service.isLogged && sync.update) {
UpdateMangaSyncService.start(context, sync)
TrackUpdateService.start(context, sync)
}
}
}
@ -474,6 +481,9 @@ class ReaderPresenter : BasePresenter<ReaderActivity>() {
* @return true if the next chapter is being loaded, false if there is no next chapter.
*/
fun loadNextChapter(): Boolean {
// Avoid skipping chapters.
if (!chapterLoaded) return true
nextChapter?.let {
onChapterLeft()
loadChapter(it, 0)
@ -488,6 +498,9 @@ class ReaderPresenter : BasePresenter<ReaderActivity>() {
* @return true if the previous chapter is being loaded, false if there is no previous chapter.
*/
fun loadPreviousChapter(): Boolean {
// Avoid skipping chapters.
if (!chapterLoaded) return true
prevChapter?.let {
onChapterLeft()
loadChapter(it, if (it.read) -1 else 0)
@ -523,19 +536,13 @@ class ReaderPresenter : BasePresenter<ReaderActivity>() {
/**
* Update cover with page file.
*/
internal fun setCover(page: Page) {
if (page.status != Page.READY)
return
internal fun setImageAsCover(page: Page) {
try {
val thumbUrl = manga.thumbnail_url ?: throw Exception("Image url not found")
if (manga.favorite) {
if (manga.thumbnail_url != null) {
val input = context.contentResolver.openInputStream(page.uri)
coverCache.copyToCache(manga.thumbnail_url!!, input)
context.toast(R.string.cover_updated)
} else {
throw Exception("Image url not found")
}
val input = context.contentResolver.openInputStream(page.uri)
coverCache.copyToCache(thumbUrl, input)
context.toast(R.string.cover_updated)
} else {
context.toast(R.string.notification_first_add_to_library)
}

View File

@ -4,12 +4,11 @@ import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.support.v4.content.FileProvider
import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.util.notificationManager
import java.io.File
import eu.kanade.tachiyomi.Constants.NOTIFICATION_DOWNLOAD_IMAGE_ID as defaultNotification
/**
* The BroadcastReceiver of [ImageNotifier]
@ -18,21 +17,16 @@ import java.io.File
class ImageNotificationReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
ACTION_SHARE_IMAGE -> {
shareImage(context, intent.getStringExtra(EXTRA_FILE_LOCATION))
context.notificationManager.cancel(intent.getIntExtra(NOTIFICATION_ID, 5))
}
ACTION_SHOW_IMAGE ->
showImage(context, intent.getStringExtra(EXTRA_FILE_LOCATION))
ACTION_DELETE_IMAGE -> {
deleteImage(intent.getStringExtra(EXTRA_FILE_LOCATION))
context.notificationManager.cancel(intent.getIntExtra(NOTIFICATION_ID, 5))
context.notificationManager.cancel(intent.getIntExtra(NOTIFICATION_ID, defaultNotification))
}
}
}
/**
* Called to delete image
*
* @param path path of file
*/
private fun deleteImage(path: String) {
@ -40,60 +34,42 @@ class ImageNotificationReceiver : BroadcastReceiver() {
if (file.exists()) file.delete()
}
/**
* Called to start share intent to share image
* @param context context of application
* @param path path of file
*/
private fun shareImage(context: Context, path: String) {
val intent = Intent(Intent.ACTION_SEND).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_MULTIPLE_TASK
putExtra(Intent.EXTRA_STREAM, Uri.parse(path))
type = "image/*"
}
context.startActivity(Intent.createChooser(intent, context.getString(R.string.action_share)))
}
/**
* Called to show image in gallery application
* @param context context of application
* @param path path of file
*/
private fun showImage(context: Context, path: String) {
val intent = Intent(Intent.ACTION_VIEW).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_MULTIPLE_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION
val uri = FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".provider", File(path))
setDataAndType(uri, "image/*")
}
context.startActivity(intent)
}
companion object {
private const val ACTION_SHARE_IMAGE = "eu.kanade.SHARE_IMAGE"
private const val ACTION_SHOW_IMAGE = "eu.kanade.SHOW_IMAGE"
private const val ACTION_DELETE_IMAGE = "eu.kanade.DELETE_IMAGE"
private const val EXTRA_FILE_LOCATION = "file_location"
private const val NOTIFICATION_ID = "notification_id"
internal fun shareImageIntent(context: Context, path: String, notificationId: Int): PendingIntent {
val intent = Intent(context, ImageNotificationReceiver::class.java).apply {
action = ACTION_SHARE_IMAGE
putExtra(EXTRA_FILE_LOCATION, path)
putExtra(NOTIFICATION_ID, notificationId)
/**
* Called to start share intent to share image
*
* @param context context of application
* @param file file that contains image
*/
internal fun shareImageIntent(context: Context, file: File): PendingIntent {
val intent = Intent(Intent.ACTION_SEND).apply {
val uri = FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".provider", file)
putExtra(Intent.EXTRA_STREAM, uri)
type = "image/*"
flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
}
return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
return PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
}
internal fun showImageIntent(context: Context, path: String): PendingIntent {
val intent = Intent(context, ImageNotificationReceiver::class.java).apply {
action = ACTION_SHOW_IMAGE
putExtra(EXTRA_FILE_LOCATION, path)
/**
* Called to show image in gallery application
*
* @param context context of application
* @param file file that contains image
*/
internal fun showImageIntent(context: Context, file: File): PendingIntent {
val intent = Intent(Intent.ACTION_VIEW).apply {
val uri = FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".provider", file)
setDataAndType(uri, "image/*")
flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
}
return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
return PendingIntent.getActivity(context, 0, intent, 0)
}
internal fun deleteImageIntent(context: Context, path: String, notificationId: Int): PendingIntent {

View File

@ -58,11 +58,11 @@ class ImageNotifier(private val context: Context) {
if (!mActions.isEmpty())
mActions.clear()
setContentIntent(ImageNotificationReceiver.showImageIntent(context, file.absolutePath))
setContentIntent(ImageNotificationReceiver.showImageIntent(context, file))
// Share action
addAction(R.drawable.ic_share_grey_24dp,
context.getString(R.string.action_share),
ImageNotificationReceiver.shareImageIntent(context, file.absolutePath, notificationId))
context.getString(R.string.action_share),
ImageNotificationReceiver.shareImageIntent(context, file))
// Delete action
addAction(R.drawable.ic_delete_grey_24dp,
context.getString(R.string.action_delete),

View File

@ -189,14 +189,38 @@ abstract class BaseReader : BaseFragment() {
abstract fun onChapterAppended(chapter: ReaderChapter)
/**
* Moves pages forward. Implementations decide how to move (by a page, by some distance...).
* Moves pages to right. Implementations decide how to move (by a page, by some distance...).
*/
abstract fun moveToNext()
abstract fun moveRight()
/**
* Moves pages backward. Implementations decide how to move (by a page, by some distance...).
* Moves pages to left. Implementations decide how to move (by a page, by some distance...).
*/
abstract fun moveToPrevious()
abstract fun moveLeft()
/**
* Moves pages down. Implementations decide how to move (by a page, by some distance...).
*/
open fun moveDown() {
moveRight()
}
/**
* Moves pages up. Implementations decide how to move (by a page, by some distance...).
*/
open fun moveUp() {
moveLeft()
}
/**
* Method the implementations can call to show a menu with options for the given page.
*/
fun onLongClick(page: Page?): Boolean {
if (isAdded && page != null) {
readerActivity.onLongClick(page)
}
return true
}
/**
* Sets the active decoder class.

View File

@ -71,6 +71,7 @@ class PageView @JvmOverloads constructor(context: Context, attrs: AttributeSet?
setBitmapDecoderClass(reader.bitmapDecoderClass)
setVerticalScrollingParent(reader is VerticalReader)
setOnTouchListener { v, motionEvent -> reader.gestureDetector.onTouchEvent(motionEvent) }
setOnLongClickListener { reader.onLongClick(page) }
setOnImageEventListener(object : SubsamplingScaleImageView.DefaultOnImageEventListener() {
override fun onReady() {
onImageDecoded(reader)

View File

@ -66,7 +66,7 @@ abstract class PagerReader : BaseReader() {
/**
* Gesture detector for touch events.
*/
val gestureDetector by lazy { createGestureDetector() }
val gestureDetector by lazy { GestureDetector(context, ImageGestureListener()) }
/**
* Subscriptions for reader settings.
@ -166,37 +166,24 @@ abstract class PagerReader : BaseReader() {
}
/**
* Creates the gesture detector for the pager.
*
* @return a gesture detector.
* Gesture detector for Subsampling Scale Image View.
*/
protected fun createGestureDetector(): GestureDetector {
return GestureDetector(activity, object : GestureDetector.SimpleOnGestureListener() {
override fun onSingleTapConfirmed(e: MotionEvent): Boolean {
if (isAdded) {
val positionX = e.x
inner class ImageGestureListener : GestureDetector.SimpleOnGestureListener() {
if (positionX < pager.width * LEFT_REGION) {
if (tappingEnabled) onLeftSideTap()
} else if (positionX > pager.width * RIGHT_REGION) {
if (tappingEnabled) onRightSideTap()
} else {
readerActivity.toggleMenu()
}
}
return true
}
override fun onSingleTapConfirmed(e: MotionEvent): Boolean {
if (isAdded) {
val positionX = e.x
override fun onLongPress(e: MotionEvent?) {
if (isAdded) {
val page = adapter.pages.getOrNull(pager.currentItem)
if (page != null)
readerActivity.onLongPress(page)
else
context.toast(getString(R.string.unknown_error))
if (positionX < pager.width * LEFT_REGION) {
if (tappingEnabled) moveLeft()
} else if (positionX > pager.width * RIGHT_REGION) {
if (tappingEnabled) moveRight()
} else {
readerActivity.toggleMenu()
}
}
})
return true
}
}
/**
@ -258,23 +245,23 @@ abstract class PagerReader : BaseReader() {
}
/**
* Called when the left side of the screen was clicked.
* Moves a page to the right.
*/
protected open fun onLeftSideTap() {
moveToPrevious()
override fun moveRight() {
moveToNext()
}
/**
* Called when the right side of the screen was clicked.
* Moves a page to the left.
*/
protected open fun onRightSideTap() {
moveToNext()
override fun moveLeft() {
moveToPrevious()
}
/**
* Moves to the next page or requests the next chapter if it's the last one.
*/
override fun moveToNext() {
protected fun moveToNext() {
if (pager.currentItem != pager.adapter.count - 1) {
pager.setCurrentItem(pager.currentItem + 1, transitions)
} else {
@ -285,7 +272,7 @@ abstract class PagerReader : BaseReader() {
/**
* Moves to the previous page or requests the previous chapter if it's the first one.
*/
override fun moveToPrevious() {
protected fun moveToPrevious() {
if (pager.currentItem != 0) {
pager.setCurrentItem(pager.currentItem - 1, transitions)
} else {

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