소스 검색

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?
inorichi 8 년 전
부모
커밋
94ee4e7fb5
76개의 변경된 파일2310개의 추가작업 그리고 1654개의 파일을 삭제
  1. 11 1
      .travis.yml
  2. 2 0
      app/build.gradle
  3. 13 1
      app/src/main/AndroidManifest.xml
  4. 1 1
      app/src/main/java/eu/kanade/tachiyomi/App.kt
  5. 2 2
      app/src/main/java/eu/kanade/tachiyomi/AppModule.kt
  6. 22 22
      app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupManager.kt
  7. 2 2
      app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/IdExclusion.kt
  8. 2 2
      app/src/main/java/eu/kanade/tachiyomi/data/database/DatabaseHelper.kt
  9. 1 1
      app/src/main/java/eu/kanade/tachiyomi/data/database/DbOpenHelper.kt
  10. 24 24
      app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/TrackTypeMapping.kt
  11. 3 3
      app/src/main/java/eu/kanade/tachiyomi/data/database/models/Track.kt
  12. 5 5
      app/src/main/java/eu/kanade/tachiyomi/data/database/models/TrackImpl.kt
  13. 0 46
      app/src/main/java/eu/kanade/tachiyomi/data/database/queries/MangaSyncQueries.kt
  14. 34 0
      app/src/main/java/eu/kanade/tachiyomi/data/database/queries/TrackQueries.kt
  15. 1 1
      app/src/main/java/eu/kanade/tachiyomi/data/database/tables/TrackTable.kt
  16. 0 23
      app/src/main/java/eu/kanade/tachiyomi/data/mangasync/MangaSyncManager.kt
  17. 0 51
      app/src/main/java/eu/kanade/tachiyomi/data/mangasync/MangaSyncService.kt
  18. 0 132
      app/src/main/java/eu/kanade/tachiyomi/data/mangasync/anilist/Anilist.kt
  19. 6 4
      app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt
  20. 12 8
      app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt
  21. 0 1
      app/src/main/java/eu/kanade/tachiyomi/data/source/online/english/Mangahere.kt
  22. 28 0
      app/src/main/java/eu/kanade/tachiyomi/data/track/TrackManager.kt
  23. 67 0
      app/src/main/java/eu/kanade/tachiyomi/data/track/TrackService.kt
  24. 15 15
      app/src/main/java/eu/kanade/tachiyomi/data/track/TrackUpdateService.kt
  25. 191 0
      app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/Anilist.kt
  26. 87 88
      app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistApi.kt
  27. 60 60
      app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistInterceptor.kt
  28. 16 16
      app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/model/ALManga.kt
  29. 5 5
      app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/model/ALUserLists.kt
  30. 28 28
      app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/model/ALUserManga.kt
  31. 10 10
      app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/model/OAuth.kt
  32. 219 0
      app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/Kitsu.kt
  33. 93 0
      app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuApi.kt
  34. 46 0
      app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuInterceptor.kt
  35. 44 0
      app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuModels.kt
  36. 11 0
      app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/OAuth.kt
  37. 263 222
      app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeList.kt
  38. 35 14
      app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaActivity.kt
  39. 3 3
      app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaPresenter.kt
  40. 0 124
      app/src/main/java/eu/kanade/tachiyomi/ui/manga/myanimelist/MyAnimeListDialogFragment.kt
  41. 0 177
      app/src/main/java/eu/kanade/tachiyomi/ui/manga/myanimelist/MyAnimeListFragment.kt
  42. 0 174
      app/src/main/java/eu/kanade/tachiyomi/ui/manga/myanimelist/MyAnimeListPresenter.kt
  43. 0 46
      app/src/main/java/eu/kanade/tachiyomi/ui/manga/myanimelist/MyAnimeListSearchAdapter.kt
  44. 33 0
      app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackAdapter.kt
  45. 166 0
      app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackFragment.kt
  46. 42 0
      app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackHolder.kt
  47. 8 0
      app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackItem.kt
  48. 137 0
      app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackPresenter.kt
  49. 47 0
      app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSearchAdapter.kt
  50. 119 0
      app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSearchDialog.kt
  51. 4 4
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt
  52. 18 18
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPresenter.kt
  53. 4 4
      app/src/main/java/eu/kanade/tachiyomi/ui/setting/AnilistLoginActivity.kt
  54. 1 1
      app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsActivity.kt
  55. 1 1
      app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsFragment.kt
  56. 94 89
      app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsTrackingFragment.kt
  57. 8 10
      app/src/main/java/eu/kanade/tachiyomi/widget/preference/TrackLoginDialog.kt
  58. BIN
      app/src/main/res/drawable-xxxhdpi/al.png
  59. BIN
      app/src/main/res/drawable-xxxhdpi/kitsu.png
  60. BIN
      app/src/main/res/drawable-xxxhdpi/mal.png
  61. 9 0
      app/src/main/res/drawable/ic_done_white_18dp.xml
  62. 0 149
      app/src/main/res/layout/card_myanimelist_personal.xml
  63. 0 14
      app/src/main/res/layout/dialog_myanimelist_search_item.xml
  64. 0 0
      app/src/main/res/layout/dialog_track_chapters.xml
  65. 0 0
      app/src/main/res/layout/dialog_track_score.xml
  66. 3 3
      app/src/main/res/layout/dialog_track_search.xml
  67. 0 17
      app/src/main/res/layout/fragment_myanimelist.xml
  68. 20 0
      app/src/main/res/layout/fragment_track.xml
  69. 185 0
      app/src/main/res/layout/item_track.xml
  70. 14 0
      app/src/main/res/layout/item_track_search.xml
  71. 2 2
      app/src/main/res/values-es/strings.xml
  72. 1 1
      app/src/main/res/values-pt/strings.xml
  73. 2 2
      app/src/main/res/values/keys.xml
  74. 3 2
      app/src/main/res/values/strings.xml
  75. 10 8
      app/src/main/res/xml/pref_tracking.xml
  76. 17 17
      app/src/test/java/eu/kanade/tachiyomi/data/backup/BackupTest.kt

+ 11 - 1
.travis.yml

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

+ 2 - 0
app/build.gradle

@@ -110,6 +110,8 @@ 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

+ 13 - 1
app/src/main/AndroidManifest.xml

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

+ 1 - 1
app/src/main/java/eu/kanade/tachiyomi/App.kt

@@ -20,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() {
 

+ 2 - 2
app/src/main/java/eu/kanade/tachiyomi/AppModule.kt

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

+ 22 - 22
app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupManager.kt

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

+ 2 - 2
app/src/main/java/eu/kanade/tachiyomi/data/backup/serializer/IdExclusion.kt

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

+ 2 - 2
app/src/main/java/eu/kanade/tachiyomi/data/database/DatabaseHelper.kt

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

+ 1 - 1
app/src/main/java/eu/kanade/tachiyomi/data/database/DbOpenHelper.kt

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

+ 24 - 24
app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/MangaSyncTypeMapping.kt → app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/TrackTypeMapping.kt

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

+ 3 - 3
app/src/main/java/eu/kanade/tachiyomi/data/database/models/MangaSync.kt → app/src/main/java/eu/kanade/tachiyomi/data/database/models/Track.kt

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

+ 5 - 5
app/src/main/java/eu/kanade/tachiyomi/data/database/models/MangaSyncImpl.kt → app/src/main/java/eu/kanade/tachiyomi/data/database/models/TrackImpl.kt

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

+ 0 - 46
app/src/main/java/eu/kanade/tachiyomi/data/database/queries/MangaSyncQueries.kt

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

+ 34 - 0
app/src/main/java/eu/kanade/tachiyomi/data/database/queries/TrackQueries.kt

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

+ 1 - 1
app/src/main/java/eu/kanade/tachiyomi/data/database/tables/MangaSyncTable.kt → app/src/main/java/eu/kanade/tachiyomi/data/database/tables/TrackTable.kt

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

+ 0 - 23
app/src/main/java/eu/kanade/tachiyomi/data/mangasync/MangaSyncManager.kt

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

+ 0 - 51
app/src/main/java/eu/kanade/tachiyomi/data/mangasync/MangaSyncService.kt

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

+ 0 - 132
app/src/main/java/eu/kanade/tachiyomi/data/mangasync/anilist/Anilist.kt

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

+ 6 - 4
app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt

@@ -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)
 
@@ -95,9 +95,11 @@ class PreferenceKeys(context: Context) {
 
     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)
 

+ 12 - 8
app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt

@@ -7,8 +7,8 @@ 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()!!
@@ -70,9 +70,9 @@ class PreferencesHelper(val 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)
 
@@ -95,17 +95,21 @@ class PreferencesHelper(val 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)

+ 0 - 1
app/src/main/java/eu/kanade/tachiyomi/data/source/online/english/Mangahere.kt

@@ -1,6 +1,5 @@
 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

+ 28 - 0
app/src/main/java/eu/kanade/tachiyomi/data/track/TrackManager.kt

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

+ 67 - 0
app/src/main/java/eu/kanade/tachiyomi/data/track/TrackService.kt

@@ -0,0 +1,67 @@
+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
+
+    abstract fun login(username: String, password: String): Completable
+
+    open val isLogged: Boolean
+        get() = !getUsername().isEmpty() &&
+                !getPassword().isEmpty()
+
+    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 getStatus(status: Int): String
+
+    abstract fun getStatusList(): List<Int>
+
+    @DrawableRes
+    abstract fun getLogo(): Int
+
+    abstract fun getLogoColor(): Int
+
+    // TODO better support (decimals)
+    abstract fun maxScore(): Int
+
+    abstract fun formatScore(track: Track): String
+
+    fun saveCredentials(username: String, password: String) {
+        preferences.setTrackCredentials(this, username, password)
+    }
+
+    @CallSuper
+    open fun logout() {
+        preferences.setTrackCredentials(this, "", "")
+    }
+
+    fun getUsername() = preferences.trackUsername(this)
+
+    fun getPassword() = preferences.trackPassword(this)
+
+}

+ 15 - 15
app/src/main/java/eu/kanade/tachiyomi/data/mangasync/UpdateMangaSyncService.kt → app/src/main/java/eu/kanade/tachiyomi/data/track/TrackUpdateService.kt

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

+ 191 - 0
app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/Anilist.kt

@@ -0,0 +1,191 @@
+package eu.kanade.tachiyomi.data.track.anilist
+
+import android.content.Context
+import android.graphics.Color
+import com.github.salomonbrys.kotson.int
+import com.github.salomonbrys.kotson.string
+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
+import timber.log.Timber
+
+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.createService(networkService.client.newBuilder()
+                .addInterceptor(interceptor)
+                .build())
+    }
+
+    override fun getLogo() = R.drawable.al
+
+    override fun getLogoColor() = Color.rgb(18, 25, 35)
+
+    override fun maxScore() = 100
+
+    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 {
+                    preferences.anilistScoreType().set(it["score_type"].int)
+                    it["id"].string
+                }, { 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)
+    }
+
+    override fun search(query: String): Observable<List<Track>> {
+        return api.search(query, 1)
+                .flatMap { Observable.from(it) }
+                .filter { it.type != "Novel" }
+                .map { it.toTrack() }
+                .toList()
+    }
+
+    fun getList(): Observable<List<Track>> {
+        return api.getList(getUsername())
+                .flatMap { Observable.from(it.flatten()) }
+                .map { it.toTrack() }
+                .toList()
+    }
+
+    override fun add(track: Track): Observable<Track> {
+        return api.addManga(track.remote_id, track.last_chapter_read, track.getAnilistStatus())
+                .doOnNext { it.body().close() }
+                .doOnNext { if (!it.isSuccessful) throw Exception("Could not add manga") }
+                .doOnError { Timber.e(it) }
+                .map { 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.updateManga(track.remote_id, track.last_chapter_read, track.getAnilistStatus(),
+                track.getAnilistScore())
+                .doOnNext { it.body().close() }
+                .doOnNext { if (!it.isSuccessful) throw Exception("Could not update manga") }
+                .doOnError { Timber.e(it) }
+                .map { track }
+    }
+
+    override fun bind(track: Track): Observable<Track> {
+        return getList()
+                .flatMap { userlist ->
+                    track.sync_id = id
+                    val remoteTrack = userlist.find { it.remote_id == track.remote_id }
+                    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 refresh(track: Track): Observable<Track> {
+        return getList()
+                .map { myList ->
+                    val remoteTrack = myList.find { it.remote_id == track.remote_id }
+                    if (remoteTrack != null) {
+                        track.copyPersonalFrom(remoteTrack)
+                        track.total_chapters = remoteTrack.total_chapters
+                        track
+                    } else {
+                        throw Exception("Could not find manga")
+                    }
+                }
+    }
+
+    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 -> ""
+        }
+    }
+
+    private fun Track.getAnilistStatus() = when (status) {
+        READING -> "reading"
+        COMPLETED -> "completed"
+        ON_HOLD -> "on-hold"
+        DROPPED -> "dropped"
+        PLAN_TO_READ -> "plan to read"
+        else -> throw NotImplementedError("Unknown status")
+    }
+
+    fun Track.getAnilistScore(): String = when (preferences.anilistScoreType().getOrDefault()) {
+        // 10 point
+        0 -> Math.floor(score.toDouble() / 10).toInt().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")
+    }
+
+    override fun formatScore(track: Track): String {
+        return track.getAnilistScore()
+    }
+
+}
+

+ 87 - 88
app/src/main/java/eu/kanade/tachiyomi/data/mangasync/anilist/AnilistApi.kt → app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistApi.kt

@@ -1,89 +1,88 @@
-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>>
-
+package eu.kanade.tachiyomi.data.track.anilist
+
+import android.net.Uri
+import com.google.gson.JsonObject
+import eu.kanade.tachiyomi.data.network.POST
+import eu.kanade.tachiyomi.data.track.anilist.model.ALManga
+import eu.kanade.tachiyomi.data.track.anilist.model.ALUserLists
+import eu.kanade.tachiyomi.data.track.anilist.model.OAuth
+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)
+            : 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") score_raw: String)
+            : Observable<Response<ResponseBody>>
+
 }

+ 60 - 60
app/src/main/java/eu/kanade/tachiyomi/data/mangasync/anilist/AnilistInterceptor.kt → app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistInterceptor.kt

@@ -1,61 +1,61 @@
-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 eu.kanade.tachiyomi.data.track.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
+    }
+
 }

+ 16 - 16
app/src/main/java/eu/kanade/tachiyomi/data/mangasync/anilist/model/ALManga.kt → app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/model/ALManga.kt

@@ -1,17 +1,17 @@
-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 = [email protected]
-        title = title_romaji
-        total_chapters = [email protected]_chapters
-    }
+package eu.kanade.tachiyomi.data.track.anilist.model
+
+import eu.kanade.tachiyomi.data.database.models.Track
+import eu.kanade.tachiyomi.data.track.TrackManager
+
+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 = [email protected]
+        title = title_romaji
+        total_chapters = [email protected]_chapters
+    }
 }

+ 5 - 5
app/src/main/java/eu/kanade/tachiyomi/data/mangasync/anilist/model/ALUserLists.kt → app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/model/ALUserLists.kt

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

+ 28 - 28
app/src/main/java/eu/kanade/tachiyomi/data/mangasync/anilist/model/ALUserManga.kt → app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/model/ALUserManga.kt

@@ -1,29 +1,29 @@
-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")
-    }
+package eu.kanade.tachiyomi.data.track.anilist.model
+
+import eu.kanade.tachiyomi.data.database.models.Track
+import eu.kanade.tachiyomi.data.track.TrackManager
+import eu.kanade.tachiyomi.data.track.anilist.Anilist
+
+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")
+    }
 }

+ 10 - 10
app/src/main/java/eu/kanade/tachiyomi/data/mangasync/anilist/model/OAuth.kt → app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/model/OAuth.kt

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

+ 219 - 0
app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/Kitsu.kt

@@ -0,0 +1,219 @@
+package eu.kanade.tachiyomi.data.track.kitsu
+
+import android.content.Context
+import android.graphics.Color
+import com.github.salomonbrys.kotson.*
+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 timber.log.Timber
+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.createService(client.newBuilder()
+                .addInterceptor(interceptor)
+                .build())
+    }
+
+    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
+        }
+    }
+
+    override fun login(username: String, password: String): Completable {
+        return KitsuApi.createLoginService(client)
+                .requestAccessToken(username, password)
+                .doOnNext { interceptor.newAuth(it) }
+                .flatMap { api.getCurrentUser().map { it["data"].array[0]["id"].string } }
+                .doOnNext { userId -> saveCredentials(username, userId) }
+                .doOnError { logout() }
+                .toCompletable()
+    }
+
+    override fun logout() {
+        super.logout()
+        interceptor.newAuth(null)
+    }
+
+    override fun search(query: String): Observable<List<Track>> {
+        return api.search(query)
+                .map { json ->
+                    val data = json["data"].array
+                    data.map { KitsuManga(it.obj).toTrack() }
+                }
+                .doOnError { Timber.e(it) }
+    }
+
+    override fun bind(track: Track): Observable<Track> {
+        return find(track)
+                .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)
+                    }
+                }
+    }
+
+    private fun find(track: Track): Observable<Track?> {
+        return api.findLibManga(getUserId(), track.remote_id)
+                .map { json ->
+                    val data = json["data"].array
+                    if (data.size() > 0) {
+                        KitsuLibManga(data[0].obj, json["included"].array[0].obj).toTrack()
+                    } else {
+                        null
+                    }
+                }
+    }
+
+    override fun add(track: Track): Observable<Track> {
+        // @formatter:off
+        val data = jsonObject(
+            "type" to "libraryEntries",
+            "attributes" to jsonObject(
+                "status" to track.getKitsuStatus(),
+                "progress" to track.last_chapter_read
+            ),
+            "relationships" to jsonObject(
+                "user" to jsonObject(
+                    "data" to jsonObject(
+                        "id" to getUserId(),
+                        "type" to "users"
+                    )
+                ),
+                "media" to jsonObject(
+                    "data" to jsonObject(
+                        "id" to track.remote_id,
+                        "type" to "manga"
+                    )
+                )
+            )
+        )
+        // @formatter:on
+
+        return api.addLibManga(jsonObject("data" to data))
+                .doOnNext { json -> track.remote_id = json["data"]["id"].int }
+                .doOnError { Timber.e(it) }
+                .map { track }
+    }
+
+    override fun update(track: Track): Observable<Track> {
+        if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) {
+            track.status = COMPLETED
+        }
+        // @formatter:off
+        val data = jsonObject(
+            "type" to "libraryEntries",
+            "id" to track.remote_id,
+            "attributes" to jsonObject(
+                "status" to track.getKitsuStatus(),
+                "progress" to track.last_chapter_read,
+                "rating" to track.getKitsuScore()
+            )
+        )
+        // @formatter:on
+
+        return api.updateLibManga(track.remote_id, jsonObject("data" to data))
+                .map { track }
+    }
+
+    override fun refresh(track: Track): Observable<Track> {
+        return api.getLibManga(track.remote_id)
+                .map { json ->
+                    val data = json["data"].array
+                    if (data.size() > 0) {
+                        val include = json["included"].array[0].obj
+                        val remoteTrack = KitsuLibManga(data[0].obj, include).toTrack()
+                        track.copyPersonalFrom(remoteTrack)
+                        track.total_chapters = remoteTrack.total_chapters
+                        track
+                    } else {
+                        throw Exception("Could not find manga")
+                    }
+                }
+    }
+
+    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 -> ""
+        }
+    }
+
+    private fun Track.getKitsuStatus() = when (status) {
+        READING -> "current"
+        COMPLETED -> "completed"
+        ON_HOLD -> "on_hold"
+        DROPPED -> "dropped"
+        PLAN_TO_READ -> "planned"
+        else -> throw Exception("Unknown status")
+    }
+
+    private fun Track.getKitsuScore(): String {
+        return if (score > 0) (score / 2).toString() else ""
+    }
+
+    override fun getLogo(): Int {
+        return R.drawable.kitsu
+    }
+
+    override fun getLogoColor(): Int {
+        return Color.rgb(51, 37, 50)
+    }
+
+    override fun maxScore(): Int {
+        return 10
+    }
+
+    override fun formatScore(track: Track): String {
+        return track.getKitsuScore()
+    }
+
+}

+ 93 - 0
app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuApi.kt

@@ -0,0 +1,93 @@
+package eu.kanade.tachiyomi.data.track.kitsu
+
+import com.google.gson.JsonObject
+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
+
+interface KitsuApi {
+
+    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 createService(client: OkHttpClient) = Retrofit.Builder()
+                .baseUrl(baseUrl)
+                .client(client)
+                .addConverterFactory(GsonConverterFactory.create())
+                .addCallAdapterFactory(RxJavaCallAdapterFactory.create())
+                .build()
+                .create(KitsuApi::class.java)
+
+        fun createLoginService(client: OkHttpClient) = Retrofit.Builder()
+                .baseUrl(loginUrl)
+                .client(client)
+                .addConverterFactory(GsonConverterFactory.create())
+                .addCallAdapterFactory(RxJavaCallAdapterFactory.create())
+                .build()
+                .create(KitsuApi::class.java)
+
+        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())
+    }
+
+    @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>
+
+    @GET("users")
+    fun getCurrentUser(
+            @Query("filter[self]", encoded = true) self: Boolean = true
+    ) : Observable<JsonObject>
+
+    @GET("manga")
+    fun search(
+            @Query("filter[text]", encoded = true) query: String
+    ): Observable<JsonObject>
+
+    @GET("library-entries")
+    fun getLibManga(
+            @Query("filter[id]", encoded = true) remoteId: Int,
+            @Query("include") includes: String = "media"
+    ) : Observable<JsonObject>
+
+    @GET("library-entries")
+    fun findLibManga(
+            @Query("filter[user_id]", encoded = true) userId: String,
+            @Query("filter[media_id]", encoded = true) remoteId: Int,
+            @Query("page[limit]", encoded = true) limit: Int = 10000,
+            @Query("include") includes: String = "media"
+    ) : Observable<JsonObject>
+
+    @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>
+
+}

+ 46 - 0
app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuInterceptor.kt

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

+ 44 - 0
app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuModels.kt

@@ -0,0 +1,44 @@
+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"]["chapterCount"].nullInt
+
+    @CallSuper
+    open fun toTrack() = Track.create(TrackManager.KITSU).apply {
+        remote_id = [email protected]
+        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"]["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")
+    }
+
+}

+ 11 - 0
app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/OAuth.kt

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

+ 263 - 222
app/src/main/java/eu/kanade/tachiyomi/data/mangasync/myanimelist/MyAnimeList.kt → app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeList.kt

@@ -1,222 +1,263 @@
-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()
-    }
-
-}
+package eu.kanade.tachiyomi.data.track.myanimelist
+
+import android.content.Context
+import android.graphics.Color
+import android.net.Uri
+import android.util.Xml
+import eu.kanade.tachiyomi.R
+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.track.TrackService
+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) : TrackService(id) {
+
+    private lateinit var headers: Headers
+
+    companion object {
+        const 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"
+
+        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
+
+        const val PREFIX_MY = "my:"
+    }
+
+    init {
+        val username = getUsername()
+        val password = getPassword()
+
+        if (!username.isEmpty() && !password.isEmpty()) {
+            createHeaders(username, password)
+        }
+    }
+
+    override val name: String
+        get() = "MyAnimeList"
+
+    override fun getLogo() = R.drawable.mal
+
+    override fun getLogoColor() = Color.rgb(46, 81, 162)
+
+    override fun maxScore() = 10
+
+    override fun formatScore(track: Track): String {
+        return track.score.toInt().toString()
+    }
+
+    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(track: Track) = Uri.parse(BASE_URL).buildUpon()
+            .appendEncodedPath("api/mangalist/update")
+            .appendPath("${track.remote_id}.xml")
+            .toString()
+
+    fun getAddUrl(track: Track) = Uri.parse(BASE_URL).buildUpon()
+            .appendEncodedPath("api/mangalist/add")
+            .appendPath("${track.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") }
+                .doOnNext { saveCredentials(username, password) }
+                .doOnError { logout() }
+                .toCompletable()
+    }
+
+    override fun search(query: String): Observable<List<Track>> {
+        return if (query.startsWith(PREFIX_MY)) {
+            val realQuery = query.substring(PREFIX_MY.length).toLowerCase().trim()
+            getList()
+                    .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(id).apply {
+                            title = it.selectText("title")!!
+                            remote_id = it.selectInt("id")
+                            total_chapters = it.selectInt("chapters")
+                        }
+                    }
+                    .toList()
+        }
+    }
+
+    override fun refresh(track: Track): Observable<Track> {
+        return getList()
+                .map { myList ->
+                    val remoteTrack = myList.find { it.remote_id == track.remote_id }
+                    if (remoteTrack != null) {
+                        track.copyPersonalFrom(remoteTrack)
+                        track.total_chapters = remoteTrack.total_chapters
+                        track
+                    } else {
+                        throw Exception("Could not find manga")
+                    }
+                }
+    }
+
+    // MAL doesn't support score with decimals
+    fun getList(): Observable<List<Track>> {
+        return networkService.forceCacheClient
+                .newCall(GET(getListUrl(getUsername()), headers))
+                .asObservable()
+                .map { Jsoup.parse(it.body().string()) }
+                .flatMap { Observable.from(it.select("manga")) }
+                .map {
+                    Track.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(track: Track): Observable<Track> {
+        return Observable.defer {
+            if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) {
+                track.status = COMPLETED
+            }
+            client.newCall(POST(getUpdateUrl(track), headers, getMangaPostPayload(track)))
+                    .asObservable()
+                    .doOnNext { it.close() }
+                    .doOnNext { if (!it.isSuccessful) throw Exception("Could not update manga") }
+                    .map { track }
+        }
+
+    }
+
+    override fun add(track: Track): Observable<Track> {
+        return Observable.defer {
+            client.newCall(POST(getAddUrl(track), headers, getMangaPostPayload(track)))
+                    .asObservable()
+                    .doOnNext { it.close() }
+                    .doOnNext { if (!it.isSuccessful) throw Exception("Could not add manga") }
+                    .map { track }
+        }
+    }
+
+    private fun getMangaPostPayload(track: Track): RequestBody {
+        val xml = Xml.newSerializer()
+        val writer = StringWriter()
+
+        with(xml) {
+            setOutput(writer)
+            startDocument("UTF-8", false)
+            startTag("", ENTRY_TAG)
+
+            // Last chapter read
+            if (track.last_chapter_read != 0) {
+                inTag(CHAPTER_TAG, track.last_chapter_read.toString())
+            }
+            // Manga status in the list
+            inTag(STATUS_TAG, track.status.toString())
+
+            // Manga score
+            inTag(SCORE_TAG, track.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(track: Track): Observable<Track> {
+        return getList()
+                .flatMap { userlist ->
+                    track.sync_id = id
+                    val remoteTrack = userlist.find { it.remote_id == track.remote_id }
+                    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 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)
+    }
+
+    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()
+    }
+
+}

+ 35 - 14
app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaActivity.kt

@@ -3,15 +3,18 @@ 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.*
@@ -28,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))
@@ -71,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)
@@ -85,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
+
+        // 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 var pageCount: Int = 0
-        private val tabTitles = arrayOf(activity.getString(R.string.manga_detail_tab),
-                activity.getString(R.string.manga_chapters_tab), "MAL")
+        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]
         }
 

+ 3 - 3
app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaPresenter.kt

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

+ 0 - 124
app/src/main/java/eu/kanade/tachiyomi/ui/manga/myanimelist/MyAnimeListDialogFragment.kt

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

+ 0 - 177
app/src/main/java/eu/kanade/tachiyomi/ui/manga/myanimelist/MyAnimeListFragment.kt

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

+ 0 - 174
app/src/main/java/eu/kanade/tachiyomi/ui/manga/myanimelist/MyAnimeListPresenter.kt

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

+ 0 - 46
app/src/main/java/eu/kanade/tachiyomi/ui/manga/myanimelist/MyAnimeListSearchAdapter.kt

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

+ 33 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackAdapter.kt

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

+ 166 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackFragment.kt

@@ -0,0 +1,166 @@
+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
+            np.maxValue = item.service.maxScore()
+            // Set initial value
+            np.value = item.track.score.toInt()
+        }
+    }
+
+}

+ 42 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackHolder.kt

@@ -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) {
+        [email protected] = 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.formatScore(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 = ""
+        }
+    }
+}

+ 8 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackItem.kt

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

+ 137 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackPresenter.kt

@@ -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, score: Int) {
+        val track = item.track!!
+        track.score = score.toFloat()
+        updateRemote(track, item.service)
+    }
+
+    fun setLastChapterRead(item: TrackItem, chapterNumber: Int) {
+        val track = item.track!!
+        track.last_chapter_read = chapterNumber
+        updateRemote(track, item.service)
+    }
+
+}

+ 47 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSearchAdapter.kt

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

+ 119 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSearchDialog.kt

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

+ 4 - 4
app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt

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

+ 18 - 18
app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPresenter.kt

@@ -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.
@@ -165,9 +165,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,
@@ -431,9 +431,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 +446,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)
             }
         }
     }

+ 4 - 4
app/src/main/java/eu/kanade/tachiyomi/ui/setting/AnilistLoginActivity.kt

@@ -7,14 +7,14 @@ import android.view.Gravity.CENTER
 import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
 import android.widget.FrameLayout
 import android.widget.ProgressBar
-import eu.kanade.tachiyomi.data.mangasync.MangaSyncManager
+import eu.kanade.tachiyomi.data.track.TrackManager
 import rx.android.schedulers.AndroidSchedulers
 import rx.schedulers.Schedulers
 import uy.kohesive.injekt.injectLazy
 
 class AnilistLoginActivity : AppCompatActivity() {
 
-    private val syncManager: MangaSyncManager by injectLazy()
+    private val trackManager: TrackManager by injectLazy()
 
     override fun onCreate(savedState: Bundle?) {
         super.onCreate(savedState)
@@ -24,7 +24,7 @@ class AnilistLoginActivity : AppCompatActivity() {
 
         val code = intent.data?.getQueryParameter("code")
         if (code != null) {
-            syncManager.aniList.login(code)
+            trackManager.aniList.login(code)
                     .subscribeOn(Schedulers.io())
                     .observeOn(AndroidSchedulers.mainThread())
                     .subscribe({
@@ -33,7 +33,7 @@ class AnilistLoginActivity : AppCompatActivity() {
                         returnToSettings()
                     })
         } else {
-            syncManager.aniList.logout()
+            trackManager.aniList.logout()
             returnToSettings()
         }
     }

+ 1 - 1
app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsActivity.kt

@@ -63,7 +63,7 @@ class SettingsActivity : BaseActivity(),
             "general_screen" -> SettingsGeneralFragment.newInstance(key)
             "downloads_screen" -> SettingsDownloadsFragment.newInstance(key)
             "sources_screen" -> SettingsSourcesFragment.newInstance(key)
-            "sync_screen" -> SettingsSyncFragment.newInstance(key)
+            "tracking_screen" -> SettingsTrackingFragment.newInstance(key)
             "advanced_screen" -> SettingsAdvancedFragment.newInstance(key)
             "about_screen" -> SettingsAboutFragment.newInstance(key)
             else -> SettingsFragment.newInstance(key)

+ 1 - 1
app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsFragment.kt

@@ -28,7 +28,7 @@ open class SettingsFragment : XpPreferenceFragment() {
         addPreferencesFromResource(R.xml.pref_reader)
         addPreferencesFromResource(R.xml.pref_downloads)
         addPreferencesFromResource(R.xml.pref_sources)
-        addPreferencesFromResource(R.xml.pref_sync)
+        addPreferencesFromResource(R.xml.pref_tracking)
         addPreferencesFromResource(R.xml.pref_advanced)
         addPreferencesFromResource(R.xml.pref_about)
 

+ 94 - 89
app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsSyncFragment.kt → app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsTrackingFragment.kt

@@ -1,89 +1,94 @@
-package eu.kanade.tachiyomi.ui.setting
-
-import android.content.Intent
-import android.os.Bundle
-import android.support.v7.preference.PreferenceCategory
-import android.support.v7.preference.XpPreferenceFragment
-import android.view.View
-import eu.kanade.tachiyomi.R
-import eu.kanade.tachiyomi.data.mangasync.MangaSyncManager
-import eu.kanade.tachiyomi.data.mangasync.MangaSyncService
-import eu.kanade.tachiyomi.data.preference.PreferencesHelper
-import eu.kanade.tachiyomi.widget.preference.LoginPreference
-import eu.kanade.tachiyomi.widget.preference.MangaSyncLoginDialog
-import uy.kohesive.injekt.injectLazy
-
-class SettingsSyncFragment : SettingsFragment() {
-
-    companion object {
-        const val SYNC_CHANGE_REQUEST = 121
-
-        fun newInstance(rootKey: String): SettingsSyncFragment {
-            val args = Bundle()
-            args.putString(XpPreferenceFragment.ARG_PREFERENCE_ROOT, rootKey)
-            return SettingsSyncFragment().apply { arguments = args }
-        }
-    }
-
-    private val syncManager: MangaSyncManager by injectLazy()
-
-    private val preferences: PreferencesHelper by injectLazy()
-
-    val syncCategory: PreferenceCategory by bindPref(R.string.pref_category_manga_sync_accounts_key)
-
-    override fun onViewCreated(view: View, savedState: Bundle?) {
-        super.onViewCreated(view, savedState)
-
-        registerService(syncManager.myAnimeList)
-
-//        registerService(syncManager.aniList) {
-//            val intent = CustomTabsIntent.Builder()
-//                    .setToolbarColor(activity.theme.getResourceColor(R.attr.colorPrimary))
-//                    .build()
-//            intent.intent.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY)
-//            intent.launchUrl(activity, AnilistApi.authUrl())
-//        }
-    }
-
-    private fun <T : MangaSyncService> registerService(
-            service: T,
-            onPreferenceClick: (T) -> Unit = defaultOnPreferenceClick) {
-
-        LoginPreference(preferenceManager.context).apply {
-            key = preferences.keys.syncUsername(service.id)
-            title = service.name
-
-            setOnPreferenceClickListener {
-                onPreferenceClick(service)
-                true
-            }
-
-            syncCategory.addPreference(this)
-        }
-    }
-
-    private val defaultOnPreferenceClick: (MangaSyncService) -> Unit
-        get() = {
-            val fragment = MangaSyncLoginDialog.newInstance(it)
-            fragment.setTargetFragment(this, SYNC_CHANGE_REQUEST)
-            fragment.show(fragmentManager, null)
-        }
-
-    override fun onResume() {
-        super.onResume()
-        // Manually refresh anilist holder
-//        updatePreference(syncManager.aniList.id)
-    }
-
-    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
-        if (requestCode == SYNC_CHANGE_REQUEST) {
-            updatePreference(resultCode)
-        }
-    }
-
-    private fun updatePreference(id: Int) {
-        val pref = findPreference(preferences.keys.syncUsername(id)) as? LoginPreference
-        pref?.notifyChanged()
-    }
-
-}
+package eu.kanade.tachiyomi.ui.setting
+
+import android.content.Intent
+import android.os.Bundle
+import android.support.customtabs.CustomTabsIntent
+import android.support.v7.preference.PreferenceCategory
+import android.support.v7.preference.XpPreferenceFragment
+import android.view.View
+import eu.kanade.tachiyomi.R
+import eu.kanade.tachiyomi.data.preference.PreferencesHelper
+import eu.kanade.tachiyomi.data.track.TrackManager
+import eu.kanade.tachiyomi.data.track.TrackService
+import eu.kanade.tachiyomi.data.track.anilist.AnilistApi
+import eu.kanade.tachiyomi.util.getResourceColor
+import eu.kanade.tachiyomi.widget.preference.LoginPreference
+import eu.kanade.tachiyomi.widget.preference.TrackLoginDialog
+import uy.kohesive.injekt.injectLazy
+
+class SettingsTrackingFragment : SettingsFragment() {
+
+    companion object {
+        const val SYNC_CHANGE_REQUEST = 121
+
+        fun newInstance(rootKey: String): SettingsTrackingFragment {
+            val args = Bundle()
+            args.putString(XpPreferenceFragment.ARG_PREFERENCE_ROOT, rootKey)
+            return SettingsTrackingFragment().apply { arguments = args }
+        }
+    }
+
+    private val trackManager: TrackManager by injectLazy()
+
+    private val preferences: PreferencesHelper by injectLazy()
+
+    val syncCategory: PreferenceCategory by bindPref(R.string.pref_category_tracking_accounts_key)
+
+    override fun onViewCreated(view: View, savedState: Bundle?) {
+        super.onViewCreated(view, savedState)
+
+        registerService(trackManager.myAnimeList)
+
+        registerService(trackManager.aniList) {
+            val intent = CustomTabsIntent.Builder()
+                    .setToolbarColor(activity.theme.getResourceColor(R.attr.colorPrimary))
+                    .build()
+            intent.intent.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY)
+            intent.launchUrl(activity, AnilistApi.authUrl())
+        }
+
+        registerService(trackManager.kitsu)
+    }
+
+    private fun <T : TrackService> registerService(
+            service: T,
+            onPreferenceClick: (T) -> Unit = defaultOnPreferenceClick) {
+
+        LoginPreference(preferenceManager.context).apply {
+            key = preferences.keys.trackUsername(service.id)
+            title = service.name
+
+            setOnPreferenceClickListener {
+                onPreferenceClick(service)
+                true
+            }
+
+            syncCategory.addPreference(this)
+        }
+    }
+
+    private val defaultOnPreferenceClick: (TrackService) -> Unit
+        get() = {
+            val fragment = TrackLoginDialog.newInstance(it)
+            fragment.setTargetFragment(this, SYNC_CHANGE_REQUEST)
+            fragment.show(fragmentManager, null)
+        }
+
+    override fun onResume() {
+        super.onResume()
+        // Manually refresh anilist holder
+        updatePreference(trackManager.aniList.id)
+    }
+
+    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
+        if (requestCode == SYNC_CHANGE_REQUEST) {
+            updatePreference(resultCode)
+        }
+    }
+
+    private fun updatePreference(id: Int) {
+        val pref = findPreference(preferences.keys.trackUsername(id)) as? LoginPreference
+        pref?.notifyChanged()
+    }
+
+}

+ 8 - 10
app/src/main/java/eu/kanade/tachiyomi/widget/preference/MangaSyncLoginDialog.kt → app/src/main/java/eu/kanade/tachiyomi/widget/preference/TrackLoginDialog.kt

@@ -3,20 +3,20 @@ package eu.kanade.tachiyomi.widget.preference
 import android.os.Bundle
 import android.view.View
 import eu.kanade.tachiyomi.R
-import eu.kanade.tachiyomi.data.mangasync.MangaSyncManager
-import eu.kanade.tachiyomi.data.mangasync.MangaSyncService
+import eu.kanade.tachiyomi.data.track.TrackManager
+import eu.kanade.tachiyomi.data.track.TrackService
 import eu.kanade.tachiyomi.util.toast
 import kotlinx.android.synthetic.main.pref_account_login.view.*
 import rx.android.schedulers.AndroidSchedulers
 import rx.schedulers.Schedulers
 import uy.kohesive.injekt.injectLazy
 
-class MangaSyncLoginDialog : LoginDialogPreference() {
+class TrackLoginDialog : LoginDialogPreference() {
 
     companion object {
 
-        fun newInstance(sync: MangaSyncService): LoginDialogPreference {
-            val fragment = MangaSyncLoginDialog()
+        fun newInstance(sync: TrackService): LoginDialogPreference {
+            val fragment = TrackLoginDialog()
             val bundle = Bundle(1)
             bundle.putInt("key", sync.id)
             fragment.arguments = bundle
@@ -24,15 +24,15 @@ class MangaSyncLoginDialog : LoginDialogPreference() {
         }
     }
 
-    val syncManager: MangaSyncManager by injectLazy()
+    val trackManager: TrackManager by injectLazy()
 
-    lateinit var sync: MangaSyncService
+    lateinit var sync: TrackService
 
     override fun onCreate(savedInstanceState: Bundle?) {
         super.onCreate(savedInstanceState)
 
         val syncId = arguments.getInt("key")
-        sync = syncManager.getService(syncId)!!
+        sync = trackManager.getService(syncId)!!
     }
 
     override fun setCredentialsOnView(view: View) = with(view) {
@@ -56,11 +56,9 @@ class MangaSyncLoginDialog : LoginDialogPreference() {
                     .subscribeOn(Schedulers.io())
                     .observeOn(AndroidSchedulers.mainThread())
                     .subscribe({
-                        sync.saveCredentials(user, pass)
                         dialog.dismiss()
                         context.toast(R.string.login_success)
                     }, { error ->
-                        sync.logout()
                         login.progress = -1
                         login.setText(R.string.unknown_error)
                     })

BIN
app/src/main/res/drawable-xxxhdpi/al.png


BIN
app/src/main/res/drawable-xxxhdpi/kitsu.png


BIN
app/src/main/res/drawable-xxxhdpi/mal.png


+ 9 - 0
app/src/main/res/drawable/ic_done_white_18dp.xml

@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="18dp"
+    android:height="18dp"
+    android:viewportWidth="24.0"
+    android:viewportHeight="24.0">
+    <path
+        android:fillColor="#FFFFFFFF"
+        android:pathData="M9,16.2L4.8,12l-1.4,1.4L9,19 21,7l-1.4,-1.4L9,16.2z"/>
+</vector>

+ 0 - 149
app/src/main/res/layout/card_myanimelist_personal.xml

@@ -1,149 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<android.support.v7.widget.CardView
-    xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:tools="http://schemas.android.com/tools"
-    android:id="@+id/cv_mal"
-    style="@style/Theme.Widget.CardView"
-    >
-
-    <RelativeLayout
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:padding="@dimen/card_margin">
-
-        <RelativeLayout
-            android:id="@+id/myanimelist_title_layout"
-            android:layout_width="match_parent"
-            android:layout_height="?android:listPreferredItemHeightSmall"
-            android:background="?attr/selectable_list_drawable"
-            android:clickable="true"
-            android:paddingLeft="?android:listPreferredItemPaddingLeft"
-            android:paddingRight="?android:listPreferredItemPaddingRight">
-
-            <TextView
-                style="@style/TextAppearance.Regular.Body1"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:layout_centerVertical="true"
-                android:text="Title"/>
-
-            <TextView
-                android:id="@+id/myanimelist_title"
-                style="@style/TextAppearance.Medium.Button"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:layout_alignParentRight="true"
-                android:layout_centerVertical="true"
-                android:text="@string/action_edit"/>
-
-        </RelativeLayout>
-
-        <View
-            android:id="@+id/divider1"
-            android:layout_width="fill_parent"
-            android:layout_height="1dp"
-            android:layout_below="@id/myanimelist_title_layout"
-            android:background="?android:attr/divider"/>
-
-        <RelativeLayout
-            android:id="@+id/myanimelist_status_layout"
-            android:layout_width="match_parent"
-            android:layout_height="?android:listPreferredItemHeightSmall"
-            android:layout_below="@id/divider1"
-            android:background="?attr/selectable_list_drawable"
-            android:clickable="true"
-            android:paddingLeft="?android:listPreferredItemPaddingLeft"
-            android:paddingRight="?android:listPreferredItemPaddingRight"
-            >
-
-            <TextView
-                style="@style/TextAppearance.Regular.Body1"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:layout_centerVertical="true"
-                android:text="Status"/>
-
-            <TextView
-                android:id="@+id/myanimelist_status"
-                style="@style/TextAppearance.Regular.Body1.Secondary"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:layout_alignParentRight="true"
-                android:layout_centerVertical="true"
-                tools:text="Reading"/>
-
-        </RelativeLayout>
-
-        <View
-            android:id="@+id/divider2"
-            android:layout_width="fill_parent"
-            android:layout_height="1dp"
-            android:layout_below="@id/myanimelist_status_layout"
-            android:background="?android:attr/divider"/>
-
-        <RelativeLayout
-            android:id="@+id/myanimelist_chapters_layout"
-            android:layout_width="match_parent"
-            android:layout_height="?android:listPreferredItemHeightSmall"
-            android:layout_below="@id/divider2"
-            android:background="?attr/selectable_list_drawable"
-            android:clickable="true"
-            android:paddingLeft="?android:listPreferredItemPaddingLeft"
-            android:paddingRight="?android:listPreferredItemPaddingRight">
-
-            <TextView
-                style="@style/TextAppearance.Regular.Body1"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:layout_centerVertical="true"
-                android:text="Chapters"/>
-
-            <TextView
-                android:id="@+id/myanimelist_chapters"
-                style="@style/TextAppearance.Regular.Body1.Secondary"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:layout_alignParentRight="true"
-                android:layout_centerVertical="true"
-                tools:text="12/24"/>
-
-        </RelativeLayout>
-
-        <View
-            android:id="@+id/divider3"
-            android:layout_width="fill_parent"
-            android:layout_height="1dp"
-            android:layout_below="@id/myanimelist_chapters_layout"
-            android:background="?android:attr/divider"/>
-
-        <RelativeLayout
-            android:id="@+id/myanimelist_score_layout"
-            android:layout_width="match_parent"
-            android:layout_height="?android:listPreferredItemHeightSmall"
-            android:layout_below="@id/divider3"
-            android:background="?attr/selectable_list_drawable"
-            android:clickable="true"
-            android:paddingLeft="?android:listPreferredItemPaddingLeft"
-            android:paddingRight="?android:listPreferredItemPaddingRight">
-
-            <TextView
-                style="@style/TextAppearance.Regular.Body1"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:layout_centerVertical="true"
-                android:text="@string/score"/>
-
-            <TextView
-                android:id="@+id/myanimelist_score"
-                style="@style/TextAppearance.Regular.Body1.Secondary"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:layout_alignParentRight="true"
-                android:layout_centerVertical="true"
-                tools:text="10"/>
-
-        </RelativeLayout>
-
-    </RelativeLayout>
-
-</android.support.v7.widget.CardView>

+ 0 - 14
app/src/main/res/layout/dialog_myanimelist_search_item.xml

@@ -1,14 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
-              android:layout_width="match_parent"
-              android:layout_height="match_parent"
-              android:orientation="vertical"
-              android:background="?attr/selectable_list_drawable">
-
-    <TextView
-        android:id="@+id/myanimelist_result_title"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:padding="10dp"/>
-
-</LinearLayout>

+ 0 - 0
app/src/main/res/layout/dialog_myanimelist_chapters.xml → app/src/main/res/layout/dialog_track_chapters.xml


+ 0 - 0
app/src/main/res/layout/dialog_myanimelist_score.xml → app/src/main/res/layout/dialog_track_score.xml


+ 3 - 3
app/src/main/res/layout/dialog_myanimelist_search.xml → app/src/main/res/layout/dialog_track_search.xml

@@ -14,11 +14,11 @@
         android:paddingRight="@dimen/margin_right">
 
         <EditText
-            android:id="@+id/myanimelist_search_field"
+            android:id="@+id/track_search"
             android:layout_width="0dp"
             android:layout_height="wrap_content"
             android:layout_weight="1"
-            android:hint="@string/title_hint"/>
+            android:hint="@string/title"/>
 
     </LinearLayout>
 
@@ -33,7 +33,7 @@
         android:visibility="gone"/>
 
     <ListView
-        android:id="@+id/myanimelist_search_results"
+        android:id="@+id/track_search_list"
         android:layout_width="match_parent"
         android:layout_height="wrap_content"
         android:choiceMode="singleChoice"

+ 0 - 17
app/src/main/res/layout/fragment_myanimelist.xml

@@ -1,17 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<android.support.v4.widget.SwipeRefreshLayout
-    android:id="@+id/swipe_refresh"
-    xmlns:android="http://schemas.android.com/apk/res/android"
-    android:layout_width="match_parent"
-    android:layout_height="match_parent"
-    android:orientation="vertical">
-
-    <ScrollView
-        android:layout_width="match_parent"
-        android:layout_height="match_parent">
-
-        <include layout="@layout/card_myanimelist_personal"/>
-
-    </ScrollView>
-
-</android.support.v4.widget.SwipeRefreshLayout>

+ 20 - 0
app/src/main/res/layout/fragment_track.xml

@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    android:orientation="vertical" android:layout_width="match_parent"
+    android:layout_height="match_parent">
+
+    <android.support.v4.widget.SwipeRefreshLayout
+        android:id="@+id/swipe_refresh"
+        xmlns:android="http://schemas.android.com/apk/res/android"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        android:orientation="vertical">
+
+        <android.support.v7.widget.RecyclerView
+            android:id="@+id/recycler"
+            android:layout_width="match_parent"
+            android:layout_height="match_parent"/>
+
+   </android.support.v4.widget.SwipeRefreshLayout>
+
+</LinearLayout>

+ 185 - 0
app/src/main/res/layout/item_track.xml

@@ -0,0 +1,185 @@
+<?xml version="1.0" encoding="utf-8"?>
+<android.support.v7.widget.CardView
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:id="@+id/track"
+    style="@style/Theme.Widget.CardView">
+
+    <android.support.constraint.ConstraintLayout
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content">
+
+        <FrameLayout
+            android:id="@+id/logo"
+            android:layout_width="48dp"
+            android:layout_height="0dp"
+            tools:background="#2E51A2"
+            app:layout_constraintLeft_toLeftOf="parent"
+            app:layout_constraintTop_toTopOf="parent"
+            app:layout_constraintBottom_toBottomOf="parent">
+
+            <ImageView
+                android:id="@+id/track_logo"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:layout_gravity="center"
+                tools:src="@drawable/mal" />
+
+        </FrameLayout>
+
+        <RelativeLayout
+            android:id="@+id/title_container"
+            android:layout_width="0dp"
+            android:layout_height="wrap_content"
+            android:padding="16dp"
+            android:background="?attr/selectable_list_drawable"
+            android:clickable="true"
+            app:layout_constraintTop_toTopOf="parent"
+            app:layout_constraintLeft_toRightOf="@+id/logo"
+            app:layout_constraintRight_toRightOf="parent">
+
+            <TextView
+                style="@style/TextAppearance.Regular.Body1"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:text="@string/title" />
+
+            <TextView
+                android:id="@+id/track_title"
+                style="@style/TextAppearance.Medium.Button"
+                android:textColor="?colorAccent"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:layout_alignParentRight="true"
+                android:layout_alignParentEnd="true"
+                android:text="@string/action_edit" />
+
+        </RelativeLayout>
+
+        <View
+            android:id="@+id/divider1"
+            android:layout_width="0dp"
+            android:layout_height="1dp"
+            android:background="?android:attr/divider"
+            app:layout_constraintTop_toBottomOf="@+id/title_container"
+            app:layout_constraintLeft_toRightOf="@+id/logo"
+            app:layout_constraintRight_toRightOf="parent"
+            android:layout_marginStart="16dp"
+            android:layout_marginLeft="16dp"
+            android:layout_marginEnd="16dp"
+            android:layout_marginRight="16dp" />
+
+        <RelativeLayout
+            android:id="@+id/status_container"
+            android:layout_width="0dp"
+            android:layout_height="wrap_content"
+            android:padding="16dp"
+            android:background="?attr/selectable_list_drawable"
+            android:clickable="true"
+            app:layout_constraintTop_toBottomOf="@+id/divider1"
+            app:layout_constraintLeft_toRightOf="@+id/logo"
+            app:layout_constraintRight_toRightOf="parent">
+
+            <TextView
+                style="@style/TextAppearance.Regular.Body1"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:text="@string/status" />
+
+            <TextView
+                android:id="@+id/track_status"
+                style="@style/TextAppearance.Regular.Body1.Secondary"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:layout_alignParentRight="true"
+                android:layout_alignParentEnd="true"
+                tools:text="Reading" />
+
+        </RelativeLayout>
+
+        <View
+            android:id="@+id/divider2"
+            android:layout_width="0dp"
+            android:layout_height="1dp"
+            android:background="?android:attr/divider"
+            app:layout_constraintTop_toBottomOf="@+id/status_container"
+            app:layout_constraintLeft_toRightOf="@+id/logo"
+            app:layout_constraintRight_toRightOf="parent"
+            android:layout_marginStart="16dp"
+            android:layout_marginLeft="16dp"
+            android:layout_marginEnd="16dp"
+            android:layout_marginRight="16dp" />
+
+        <RelativeLayout
+            android:id="@+id/chapters_container"
+            android:layout_width="0dp"
+            android:layout_height="wrap_content"
+            android:padding="16dp"
+            android:background="?attr/selectable_list_drawable"
+            android:clickable="true"
+            app:layout_constraintTop_toBottomOf="@+id/divider2"
+            app:layout_constraintLeft_toRightOf="@+id/logo"
+            app:layout_constraintRight_toRightOf="parent">
+
+            <TextView
+                style="@style/TextAppearance.Regular.Body1"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:text="@string/chapters" />
+
+            <TextView
+                android:id="@+id/track_chapters"
+                style="@style/TextAppearance.Regular.Body1.Secondary"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:layout_alignParentRight="true"
+                android:layout_alignParentEnd="true"
+                tools:text="12/24" />
+
+        </RelativeLayout>
+
+        <View
+            android:id="@+id/divider3"
+            android:layout_width="0dp"
+            android:layout_height="1dp"
+            android:background="?android:attr/divider"
+            app:layout_constraintTop_toBottomOf="@+id/chapters_container"
+            app:layout_constraintLeft_toRightOf="@+id/logo"
+            app:layout_constraintRight_toRightOf="parent"
+            android:layout_marginStart="16dp"
+            android:layout_marginLeft="16dp"
+            android:layout_marginEnd="16dp"
+            android:layout_marginRight="16dp" />
+
+        <RelativeLayout
+            android:id="@+id/score_container"
+            android:layout_width="0dp"
+            android:layout_height="wrap_content"
+            android:padding="16dp"
+            android:background="?attr/selectable_list_drawable"
+            android:clickable="true"
+            app:layout_constraintTop_toBottomOf="@+id/divider3"
+            app:layout_constraintLeft_toRightOf="@+id/logo"
+            app:layout_constraintRight_toRightOf="parent">
+
+            <TextView
+                style="@style/TextAppearance.Regular.Body1"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:text="@string/score" />
+
+            <TextView
+                android:id="@+id/track_score"
+                style="@style/TextAppearance.Regular.Body1.Secondary"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:layout_alignParentRight="true"
+                android:layout_alignParentEnd="true"
+                tools:text="10" />
+
+        </RelativeLayout>
+
+    </android.support.constraint.ConstraintLayout>
+
+</android.support.v7.widget.CardView>

+ 14 - 0
app/src/main/res/layout/item_track_search.xml

@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:orientation="vertical"
+    android:background="?attr/selectable_list_drawable">
+
+    <TextView
+        android:id="@+id/track_search_title"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:padding="10dp"/>
+
+</LinearLayout>

+ 2 - 2
app/src/main/res/values-es/strings.xml

@@ -65,7 +65,7 @@
     <string name="pref_category_reader">Lector</string>
     <string name="pref_category_downloads">Descargas</string>
     <string name="pref_category_sources">Fuentes</string>
-    <string name="pref_category_sync">Sincronización</string>
+    <string name="pref_category_tracking">Seguimiento</string>
     <string name="pref_category_advanced">Avanzado</string>
     <string name="pref_category_about">Acerca de</string>
 
@@ -232,7 +232,7 @@
     <string name="on_hold">En espera</string>
     <string name="plan_to_read">Para leer luego</string>
     <string name="score">Puntuación</string>
-    <string name="title_hint">Título</string>
+    <string name="title">Título</string>
     <string name="status">Estado</string>
     <string name="chapters">Capítulos</string>
 

+ 1 - 1
app/src/main/res/values-pt/strings.xml

@@ -240,7 +240,7 @@
     <string name="on_hold">Em espera</string>
     <string name="plan_to_read">Planeada a leitura</string>
     <string name="score">Avaliação</string>
-    <string name="title_hint">Título</string>
+    <string name="title">Título</string>
     <string name="status">Estado</string>
     <string name="chapters">Capítulos</string>
 

+ 2 - 2
app/src/main/res/values/keys.xml

@@ -3,7 +3,7 @@
 
     <string name="pref_category_general_key">pref_category_general_key</string>
     <string name="pref_category_reader_key">pref_category_reader_key</string>
-    <string name="pref_category_sync_key">pref_category_sync_key</string>
+    <string name="pref_category_tracking_key">pref_category_tracking_key</string>
     <string name="pref_category_downloads_key">pref_category_downloads_key</string>
     <string name="pref_category_advanced_key">pref_category_advanced_key</string>
     <string name="pref_category_about_key">pref_category_about_key</string>
@@ -52,7 +52,7 @@
     <string name="pref_last_used_category_key">last_used_category</string>
 
     <string name="pref_source_languages">pref_source_languages</string>
-    <string name="pref_category_manga_sync_accounts_key">category_manga_sync_accounts</string>
+    <string name="pref_category_tracking_accounts_key">category_tracking_accounts</string>
 
     <string name="pref_clear_chapter_cache_key">pref_clear_chapter_cache_key</string>
     <string name="pref_clear_database_key">pref_clear_database_key</string>

+ 3 - 2
app/src/main/res/values/strings.xml

@@ -80,7 +80,7 @@
     <string name="pref_category_reader">Reader</string>
     <string name="pref_category_downloads">Downloads</string>
     <string name="pref_category_sources">Sources</string>
-    <string name="pref_category_sync">Sync</string>
+    <string name="pref_category_tracking">Tracking</string>
     <string name="pref_category_advanced">Advanced</string>
     <string name="pref_category_about">About</string>
 
@@ -276,13 +276,14 @@
     <string name="confirm_delete_chapters">Are you sure you want to delete selected chapters?</string>
 
     <!-- MyAnimeList fragment -->
+    <string name="manga_tracking_tab">Tracking</string>
     <string name="reading">Reading</string>
     <string name="completed">Completed</string>
     <string name="dropped">Dropped</string>
     <string name="on_hold">On hold</string>
     <string name="plan_to_read">Plan to read</string>
     <string name="score">Score</string>
-    <string name="title_hint">Title</string>
+    <string name="title">Title</string>
     <string name="status">Status</string>
     <string name="chapters">Chapters</string>
 

+ 10 - 8
app/src/main/res/xml/pref_sync.xml → app/src/main/res/xml/pref_tracking.xml

@@ -1,30 +1,32 @@
 <?xml version="1.0" encoding="utf-8"?>
-<PreferenceScreen
-    xmlns:android="http://schemas.android.com/apk/res/android"
+<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
     xmlns:app="http://schemas.android.com/apk/res-auto">
 
     <PreferenceScreen
         android:icon="@drawable/ic_sync_black_24dp"
-        android:key="sync_screen"
+        android:key="tracking_screen"
         android:persistent="false"
-        android:title="@string/pref_category_sync"
+        android:title="@string/pref_category_tracking"
         app:asp_tintEnabled="true">
 
         <SwitchPreference
             android:key="@string/pref_auto_update_manga_sync_key"
             android:title="@string/pref_auto_update_manga_sync"
-            android:defaultValue="true" />
+            android:defaultValue="true"
+            app:showText="false"/>
 
         <SwitchPreference
             android:key="@string/pref_ask_update_manga_sync_key"
             android:title="@string/pref_ask_update_manga_sync"
             android:defaultValue="false"
-            android:dependency="@string/pref_auto_update_manga_sync_key" />
+            android:dependency="@string/pref_auto_update_manga_sync_key"
+            app:showText="false"/>
 
         <PreferenceCategory
-            android:key="@string/pref_category_manga_sync_accounts_key"
+            android:key="@string/pref_category_tracking_accounts_key"
             android:title="@string/services"
-            android:persistent="false" />
+            android:persistent="false"
+            app:showText="false"/>
 
     </PreferenceScreen>
 

+ 17 - 17
app/src/test/java/eu/kanade/tachiyomi/data/backup/BackupTest.kt

@@ -390,16 +390,16 @@ class BackupTest {
 
     @Test
     fun testRestoreSyncForManga() {
-        // Create a manga and mangaSync
+        // Create a manga and track
         val manga = createManga("title")
         manga.id = 1L
 
-        val mangaSync = createMangaSync(manga, 1, 2, 3)
+        val track = createTrack(manga, 1, 2, 3)
 
         // Add an entry for the manga
         val entry = JsonObject()
         entry.add("manga", toJson(manga))
-        entry.add("sync", toJson(mangaSync))
+        entry.add("sync", toJson(track))
 
         // Append the entry to the backup list
         val mangas = ArrayList<JsonElement>()
@@ -412,7 +412,7 @@ class BackupTest {
         val dbManga = db.getManga(1).executeAsBlocking()
         assertThat(dbManga).isNotNull()
 
-        val dbSync = db.getMangasSync(dbManga!!).executeAsBlocking()
+        val dbSync = db.getTracks(dbManga!!).executeAsBlocking()
         assertThat(dbSync).hasSize(3)
     }
 
@@ -422,13 +422,13 @@ class BackupTest {
         // Create a manga and 3 sync
         val manga = createManga("title")
         manga.id = mangaId
-        val mangaSync = createMangaSync(manga, 1, 2, 3)
+        val track = createTrack(manga, 1, 2, 3)
         db.insertManga(manga).executeAsBlocking()
 
         // Add an entry for the manga
         val entry = JsonObject()
         entry.add("manga", toJson(manga))
-        entry.add("sync", toJson(mangaSync))
+        entry.add("sync", toJson(track))
 
         // Append the entry to the backup list
         val mangas = ArrayList<JsonElement>()
@@ -441,7 +441,7 @@ class BackupTest {
         val dbManga = db.getManga(mangaId).executeAsBlocking()
         assertThat(dbManga).isNotNull()
 
-        val dbSync = db.getMangasSync(dbManga!!).executeAsBlocking()
+        val dbSync = db.getTracks(dbManga!!).executeAsBlocking()
         assertThat(dbSync).hasSize(3)
     }
 
@@ -451,17 +451,17 @@ class BackupTest {
         // Store a manga and 3 sync
         val manga = createManga("title")
         manga.id = mangaId
-        var mangaSync = createMangaSync(manga, 1, 2, 3)
+        var track = createTrack(manga, 1, 2, 3)
         db.insertManga(manga).executeAsBlocking()
-        db.insertMangasSync(mangaSync).executeAsBlocking()
+        db.insertTracks(track).executeAsBlocking()
 
         // The backup contains a existing sync and a new one, so it should have 4 sync
-        mangaSync = createMangaSync(manga, 3, 4)
+        track = createTrack(manga, 3, 4)
 
         // Add an entry for the manga
         val entry = JsonObject()
         entry.add("manga", toJson(manga))
-        entry.add("sync", toJson(mangaSync))
+        entry.add("sync", toJson(track))
 
         // Append the entry to the backup list
         val mangas = ArrayList<JsonElement>()
@@ -474,7 +474,7 @@ class BackupTest {
         val dbManga = db.getManga(mangaId).executeAsBlocking()
         assertThat(dbManga).isNotNull()
 
-        val dbSync = db.getMangasSync(dbManga!!).executeAsBlocking()
+        val dbSync = db.getTracks(dbManga!!).executeAsBlocking()
         assertThat(dbSync).hasSize(4)
     }
 
@@ -546,17 +546,17 @@ class BackupTest {
         return chapters
     }
 
-    private fun createMangaSync(manga: Manga, syncId: Int): MangaSync {
-        val m = MangaSync.create(syncId)
+    private fun createTrack(manga: Manga, syncId: Int): Track {
+        val m = Track.create(syncId)
         m.manga_id = manga.id!!
         m.title = "title"
         return m
     }
 
-    private fun createMangaSync(manga: Manga, vararg syncIds: Int): List<MangaSync> {
-        val ms = ArrayList<MangaSync>()
+    private fun createTrack(manga: Manga, vararg syncIds: Int): List<Track> {
+        val ms = ArrayList<Track>()
         for (title in syncIds) {
-            ms.add(createMangaSync(manga, title))
+            ms.add(createTrack(manga, title))
         }
         return ms
     }