mirror of
				https://github.com/mihonapp/mihon.git
				synced 2025-10-31 14:27:57 +01:00 
			
		
		
		
	merge double upstream
This commit is contained in:
		| @@ -3,6 +3,7 @@ package eu.kanade.tachiyomi | ||||
| import android.app.Application | ||||
| import android.content.Context | ||||
| import android.content.res.Configuration | ||||
| <<<<<<< HEAD | ||||
| import android.graphics.Color | ||||
| import android.os.Build | ||||
| import android.os.Environment | ||||
| @@ -37,16 +38,28 @@ import io.realm.Realm | ||||
| import io.realm.RealmConfiguration | ||||
| import kotlinx.coroutines.GlobalScope | ||||
| import kotlinx.coroutines.launch | ||||
| import androidx.lifecycle.Lifecycle | ||||
| import androidx.lifecycle.LifecycleObserver | ||||
| import androidx.lifecycle.OnLifecycleEvent | ||||
| import androidx.lifecycle.ProcessLifecycleOwner | ||||
| import androidx.multidex.MultiDex | ||||
| import eu.kanade.tachiyomi.data.notification.Notifications | ||||
| import eu.kanade.tachiyomi.data.preference.PreferencesHelper | ||||
| import eu.kanade.tachiyomi.ui.security.SecureActivityDelegate | ||||
| import eu.kanade.tachiyomi.util.system.LocaleHelper | ||||
| import timber.log.Timber | ||||
| import uy.kohesive.injekt.Injekt | ||||
| import uy.kohesive.injekt.api.InjektScope | ||||
| import uy.kohesive.injekt.injectLazy | ||||
| import uy.kohesive.injekt.registry.default.DefaultRegistrar | ||||
| import java.io.File | ||||
| import java.security.NoSuchAlgorithmException | ||||
| import javax.net.ssl.SSLContext | ||||
| import kotlin.concurrent.thread | ||||
|  | ||||
| open class App : Application() { | ||||
|  | ||||
| open class App : Application(), LifecycleObserver { | ||||
|  | ||||
|     override fun onCreate() { | ||||
|         super.onCreate() | ||||
|         if (BuildConfig.DEBUG) Timber.plant(Timber.DebugTree()) | ||||
| @@ -57,7 +70,6 @@ open class App : Application() { | ||||
|         Injekt = InjektScope(DefaultRegistrar()) | ||||
|         Injekt.importModule(AppModule(this)) | ||||
|  | ||||
|         setupJobManager() | ||||
|         setupNotificationChannels() | ||||
|         GlobalScope.launch { deleteOldMetadataRealm() } // Delete old metadata DB (EH) | ||||
|         Reprint.initialize(this) //Setup fingerprint (EH) | ||||
| @@ -66,6 +78,8 @@ open class App : Application() { | ||||
|         } | ||||
|  | ||||
|         LocaleHelper.updateConfiguration(this, resources.configuration) | ||||
|  | ||||
|         ProcessLifecycleOwner.get().lifecycle.addObserver(this) | ||||
|     } | ||||
|  | ||||
|     override fun attachBaseContext(base: Context) { | ||||
| @@ -97,18 +111,12 @@ open class App : Application() { | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     protected open fun setupJobManager() { | ||||
|         try { | ||||
|             JobManager.create(this).addJobCreator { tag -> | ||||
|                 when (tag) { | ||||
|                     LibraryUpdateJob.TAG -> LibraryUpdateJob() | ||||
|                     UpdaterJob.TAG -> UpdaterJob() | ||||
|                     BackupCreatorJob.TAG -> BackupCreatorJob() | ||||
|                     else -> null | ||||
|                 } | ||||
|             } | ||||
|         } catch (e: Exception) { | ||||
|             Timber.w("Can't initialize job manager") | ||||
|     @OnLifecycleEvent(Lifecycle.Event.ON_STOP) | ||||
|     @Suppress("unused") | ||||
|     fun onAppBackgrounded() { | ||||
|         val preferences: PreferencesHelper by injectLazy() | ||||
|         if (preferences.lockAppAfter().get() >= 0) { | ||||
|             SecureActivityDelegate.locked = true | ||||
|         } | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -11,16 +11,17 @@ import eu.kanade.tachiyomi.data.track.TrackManager | ||||
| import eu.kanade.tachiyomi.extension.ExtensionManager | ||||
| import eu.kanade.tachiyomi.network.NetworkHelper | ||||
| import eu.kanade.tachiyomi.source.SourceManager | ||||
| import exh.eh.EHentaiUpdateHelper | ||||
| import io.noties.markwon.Markwon | ||||
| import rx.Observable | ||||
| import rx.schedulers.Schedulers | ||||
| import uy.kohesive.injekt.api.* | ||||
| import kotlinx.coroutines.GlobalScope | ||||
| import kotlinx.coroutines.launch | ||||
| import uy.kohesive.injekt.api.InjektModule | ||||
| import uy.kohesive.injekt.api.InjektRegistrar | ||||
| import uy.kohesive.injekt.api.addSingleton | ||||
| import uy.kohesive.injekt.api.addSingletonFactory | ||||
| import uy.kohesive.injekt.api.get | ||||
|  | ||||
| class AppModule(val app: Application) : InjektModule { | ||||
|  | ||||
|     override fun InjektRegistrar.registerInjectables() { | ||||
|  | ||||
|         addSingleton(app) | ||||
|  | ||||
|         addSingletonFactory { PreferencesHelper(app) } | ||||
| @@ -49,20 +50,14 @@ class AppModule(val app: Application) : InjektModule { | ||||
|  | ||||
|         // Asynchronously init expensive components for a faster cold start | ||||
|  | ||||
|         rxAsync { get<PreferencesHelper>() } | ||||
|         GlobalScope.launch { get<PreferencesHelper>() } | ||||
|  | ||||
|         rxAsync { get<NetworkHelper>() } | ||||
|         GlobalScope.launch { get<NetworkHelper>() } | ||||
|  | ||||
|         rxAsync { get<SourceManager>() } | ||||
|         GlobalScope.launch { get<SourceManager>() } | ||||
|  | ||||
|         rxAsync { get<DatabaseHelper>() } | ||||
|  | ||||
|         rxAsync { get<DownloadManager>() } | ||||
|         GlobalScope.launch { get<DatabaseHelper>() } | ||||
|  | ||||
|         GlobalScope.launch { get<DownloadManager>() } | ||||
|     } | ||||
|  | ||||
|     private fun rxAsync(block: () -> Unit) { | ||||
|         Observable.fromCallable { block() }.subscribeOn(Schedulers.computation()).subscribe() | ||||
|     } | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -1,9 +1,11 @@ | ||||
| package eu.kanade.tachiyomi | ||||
|  | ||||
| import eu.kanade.tachiyomi.data.backup.BackupCreatorJob | ||||
| import eu.kanade.tachiyomi.data.library.LibraryUpdateJob | ||||
| import eu.kanade.tachiyomi.data.preference.PreferencesHelper | ||||
| import eu.kanade.tachiyomi.data.preference.getOrDefault | ||||
| import eu.kanade.tachiyomi.data.updater.UpdaterJob | ||||
| import eu.kanade.tachiyomi.extension.ExtensionUpdateJob | ||||
| import eu.kanade.tachiyomi.ui.library.LibrarySort | ||||
| import java.io.File | ||||
|  | ||||
| object Migrations { | ||||
| @@ -18,18 +20,33 @@ object Migrations { | ||||
|      */ | ||||
|     fun upgrade(preferences: PreferencesHelper): Boolean { | ||||
|         val context = preferences.context | ||||
|         val oldVersion = preferences.lastVersionCode().getOrDefault() | ||||
|         val oldVersion = preferences.lastVersionCode().get() | ||||
|  | ||||
|         // Cancel app updater job for debug builds that don't include it | ||||
|         if (BuildConfig.DEBUG && !BuildConfig.INCLUDE_UPDATER) { | ||||
|             UpdaterJob.cancelTask(context) | ||||
|         } | ||||
|  | ||||
|         if (oldVersion < BuildConfig.VERSION_CODE) { | ||||
|             preferences.lastVersionCode().set(BuildConfig.VERSION_CODE) | ||||
|  | ||||
|             if (oldVersion == 0) return false | ||||
|             // Fresh install | ||||
|             if (oldVersion == 0) { | ||||
|                 // Set up default background tasks | ||||
|                 if (BuildConfig.INCLUDE_UPDATER) { | ||||
|                     UpdaterJob.setupTask(context) | ||||
|                 } | ||||
|                 ExtensionUpdateJob.setupTask(context) | ||||
|                 LibraryUpdateJob.setupTask(context) | ||||
|                 return false | ||||
|             } | ||||
|  | ||||
|             if (oldVersion < 14) { | ||||
|                 // Restore jobs after upgrading to evernote's job scheduler. | ||||
|                 if (BuildConfig.INCLUDE_UPDATER && preferences.automaticUpdates()) { | ||||
|                     UpdaterJob.setupTask() | ||||
|                 // Restore jobs after upgrading to Evernote's job scheduler. | ||||
|                 if (BuildConfig.INCLUDE_UPDATER) { | ||||
|                     UpdaterJob.setupTask(context) | ||||
|                 } | ||||
|                 LibraryUpdateJob.setupTask() | ||||
|                 LibraryUpdateJob.setupTask(context) | ||||
|             } | ||||
|             if (oldVersion < 15) { | ||||
|                 // Delete internal chapter cache dir. | ||||
| @@ -41,7 +58,7 @@ object Migrations { | ||||
|                 if (oldDir.exists()) { | ||||
|                     val destDir = context.getExternalFilesDir("covers") | ||||
|                     if (destDir != null) { | ||||
|                         oldDir.listFiles().forEach { | ||||
|                         oldDir.listFiles()?.forEach { | ||||
|                             it.renameTo(File(destDir, it.name)) | ||||
|                         } | ||||
|                     } | ||||
| @@ -57,12 +74,25 @@ object Migrations { | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|             if (oldVersion < 43) { | ||||
|                 // Restore jobs after migrating from Evernote's job scheduler to WorkManager. | ||||
|                 if (BuildConfig.INCLUDE_UPDATER) { | ||||
|                     UpdaterJob.setupTask(context) | ||||
|                 } | ||||
|                 LibraryUpdateJob.setupTask(context) | ||||
|                 BackupCreatorJob.setupTask(context) | ||||
|  | ||||
|             // ===========[ ALL MIGRATIONS ABOVE HERE HAVE BEEN ALREADY REWRITTEN ]=========== | ||||
|  | ||||
|                 // New extension update check job | ||||
|                 ExtensionUpdateJob.setupTask(context) | ||||
|             } | ||||
|             if (oldVersion < 44) { | ||||
|                 // Reset sorting preference if using removed sort by source | ||||
|                 if (preferences.librarySortingMode().get() == LibrarySort.SOURCE) { | ||||
|                     preferences.librarySortingMode().set(LibrarySort.ALPHA) | ||||
|                 } | ||||
|             } | ||||
|             return true | ||||
|         } | ||||
|         return false | ||||
|     } | ||||
|  | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -1,23 +1,10 @@ | ||||
| package eu.kanade.tachiyomi.data.backup | ||||
| import eu.kanade.tachiyomi.BuildConfig.APPLICATION_ID as ID | ||||
|  | ||||
| import eu.kanade.tachiyomi.BuildConfig.APPLICATION_ID as ID | ||||
|  | ||||
| object BackupConst { | ||||
|  | ||||
|     const val INTENT_FILTER = "SettingsBackupFragment" | ||||
|     const val ACTION_BACKUP_COMPLETED_DIALOG = "$ID.$INTENT_FILTER.ACTION_BACKUP_COMPLETED_DIALOG" | ||||
|     const val ACTION_SET_PROGRESS_DIALOG = "$ID.$INTENT_FILTER.ACTION_SET_PROGRESS_DIALOG" | ||||
|     const val ACTION_ERROR_BACKUP_DIALOG = "$ID.$INTENT_FILTER.ACTION_ERROR_BACKUP_DIALOG" | ||||
|     const val ACTION_ERROR_RESTORE_DIALOG = "$ID.$INTENT_FILTER.ACTION_ERROR_RESTORE_DIALOG" | ||||
|     const val ACTION_RESTORE_COMPLETED_DIALOG = "$ID.$INTENT_FILTER.ACTION_RESTORE_COMPLETED_DIALOG" | ||||
|     const val ACTION = "$ID.$INTENT_FILTER.ACTION" | ||||
|     const val EXTRA_PROGRESS = "$ID.$INTENT_FILTER.EXTRA_PROGRESS" | ||||
|     const val EXTRA_AMOUNT = "$ID.$INTENT_FILTER.EXTRA_AMOUNT" | ||||
|     const val EXTRA_ERRORS = "$ID.$INTENT_FILTER.EXTRA_ERRORS" | ||||
|     const val EXTRA_CONTENT = "$ID.$INTENT_FILTER.EXTRA_CONTENT" | ||||
|     const val EXTRA_ERROR_MESSAGE = "$ID.$INTENT_FILTER.EXTRA_ERROR_MESSAGE" | ||||
|     const val EXTRA_URI = "$ID.$INTENT_FILTER.EXTRA_URI" | ||||
|     const val EXTRA_TIME = "$ID.$INTENT_FILTER.EXTRA_TIME" | ||||
|     const val EXTRA_ERROR_FILE_PATH = "$ID.$INTENT_FILTER.EXTRA_ERROR_FILE_PATH" | ||||
|     const val EXTRA_ERROR_FILE = "$ID.$INTENT_FILTER.EXTRA_ERROR_FILE" | ||||
| } | ||||
|     private const val NAME = "BackupRestoreServices" | ||||
|     const val EXTRA_URI = "$ID.$NAME.EXTRA_URI" | ||||
|     const val EXTRA_FLAGS = "$ID.$NAME.EXTRA_FLAGS" | ||||
| } | ||||
|   | ||||
| @@ -1,25 +1,22 @@ | ||||
| package eu.kanade.tachiyomi.data.backup | ||||
|  | ||||
| import android.app.IntentService | ||||
| import android.app.Service | ||||
| import android.content.Context | ||||
| import android.content.Intent | ||||
| import android.net.Uri | ||||
| import com.google.gson.JsonArray | ||||
| import eu.kanade.tachiyomi.data.database.models.Manga | ||||
| import eu.kanade.tachiyomi.BuildConfig.APPLICATION_ID as ID | ||||
| import android.os.Build | ||||
| import android.os.IBinder | ||||
| import android.os.PowerManager | ||||
| import com.hippo.unifile.UniFile | ||||
| import eu.kanade.tachiyomi.data.notification.Notifications | ||||
| import eu.kanade.tachiyomi.util.system.isServiceRunning | ||||
|  | ||||
| /** | ||||
|  * [IntentService] used to backup [Manga] information to [JsonArray] | ||||
|  * Service for backing up library information to a JSON file. | ||||
|  */ | ||||
| class BackupCreateService : IntentService(NAME) { | ||||
| class BackupCreateService : Service() { | ||||
|  | ||||
|     companion object { | ||||
|         // Name of class | ||||
|         private const val NAME = "BackupCreateService" | ||||
|  | ||||
|         // Options for backup | ||||
|         private const val EXTRA_FLAGS = "$ID.$NAME.EXTRA_FLAGS" | ||||
|  | ||||
|         // Filter options | ||||
|         internal const val BACKUP_CATEGORY = 0x1 | ||||
|         internal const val BACKUP_CATEGORY_MASK = 0x1 | ||||
| @@ -31,6 +28,15 @@ class BackupCreateService : IntentService(NAME) { | ||||
|         internal const val BACKUP_TRACK_MASK = 0x8 | ||||
|         internal const val BACKUP_ALL = 0xF | ||||
|  | ||||
|         /** | ||||
|          * Returns the status of the service. | ||||
|          * | ||||
|          * @param context the application context. | ||||
|          * @return true if the service is running, false otherwise. | ||||
|          */ | ||||
|         fun isRunning(context: Context): Boolean = | ||||
|             context.isServiceRunning(BackupCreateService::class.java) | ||||
|  | ||||
|         /** | ||||
|          * Make a backup from library | ||||
|          * | ||||
| @@ -38,26 +44,78 @@ class BackupCreateService : IntentService(NAME) { | ||||
|          * @param uri path of Uri | ||||
|          * @param flags determines what to backup | ||||
|          */ | ||||
|         fun makeBackup(context: Context, uri: Uri, flags: Int) { | ||||
|             val intent = Intent(context, BackupCreateService::class.java).apply { | ||||
|                 putExtra(BackupConst.EXTRA_URI, uri) | ||||
|                 putExtra(EXTRA_FLAGS, flags) | ||||
|         fun start(context: Context, uri: Uri, flags: Int) { | ||||
|             if (!isRunning(context)) { | ||||
|                 val intent = Intent(context, BackupCreateService::class.java).apply { | ||||
|                     putExtra(BackupConst.EXTRA_URI, uri) | ||||
|                     putExtra(BackupConst.EXTRA_FLAGS, flags) | ||||
|                 } | ||||
|                 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { | ||||
|                     context.startService(intent) | ||||
|                 } else { | ||||
|                     context.startForegroundService(intent) | ||||
|                 } | ||||
|             } | ||||
|             context.startService(intent) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Wake lock that will be held until the service is destroyed. | ||||
|      */ | ||||
|     private lateinit var wakeLock: PowerManager.WakeLock | ||||
|  | ||||
|     private lateinit var backupManager: BackupManager | ||||
|     private lateinit var notifier: BackupNotifier | ||||
|  | ||||
|     override fun onCreate() { | ||||
|         super.onCreate() | ||||
|         notifier = BackupNotifier(this) | ||||
|  | ||||
|         startForeground(Notifications.ID_BACKUP_PROGRESS, notifier.showBackupProgress().build()) | ||||
|  | ||||
|         wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).newWakeLock( | ||||
|             PowerManager.PARTIAL_WAKE_LOCK, "${javaClass.name}:WakeLock" | ||||
|         ) | ||||
|         wakeLock.acquire() | ||||
|     } | ||||
|  | ||||
|     override fun stopService(name: Intent?): Boolean { | ||||
|         destroyJob() | ||||
|         return super.stopService(name) | ||||
|     } | ||||
|  | ||||
|     override fun onDestroy() { | ||||
|         destroyJob() | ||||
|         super.onDestroy() | ||||
|     } | ||||
|  | ||||
|     private fun destroyJob() { | ||||
|         if (wakeLock.isHeld) { | ||||
|             wakeLock.release() | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * This method needs to be implemented, but it's not used/needed. | ||||
|      */ | ||||
|     override fun onBind(intent: Intent): IBinder? = null | ||||
|  | ||||
|     override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { | ||||
|         if (intent == null) return START_NOT_STICKY | ||||
|  | ||||
|         try { | ||||
|             val uri = intent.getParcelableExtra<Uri>(BackupConst.EXTRA_URI) | ||||
|             val backupFlags = intent.getIntExtra(BackupConst.EXTRA_FLAGS, 0) | ||||
|             backupManager = BackupManager(this) | ||||
|  | ||||
|             val backupFileUri = Uri.parse(backupManager.createBackup(uri, backupFlags, false)) | ||||
|             val unifile = UniFile.fromUri(this, backupFileUri) | ||||
|             notifier.showBackupComplete(unifile) | ||||
|         } catch (e: Exception) { | ||||
|             notifier.showBackupError(e.message) | ||||
|         } | ||||
|  | ||||
|         stopSelf(startId) | ||||
|         return START_NOT_STICKY | ||||
|     } | ||||
|  | ||||
|     private val backupManager by lazy { BackupManager(this) } | ||||
|  | ||||
|     override fun onHandleIntent(intent: Intent?) { | ||||
|         if (intent == null) return | ||||
|  | ||||
|         // Get values | ||||
|         val uri = intent.getParcelableExtra<Uri>(BackupConst.EXTRA_URI) | ||||
|         val flags = intent.getIntExtra(EXTRA_FLAGS, 0) | ||||
|         // Create backup | ||||
|         backupManager.createBackup(uri, flags, false) | ||||
|     } | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -1,42 +1,51 @@ | ||||
| package eu.kanade.tachiyomi.data.backup | ||||
|  | ||||
| import android.content.Context | ||||
| import android.net.Uri | ||||
| import com.evernote.android.job.Job | ||||
| import com.evernote.android.job.JobManager | ||||
| import com.evernote.android.job.JobRequest | ||||
| import androidx.work.ExistingPeriodicWorkPolicy | ||||
| import androidx.work.PeriodicWorkRequestBuilder | ||||
| import androidx.work.WorkManager | ||||
| import androidx.work.Worker | ||||
| import androidx.work.WorkerParameters | ||||
| import eu.kanade.tachiyomi.data.preference.PreferencesHelper | ||||
| import eu.kanade.tachiyomi.data.preference.getOrDefault | ||||
| import java.util.concurrent.TimeUnit | ||||
| import uy.kohesive.injekt.Injekt | ||||
| import uy.kohesive.injekt.api.get | ||||
|  | ||||
| class BackupCreatorJob : Job() { | ||||
| class BackupCreatorJob(private val context: Context, workerParams: WorkerParameters) : | ||||
|     Worker(context, workerParams) { | ||||
|  | ||||
|     override fun onRunJob(params: Params): Result { | ||||
|     override fun doWork(): Result { | ||||
|         val preferences = Injekt.get<PreferencesHelper>() | ||||
|         val backupManager = BackupManager(context) | ||||
|         val uri = Uri.parse(preferences.backupsDirectory().getOrDefault()) | ||||
|         val uri = Uri.parse(preferences.backupsDirectory().get()) | ||||
|         val flags = BackupCreateService.BACKUP_ALL | ||||
|         backupManager.createBackup(uri, flags, true) | ||||
|         return Result.SUCCESS | ||||
|         return try { | ||||
|             backupManager.createBackup(uri, flags, true) | ||||
|             Result.success() | ||||
|         } catch (e: Exception) { | ||||
|             Result.failure() | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     companion object { | ||||
|         const val TAG = "BackupCreator" | ||||
|         private const val TAG = "BackupCreator" | ||||
|  | ||||
|         fun setupTask(prefInterval: Int? = null) { | ||||
|         fun setupTask(context: Context, prefInterval: Int? = null) { | ||||
|             val preferences = Injekt.get<PreferencesHelper>() | ||||
|             val interval = prefInterval ?: preferences.backupInterval().getOrDefault() | ||||
|             val interval = prefInterval ?: preferences.backupInterval().get() | ||||
|             if (interval > 0) { | ||||
|                 JobRequest.Builder(TAG) | ||||
|                         .setPeriodic(interval * 60 * 60 * 1000L, 10 * 60 * 1000) | ||||
|                         .setUpdateCurrent(true) | ||||
|                         .build() | ||||
|                         .schedule() | ||||
|             } | ||||
|         } | ||||
|                 val request = PeriodicWorkRequestBuilder<BackupCreatorJob>( | ||||
|                     interval.toLong(), TimeUnit.HOURS, | ||||
|                     10, TimeUnit.MINUTES | ||||
|                 ) | ||||
|                     .addTag(TAG) | ||||
|                     .build() | ||||
|  | ||||
|         fun cancelTask() { | ||||
|             JobManager.instance().cancelAllForTag(TAG) | ||||
|                 WorkManager.getInstance(context).enqueueUniquePeriodicWork(TAG, ExistingPeriodicWorkPolicy.REPLACE, request) | ||||
|             } else { | ||||
|                 WorkManager.getInstance(context).cancelAllWorkByTag(TAG) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -1,10 +1,16 @@ | ||||
| package eu.kanade.tachiyomi.data.backup | ||||
|  | ||||
| import android.content.Context | ||||
| import android.content.Intent | ||||
| import android.net.Uri | ||||
| import com.github.salomonbrys.kotson.* | ||||
| import com.google.gson.* | ||||
| import com.github.salomonbrys.kotson.fromJson | ||||
| import com.github.salomonbrys.kotson.registerTypeAdapter | ||||
| import com.github.salomonbrys.kotson.registerTypeHierarchyAdapter | ||||
| import com.github.salomonbrys.kotson.set | ||||
| import com.google.gson.Gson | ||||
| import com.google.gson.GsonBuilder | ||||
| import com.google.gson.JsonArray | ||||
| import com.google.gson.JsonElement | ||||
| import com.google.gson.JsonObject | ||||
| import com.hippo.unifile.UniFile | ||||
| import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_CATEGORY | ||||
| import eu.kanade.tachiyomi.data.backup.BackupCreateService.Companion.BACKUP_CATEGORY_MASK | ||||
| @@ -18,42 +24,42 @@ import eu.kanade.tachiyomi.data.backup.models.Backup | ||||
| import eu.kanade.tachiyomi.data.backup.models.Backup.CATEGORIES | ||||
| import eu.kanade.tachiyomi.data.backup.models.Backup.CHAPTERS | ||||
| import eu.kanade.tachiyomi.data.backup.models.Backup.CURRENT_VERSION | ||||
| import eu.kanade.tachiyomi.data.backup.models.Backup.EXTENSIONS | ||||
| import eu.kanade.tachiyomi.data.backup.models.Backup.HISTORY | ||||
| import eu.kanade.tachiyomi.data.backup.models.Backup.MANGA | ||||
| import eu.kanade.tachiyomi.data.backup.models.Backup.TRACK | ||||
| import eu.kanade.tachiyomi.data.backup.models.DHistory | ||||
| import eu.kanade.tachiyomi.data.backup.serializer.* | ||||
| import eu.kanade.tachiyomi.data.backup.serializer.CategoryTypeAdapter | ||||
| import eu.kanade.tachiyomi.data.backup.serializer.ChapterTypeAdapter | ||||
| import eu.kanade.tachiyomi.data.backup.serializer.HistoryTypeAdapter | ||||
| import eu.kanade.tachiyomi.data.backup.serializer.MangaTypeAdapter | ||||
| import eu.kanade.tachiyomi.data.backup.serializer.TrackTypeAdapter | ||||
| import eu.kanade.tachiyomi.data.database.DatabaseHelper | ||||
| import eu.kanade.tachiyomi.data.database.models.* | ||||
| import eu.kanade.tachiyomi.data.database.models.CategoryImpl | ||||
| import eu.kanade.tachiyomi.data.database.models.Chapter | ||||
| import eu.kanade.tachiyomi.data.database.models.ChapterImpl | ||||
| import eu.kanade.tachiyomi.data.database.models.History | ||||
| import eu.kanade.tachiyomi.data.database.models.Manga | ||||
| import eu.kanade.tachiyomi.data.database.models.MangaCategory | ||||
| import eu.kanade.tachiyomi.data.database.models.MangaImpl | ||||
| import eu.kanade.tachiyomi.data.database.models.Track | ||||
| import eu.kanade.tachiyomi.data.database.models.TrackImpl | ||||
| import eu.kanade.tachiyomi.data.preference.PreferencesHelper | ||||
| import eu.kanade.tachiyomi.data.preference.getOrDefault | ||||
| import eu.kanade.tachiyomi.data.track.TrackManager | ||||
| import eu.kanade.tachiyomi.source.Source | ||||
| import eu.kanade.tachiyomi.source.SourceManager | ||||
| import eu.kanade.tachiyomi.source.online.all.EHentai | ||||
| import eu.kanade.tachiyomi.util.sendLocalBroadcast | ||||
| import eu.kanade.tachiyomi.util.syncChaptersWithSource | ||||
| import exh.eh.EHentaiThrottleManager | ||||
| import eu.kanade.tachiyomi.util.chapter.syncChaptersWithSource | ||||
| import kotlin.math.max | ||||
| import rx.Observable | ||||
| import timber.log.Timber | ||||
| import uy.kohesive.injekt.injectLazy | ||||
|  | ||||
| class BackupManager(val context: Context, version: Int = CURRENT_VERSION) { | ||||
|  | ||||
|     /** | ||||
|      * Database. | ||||
|      */ | ||||
|     internal val databaseHelper: DatabaseHelper by injectLazy() | ||||
|  | ||||
|     /** | ||||
|      * Source manager. | ||||
|      */ | ||||
|     internal val sourceManager: SourceManager by injectLazy() | ||||
|  | ||||
|     /** | ||||
|      * Tracking manager | ||||
|      */ | ||||
|     internal val trackManager: TrackManager by injectLazy() | ||||
|     private val preferences: PreferencesHelper by injectLazy() | ||||
|  | ||||
|     /** | ||||
|      * Version of parser | ||||
| @@ -66,11 +72,6 @@ class BackupManager(val context: Context, version: Int = CURRENT_VERSION) { | ||||
|      */ | ||||
|     var parser: Gson = initParser() | ||||
|  | ||||
|     /** | ||||
|      * Preferences | ||||
|      */ | ||||
|     private val preferences: PreferencesHelper by injectLazy() | ||||
|  | ||||
|     /** | ||||
|      * Set version of parser | ||||
|      * | ||||
| @@ -83,7 +84,8 @@ class BackupManager(val context: Context, version: Int = CURRENT_VERSION) { | ||||
|  | ||||
|     private fun initParser(): Gson = when (version) { | ||||
|         1 -> GsonBuilder().create() | ||||
|         2 -> GsonBuilder() | ||||
|         2 -> | ||||
|             GsonBuilder() | ||||
|                 .registerTypeAdapter<MangaImpl>(MangaTypeAdapter.build()) | ||||
|                 .registerTypeHierarchyAdapter<ChapterImpl>(ChapterTypeAdapter.build()) | ||||
|                 .registerTypeAdapter<CategoryImpl>(CategoryTypeAdapter.build()) | ||||
| @@ -99,7 +101,7 @@ class BackupManager(val context: Context, version: Int = CURRENT_VERSION) { | ||||
|      * @param uri path of Uri | ||||
|      * @param isJob backup called from job | ||||
|      */ | ||||
|     fun createBackup(uri: Uri, flags: Int, isJob: Boolean) { | ||||
|     fun createBackup(uri: Uri, flags: Int, isJob: Boolean): String? { | ||||
|         // Create root object | ||||
|         val root = JsonObject() | ||||
|  | ||||
| @@ -109,24 +111,38 @@ class BackupManager(val context: Context, version: Int = CURRENT_VERSION) { | ||||
|         // Create category array | ||||
|         val categoryEntries = JsonArray() | ||||
|  | ||||
|         // Create extension ID/name mapping | ||||
|         val extensionEntries = JsonArray() | ||||
|  | ||||
|         // Add value's to root | ||||
|         root[Backup.VERSION] = Backup.CURRENT_VERSION | ||||
|         root[Backup.VERSION] = CURRENT_VERSION | ||||
|         root[Backup.MANGAS] = mangaEntries | ||||
|         root[CATEGORIES] = categoryEntries | ||||
|         root[EXTENSIONS] = extensionEntries | ||||
|  | ||||
|         databaseHelper.inTransaction { | ||||
|             // Get manga from database | ||||
|             val mangas = getFavoriteManga() | ||||
|  | ||||
|             val extensions: MutableSet<String> = mutableSetOf() | ||||
|  | ||||
|             // Backup library manga and its dependencies | ||||
|             mangas.forEach { manga -> | ||||
|                 mangaEntries.add(backupMangaObject(manga, flags)) | ||||
|  | ||||
|                 // Maintain set of extensions/sources used (excludes local source) | ||||
|                 if (manga.source != 0L && sourceManager.get(manga.source) != null) { | ||||
|                     extensions.add("${manga.source}:${sourceManager.get(manga.source)!!.name}") | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             // Backup categories | ||||
|             if ((flags and BACKUP_CATEGORY_MASK) == BACKUP_CATEGORY) { | ||||
|                 backupCategories(categoryEntries) | ||||
|             } | ||||
|  | ||||
|             // Backup extension ID/name mapping | ||||
|             backupExtensionInfo(extensionEntries, extensions) | ||||
|         } | ||||
|  | ||||
|         try { | ||||
| @@ -140,42 +156,38 @@ class BackupManager(val context: Context, version: Int = CURRENT_VERSION) { | ||||
|                 val numberOfBackups = numberOfBackups() | ||||
|                 val backupRegex = Regex("""tachiyomi_\d+-\d+-\d+_\d+-\d+.json""") | ||||
|                 dir.listFiles { _, filename -> backupRegex.matches(filename) } | ||||
|                         .orEmpty() | ||||
|                         .sortedByDescending { it.name } | ||||
|                         .drop(numberOfBackups - 1) | ||||
|                         .forEach { it.delete() } | ||||
|                     .orEmpty() | ||||
|                     .sortedByDescending { it.name } | ||||
|                     .drop(numberOfBackups - 1) | ||||
|                     .forEach { it.delete() } | ||||
|  | ||||
|                 // Create new file to place backup | ||||
|                 val newFile = dir.createFile(Backup.getDefaultFilename()) | ||||
|                         ?: throw Exception("Couldn't create backup file") | ||||
|                     ?: throw Exception("Couldn't create backup file") | ||||
|  | ||||
|                 newFile.openOutputStream().bufferedWriter().use { | ||||
|                     parser.toJson(root, it) | ||||
|                 } | ||||
|  | ||||
|                 return newFile.uri.toString() | ||||
|             } else { | ||||
|                 val file = UniFile.fromUri(context, uri) | ||||
|                         ?: throw Exception("Couldn't create backup file") | ||||
|                     ?: throw Exception("Couldn't create backup file") | ||||
|                 file.openOutputStream().bufferedWriter().use { | ||||
|                     parser.toJson(root, it) | ||||
|                 } | ||||
|  | ||||
|                 // Show completed dialog | ||||
|                 val intent = Intent(BackupConst.INTENT_FILTER).apply { | ||||
|                     putExtra(BackupConst.ACTION, BackupConst.ACTION_BACKUP_COMPLETED_DIALOG) | ||||
|                     putExtra(BackupConst.EXTRA_URI, file.uri.toString()) | ||||
|                 } | ||||
|                 context.sendLocalBroadcast(intent) | ||||
|                 return file.uri.toString() | ||||
|             } | ||||
|         } catch (e: Exception) { | ||||
|             Timber.e(e) | ||||
|             if (!isJob) { | ||||
|                 // Show error dialog | ||||
|                 val intent = Intent(BackupConst.INTENT_FILTER).apply { | ||||
|                     putExtra(BackupConst.ACTION, BackupConst.ACTION_ERROR_BACKUP_DIALOG) | ||||
|                     putExtra(BackupConst.EXTRA_ERROR_MESSAGE, e.message) | ||||
|                 } | ||||
|                 context.sendLocalBroadcast(intent) | ||||
|             } | ||||
|             throw e | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private fun backupExtensionInfo(root: JsonArray, extensions: Set<String>) { | ||||
|         extensions.sorted().forEach { | ||||
|             root.add(it) | ||||
|         } | ||||
|     } | ||||
|  | ||||
| @@ -206,7 +218,7 @@ class BackupManager(val context: Context, version: Int = CURRENT_VERSION) { | ||||
|         if (options and BACKUP_CHAPTER_MASK == BACKUP_CHAPTER) { | ||||
|             // Backup all the chapters | ||||
|             val chapters = databaseHelper.getChapters(manga).executeAsBlocking() | ||||
|             if (!chapters.isEmpty()) { | ||||
|             if (chapters.isNotEmpty()) { | ||||
|                 val chaptersJson = parser.toJsonTree(chapters) | ||||
|                 if (chaptersJson.asJsonArray.size() > 0) { | ||||
|                     entry[CHAPTERS] = chaptersJson | ||||
| @@ -218,7 +230,7 @@ class BackupManager(val context: Context, version: Int = CURRENT_VERSION) { | ||||
|         if (options and BACKUP_CATEGORY_MASK == BACKUP_CATEGORY) { | ||||
|             // Backup categories for this manga | ||||
|             val categoriesForManga = databaseHelper.getCategoriesForManga(manga).executeAsBlocking() | ||||
|             if (!categoriesForManga.isEmpty()) { | ||||
|             if (categoriesForManga.isNotEmpty()) { | ||||
|                 val categoriesNames = categoriesForManga.map { it.name } | ||||
|                 entry[CATEGORIES] = parser.toJsonTree(categoriesNames) | ||||
|             } | ||||
| @@ -227,7 +239,7 @@ class BackupManager(val context: Context, version: Int = CURRENT_VERSION) { | ||||
|         // Check if user wants track information in backup | ||||
|         if (options and BACKUP_TRACK_MASK == BACKUP_TRACK) { | ||||
|             val tracks = databaseHelper.getTracks(manga).executeAsBlocking() | ||||
|             if (!tracks.isEmpty()) { | ||||
|             if (tracks.isNotEmpty()) { | ||||
|                 entry[TRACK] = parser.toJsonTree(tracks) | ||||
|             } | ||||
|         } | ||||
| @@ -235,7 +247,7 @@ class BackupManager(val context: Context, version: Int = CURRENT_VERSION) { | ||||
|         // Check if user wants history information in backup | ||||
|         if (options and BACKUP_HISTORY_MASK == BACKUP_HISTORY) { | ||||
|             val historyForManga = databaseHelper.getHistoryByMangaId(manga.id!!).executeAsBlocking() | ||||
|             if (!historyForManga.isEmpty()) { | ||||
|             if (historyForManga.isNotEmpty()) { | ||||
|                 val historyData = historyForManga.mapNotNull { history -> | ||||
|                     val url = databaseHelper.getChapter(history.chapter_id).executeAsBlocking()?.url | ||||
|                     url?.let { DHistory(url, history.last_read) } | ||||
| @@ -266,13 +278,13 @@ class BackupManager(val context: Context, version: Int = CURRENT_VERSION) { | ||||
|      */ | ||||
|     fun restoreMangaFetchObservable(source: Source, manga: Manga): Observable<Manga> { | ||||
|         return source.fetchMangaDetails(manga) | ||||
|                 .map { networkManga -> | ||||
|                     manga.copyFrom(networkManga) | ||||
|                     manga.favorite = true | ||||
|                     manga.initialized = true | ||||
|                     manga.id = insertManga(manga) | ||||
|                     manga | ||||
|                 } | ||||
|             .map { networkManga -> | ||||
|                 manga.copyFrom(networkManga) | ||||
|                 manga.favorite = true | ||||
|                 manga.initialized = true | ||||
|                 manga.id = insertManga(manga) | ||||
|                 manga | ||||
|             } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @@ -282,18 +294,18 @@ class BackupManager(val context: Context, version: Int = CURRENT_VERSION) { | ||||
|      * @param manga manga that needs updating | ||||
|      * @return [Observable] that contains manga | ||||
|      */ | ||||
|     fun restoreChapterFetchObservable(source: Source, manga: Manga, chapters: List<Chapter>, throttleManager: EHentaiThrottleManager): Observable<Pair<List<Chapter>, List<Chapter>>> { | ||||
|     fun restoreChapterFetchObservable(source: Source, manga: Manga, chapters: List<Chapter>): Observable<Pair<List<Chapter>, List<Chapter>>> { | ||||
|         return (if(source is EHentai) { | ||||
|             source.fetchChapterList(manga, throttleManager::throttle) | ||||
|         } else { | ||||
|             source.fetchChapterList(manga) | ||||
|         }).map { syncChaptersWithSource(databaseHelper, it, manga, source) } | ||||
|                 .doOnNext { | ||||
|                     if (it.first.isNotEmpty()) { | ||||
|                         chapters.forEach { it.manga_id = manga.id } | ||||
|                         insertChapters(chapters) | ||||
|                     } | ||||
|             .map { syncChaptersWithSource(databaseHelper, it, manga, source) } | ||||
|             .doOnNext { pair -> | ||||
|                 if (pair.first.isNotEmpty()) { | ||||
|                     chapters.forEach { it.manga_id = manga.id } | ||||
|                     insertChapters(chapters) | ||||
|                 } | ||||
|             } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @@ -349,7 +361,7 @@ class BackupManager(val context: Context, version: Int = CURRENT_VERSION) { | ||||
|         } | ||||
|  | ||||
|         // Update database | ||||
|         if (!mangaCategoriesToUpdate.isEmpty()) { | ||||
|         if (mangaCategoriesToUpdate.isNotEmpty()) { | ||||
|             val mangaAsList = ArrayList<Manga>() | ||||
|             mangaAsList.add(manga) | ||||
|             databaseHelper.deleteOldMangasCategories(mangaAsList).executeAsBlocking() | ||||
| @@ -370,7 +382,7 @@ class BackupManager(val context: Context, version: Int = CURRENT_VERSION) { | ||||
|             // Check if history already in database and update | ||||
|             if (dbHistory != null) { | ||||
|                 dbHistory.apply { | ||||
|                     last_read = Math.max(lastRead, dbHistory.last_read) | ||||
|                     last_read = max(lastRead, dbHistory.last_read) | ||||
|                 } | ||||
|                 historyToBeUpdated.add(dbHistory) | ||||
|             } else { | ||||
| @@ -413,7 +425,7 @@ class BackupManager(val context: Context, version: Int = CURRENT_VERSION) { | ||||
|                         if (track.library_id != dbTrack.library_id) { | ||||
|                             dbTrack.library_id = track.library_id | ||||
|                         } | ||||
|                         dbTrack.last_chapter_read = Math.max(dbTrack.last_chapter_read, track.last_chapter_read) | ||||
|                         dbTrack.last_chapter_read = max(dbTrack.last_chapter_read, track.last_chapter_read) | ||||
|                         isInDatabase = true | ||||
|                         trackToUpdate.add(dbTrack) | ||||
|                         break | ||||
| @@ -427,7 +439,7 @@ class BackupManager(val context: Context, version: Int = CURRENT_VERSION) { | ||||
|             } | ||||
|         } | ||||
|         // Update database | ||||
|         if (!trackToUpdate.isEmpty()) { | ||||
|         if (trackToUpdate.isNotEmpty()) { | ||||
|             databaseHelper.insertTracks(trackToUpdate).executeAsBlocking() | ||||
|         } | ||||
|     } | ||||
| @@ -443,8 +455,9 @@ class BackupManager(val context: Context, version: Int = CURRENT_VERSION) { | ||||
|         val dbChapters = databaseHelper.getChapters(manga).executeAsBlocking() | ||||
|  | ||||
|         // Return if fetch is needed | ||||
|         if (dbChapters.isEmpty() || dbChapters.size < chapters.size) | ||||
|         if (dbChapters.isEmpty() || dbChapters.size < chapters.size) { | ||||
|             return false | ||||
|         } | ||||
|  | ||||
|         for (chapter in chapters) { | ||||
|             val pos = dbChapters.indexOf(chapter) | ||||
| @@ -469,7 +482,7 @@ class BackupManager(val context: Context, version: Int = CURRENT_VERSION) { | ||||
|      * @return [Manga], null if not found | ||||
|      */ | ||||
|     internal fun getMangaFromDatabase(manga: Manga): Manga? = | ||||
|             databaseHelper.getManga(manga.url, manga.source).executeAsBlocking() | ||||
|         databaseHelper.getManga(manga.url, manga.source).executeAsBlocking() | ||||
|  | ||||
|     /** | ||||
|      * Returns list containing manga from library | ||||
| @@ -477,7 +490,7 @@ class BackupManager(val context: Context, version: Int = CURRENT_VERSION) { | ||||
|      * @return [Manga] from library | ||||
|      */ | ||||
|     internal fun getFavoriteManga(): List<Manga> = | ||||
|             databaseHelper.getFavoriteMangas().executeAsBlocking() | ||||
|         databaseHelper.getFavoriteMangas().executeAsBlocking() | ||||
|  | ||||
|     /** | ||||
|      * Inserts manga and returns id | ||||
| @@ -485,7 +498,7 @@ class BackupManager(val context: Context, version: Int = CURRENT_VERSION) { | ||||
|      * @return id of [Manga], null if not found | ||||
|      */ | ||||
|     internal fun insertManga(manga: Manga): Long? = | ||||
|             databaseHelper.insertManga(manga).executeAsBlocking().insertedId() | ||||
|         databaseHelper.insertManga(manga).executeAsBlocking().insertedId() | ||||
|  | ||||
|     /** | ||||
|      * Inserts list of chapters | ||||
| @@ -499,5 +512,5 @@ class BackupManager(val context: Context, version: Int = CURRENT_VERSION) { | ||||
|      * | ||||
|      * @return number of backups selected by user | ||||
|      */ | ||||
|     fun numberOfBackups(): Int = preferences.numberOfBackups().getOrDefault() | ||||
|     fun numberOfBackups(): Int = preferences.numberOfBackups().get() | ||||
| } | ||||
|   | ||||
| @@ -0,0 +1,159 @@ | ||||
| package eu.kanade.tachiyomi.data.backup | ||||
|  | ||||
| import android.content.Context | ||||
| import android.graphics.BitmapFactory | ||||
| import androidx.core.app.NotificationCompat | ||||
| import com.hippo.unifile.UniFile | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.notification.NotificationReceiver | ||||
| import eu.kanade.tachiyomi.data.notification.Notifications | ||||
| import eu.kanade.tachiyomi.data.preference.PreferencesHelper | ||||
| import eu.kanade.tachiyomi.util.storage.getUriCompat | ||||
| import eu.kanade.tachiyomi.util.system.notificationBuilder | ||||
| import eu.kanade.tachiyomi.util.system.notificationManager | ||||
| import java.io.File | ||||
| import java.util.concurrent.TimeUnit | ||||
| import uy.kohesive.injekt.injectLazy | ||||
|  | ||||
| internal class BackupNotifier(private val context: Context) { | ||||
|  | ||||
|     private val preferences: PreferencesHelper by injectLazy() | ||||
|  | ||||
|     private val progressNotificationBuilder = context.notificationBuilder(Notifications.CHANNEL_BACKUP_RESTORE_PROGRESS) { | ||||
|         setLargeIcon(BitmapFactory.decodeResource(context.resources, R.mipmap.ic_launcher)) | ||||
|         setSmallIcon(R.drawable.ic_tachi) | ||||
|         setAutoCancel(false) | ||||
|         setOngoing(true) | ||||
|     } | ||||
|  | ||||
|     private val completeNotificationBuilder = context.notificationBuilder(Notifications.CHANNEL_BACKUP_RESTORE_COMPLETE) { | ||||
|         setLargeIcon(BitmapFactory.decodeResource(context.resources, R.mipmap.ic_launcher)) | ||||
|         setSmallIcon(R.drawable.ic_tachi) | ||||
|         setAutoCancel(false) | ||||
|     } | ||||
|  | ||||
|     private fun NotificationCompat.Builder.show(id: Int) { | ||||
|         context.notificationManager.notify(id, build()) | ||||
|     } | ||||
|  | ||||
|     fun showBackupProgress(): NotificationCompat.Builder { | ||||
|         val builder = with(progressNotificationBuilder) { | ||||
|             setContentTitle(context.getString(R.string.creating_backup)) | ||||
|  | ||||
|             setProgress(0, 0, true) | ||||
|         } | ||||
|  | ||||
|         builder.show(Notifications.ID_BACKUP_PROGRESS) | ||||
|  | ||||
|         return builder | ||||
|     } | ||||
|  | ||||
|     fun showBackupError(error: String?) { | ||||
|         context.notificationManager.cancel(Notifications.ID_BACKUP_PROGRESS) | ||||
|  | ||||
|         with(completeNotificationBuilder) { | ||||
|             setContentTitle(context.getString(R.string.creating_backup_error)) | ||||
|             setContentText(error) | ||||
|  | ||||
|             show(Notifications.ID_BACKUP_COMPLETE) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fun showBackupComplete(unifile: UniFile) { | ||||
|         context.notificationManager.cancel(Notifications.ID_BACKUP_PROGRESS) | ||||
|  | ||||
|         with(completeNotificationBuilder) { | ||||
|             setContentTitle(context.getString(R.string.backup_created)) | ||||
|  | ||||
|             if (unifile.filePath != null) { | ||||
|                 setContentText(unifile.filePath) | ||||
|             } | ||||
|  | ||||
|             // Clear old actions if they exist | ||||
|             if (mActions.isNotEmpty()) { | ||||
|                 mActions.clear() | ||||
|             } | ||||
|  | ||||
|             addAction( | ||||
|                 R.drawable.ic_share_24dp, | ||||
|                 context.getString(R.string.action_share), | ||||
|                 NotificationReceiver.shareBackupPendingBroadcast(context, unifile.uri, Notifications.ID_BACKUP_COMPLETE) | ||||
|             ) | ||||
|  | ||||
|             show(Notifications.ID_BACKUP_COMPLETE) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fun showRestoreProgress(content: String = "", progress: Int = 0, maxAmount: Int = 100): NotificationCompat.Builder { | ||||
|         val builder = with(progressNotificationBuilder) { | ||||
|             setContentTitle(context.getString(R.string.restoring_backup)) | ||||
|  | ||||
|             if (!preferences.hideNotificationContent()) { | ||||
|                 setContentText(content) | ||||
|             } | ||||
|  | ||||
|             setProgress(maxAmount, progress, false) | ||||
|  | ||||
|             // Clear old actions if they exist | ||||
|             if (mActions.isNotEmpty()) { | ||||
|                 mActions.clear() | ||||
|             } | ||||
|  | ||||
|             addAction( | ||||
|                 R.drawable.ic_close_24dp, | ||||
|                 context.getString(R.string.action_stop), | ||||
|                 NotificationReceiver.cancelRestorePendingBroadcast(context, Notifications.ID_RESTORE_PROGRESS) | ||||
|             ) | ||||
|         } | ||||
|  | ||||
|         builder.show(Notifications.ID_RESTORE_PROGRESS) | ||||
|  | ||||
|         return builder | ||||
|     } | ||||
|  | ||||
|     fun showRestoreError(error: String?) { | ||||
|         context.notificationManager.cancel(Notifications.ID_RESTORE_PROGRESS) | ||||
|  | ||||
|         with(completeNotificationBuilder) { | ||||
|             setContentTitle(context.getString(R.string.restoring_backup_error)) | ||||
|             setContentText(error) | ||||
|  | ||||
|             show(Notifications.ID_RESTORE_COMPLETE) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fun showRestoreComplete(time: Long, errorCount: Int, path: String?, file: String?) { | ||||
|         context.notificationManager.cancel(Notifications.ID_RESTORE_PROGRESS) | ||||
|  | ||||
|         val timeString = context.getString( | ||||
|             R.string.restore_duration, | ||||
|             TimeUnit.MILLISECONDS.toMinutes(time), | ||||
|             TimeUnit.MILLISECONDS.toSeconds(time) - TimeUnit.MINUTES.toSeconds( | ||||
|                 TimeUnit.MILLISECONDS.toMinutes(time) | ||||
|             ) | ||||
|         ) | ||||
|  | ||||
|         with(completeNotificationBuilder) { | ||||
|             setContentTitle(context.getString(R.string.restore_completed)) | ||||
|             setContentText(context.getString(R.string.restore_completed_content, timeString, errorCount)) | ||||
|  | ||||
|             // Clear old actions if they exist | ||||
|             if (mActions.isNotEmpty()) { | ||||
|                 mActions.clear() | ||||
|             } | ||||
|  | ||||
|             if (errorCount > 0 && !path.isNullOrEmpty() && !file.isNullOrEmpty()) { | ||||
|                 val destFile = File(path, file) | ||||
|                 val uri = destFile.getUriCompat(context) | ||||
|  | ||||
|                 addAction( | ||||
|                     R.drawable.nnf_ic_file_folder, | ||||
|                     context.getString(R.string.action_open_log), | ||||
|                     NotificationReceiver.openErrorLogPendingActivity(context, uri) | ||||
|                 ) | ||||
|             } | ||||
|  | ||||
|             show(Notifications.ID_RESTORE_COMPLETE) | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -10,6 +10,8 @@ import android.os.PowerManager | ||||
| import com.elvishew.xlog.XLog | ||||
| import com.github.salomonbrys.kotson.fromJson | ||||
| import com.google.gson.JsonArray | ||||
| import com.google.gson.JsonElement | ||||
| import com.google.gson.JsonObject | ||||
| import com.google.gson.JsonParser | ||||
| import com.google.gson.stream.JsonReader | ||||
| import eu.kanade.tachiyomi.R | ||||
| @@ -22,12 +24,16 @@ import eu.kanade.tachiyomi.data.backup.models.Backup.TRACK | ||||
| import eu.kanade.tachiyomi.data.backup.models.Backup.VERSION | ||||
| import eu.kanade.tachiyomi.data.backup.models.DHistory | ||||
| import eu.kanade.tachiyomi.data.database.DatabaseHelper | ||||
| import eu.kanade.tachiyomi.data.database.models.* | ||||
| import eu.kanade.tachiyomi.data.database.models.Chapter | ||||
| import eu.kanade.tachiyomi.data.database.models.ChapterImpl | ||||
| import eu.kanade.tachiyomi.data.database.models.Manga | ||||
| import eu.kanade.tachiyomi.data.database.models.MangaImpl | ||||
| import eu.kanade.tachiyomi.data.database.models.Track | ||||
| import eu.kanade.tachiyomi.data.database.models.TrackImpl | ||||
| import eu.kanade.tachiyomi.data.notification.Notifications | ||||
| import eu.kanade.tachiyomi.data.track.TrackManager | ||||
| import eu.kanade.tachiyomi.source.Source | ||||
| import eu.kanade.tachiyomi.util.chop | ||||
| import eu.kanade.tachiyomi.util.isServiceRunning | ||||
| import eu.kanade.tachiyomi.util.sendLocalBroadcast | ||||
| import eu.kanade.tachiyomi.util.system.isServiceRunning | ||||
| import exh.BackupEntry | ||||
| import exh.EH_SOURCE_ID | ||||
| import exh.EXHMigrations | ||||
| @@ -42,11 +48,16 @@ import java.io.File | ||||
| import java.text.SimpleDateFormat | ||||
| import java.util.Date | ||||
| import java.util.Locale | ||||
| import java.util.concurrent.ExecutorService | ||||
| import java.util.concurrent.Executors | ||||
| import kotlinx.coroutines.CoroutineExceptionHandler | ||||
| import kotlinx.coroutines.GlobalScope | ||||
| import kotlinx.coroutines.Job | ||||
| import kotlinx.coroutines.launch | ||||
| import rx.Observable | ||||
| import timber.log.Timber | ||||
| import uy.kohesive.injekt.injectLazy | ||||
|  | ||||
| /** | ||||
|  * Restores backup from json file | ||||
|  * Restores backup from a JSON file. | ||||
|  */ | ||||
| class BackupRestoreService : Service() { | ||||
|  | ||||
| @@ -58,8 +69,8 @@ class BackupRestoreService : Service() { | ||||
|          * @param context the application context. | ||||
|          * @return true if the service is running, false otherwise. | ||||
|          */ | ||||
|         private fun isRunning(context: Context): Boolean = | ||||
|                 context.isServiceRunning(BackupRestoreService::class.java) | ||||
|         fun isRunning(context: Context): Boolean = | ||||
|             context.isServiceRunning(BackupRestoreService::class.java) | ||||
|  | ||||
|         /** | ||||
|          * Starts a service to restore a backup from Json | ||||
| @@ -72,7 +83,11 @@ class BackupRestoreService : Service() { | ||||
|                 val intent = Intent(context, BackupRestoreService::class.java).apply { | ||||
|                     putExtra(BackupConst.EXTRA_URI, uri) | ||||
|                 } | ||||
|                 context.startService(intent) | ||||
|                 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { | ||||
|                     context.startService(intent) | ||||
|                 } else { | ||||
|                     context.startForegroundService(intent) | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
| @@ -83,6 +98,8 @@ class BackupRestoreService : Service() { | ||||
|          */ | ||||
|         fun stop(context: Context) { | ||||
|             context.stopService(Intent(context, BackupRestoreService::class.java)) | ||||
|  | ||||
|             BackupNotifier(context).showRestoreError(context.getString(R.string.restoring_backup_canceled)) | ||||
|         } | ||||
|     } | ||||
|  | ||||
| @@ -91,10 +108,7 @@ class BackupRestoreService : Service() { | ||||
|      */ | ||||
|     private lateinit var wakeLock: PowerManager.WakeLock | ||||
|  | ||||
|     /** | ||||
|      * Subscription where the update is done. | ||||
|      */ | ||||
|     private var subscription: Subscription? = null | ||||
|     private var job: Job? = null | ||||
|  | ||||
|     /** | ||||
|      * The progress of a backup restore | ||||
| @@ -111,20 +125,12 @@ class BackupRestoreService : Service() { | ||||
|      */ | ||||
|     private val errors = mutableListOf<Pair<Date, String>>() | ||||
|  | ||||
|     /** | ||||
|      * Backup manager | ||||
|      */ | ||||
|     private lateinit var backupManager: BackupManager | ||||
|     private lateinit var notifier: BackupNotifier | ||||
|  | ||||
|     /** | ||||
|      * Database | ||||
|      */ | ||||
|     private val db: DatabaseHelper by injectLazy() | ||||
|  | ||||
|     /** | ||||
|      * Tracking manager | ||||
|      */ | ||||
|     internal val trackManager: TrackManager by injectLazy() | ||||
|     private val trackManager: TrackManager by injectLazy() | ||||
|  | ||||
|  | ||||
|     private lateinit var executor: ExecutorService | ||||
| @@ -136,23 +142,31 @@ class BackupRestoreService : Service() { | ||||
|      */ | ||||
|     override fun onCreate() { | ||||
|         super.onCreate() | ||||
|         notifier = BackupNotifier(this) | ||||
|  | ||||
|         startForeground(Notifications.ID_RESTORE_PROGRESS, notifier.showRestoreProgress().build()) | ||||
|  | ||||
|         wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).newWakeLock( | ||||
|                 PowerManager.PARTIAL_WAKE_LOCK, "BackupRestoreService:WakeLock") | ||||
|             PowerManager.PARTIAL_WAKE_LOCK, "${javaClass.name}:WakeLock" | ||||
|         ) | ||||
|         wakeLock.acquire() | ||||
|         executor = Executors.newSingleThreadExecutor() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Method called when the service is destroyed. It destroys the running subscription and | ||||
|      * releases the wake lock. | ||||
|      */ | ||||
|     override fun stopService(name: Intent?): Boolean { | ||||
|         destroyJob() | ||||
|         return super.stopService(name) | ||||
|     } | ||||
|  | ||||
|     override fun onDestroy() { | ||||
|         subscription?.unsubscribe() | ||||
|         executor.shutdown() // must be called after unsubscribe | ||||
|         destroyJob() | ||||
|         super.onDestroy() | ||||
|     } | ||||
|  | ||||
|     private fun destroyJob() { | ||||
|         job?.cancel() | ||||
|         if (wakeLock.isHeld) { | ||||
|             wakeLock.release() | ||||
|         } | ||||
|         super.onDestroy() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @@ -169,157 +183,109 @@ class BackupRestoreService : Service() { | ||||
|      * @return the start value of the command. | ||||
|      */ | ||||
|     override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { | ||||
|         if (intent == null) return Service.START_NOT_STICKY | ||||
|         val uri = intent?.getParcelableExtra<Uri>(BackupConst.EXTRA_URI) ?: return START_NOT_STICKY | ||||
|  | ||||
|         val uri = intent.getParcelableExtra<Uri>(BackupConst.EXTRA_URI) | ||||
|         // Cancel any previous job if needed. | ||||
|         job?.cancel() | ||||
|         val handler = CoroutineExceptionHandler { _, exception -> | ||||
|             Timber.e(exception) | ||||
|             writeErrorLog() | ||||
|  | ||||
|         throttleManager.resetThrottle() | ||||
|             notifier.showRestoreError(exception.message) | ||||
|  | ||||
|         // Unsubscribe from any previous subscription if needed. | ||||
|         subscription?.unsubscribe() | ||||
|             stopSelf(startId) | ||||
|         } | ||||
|         job = GlobalScope.launch(handler) { | ||||
|             restoreBackup(uri) | ||||
|         } | ||||
|         job?.invokeOnCompletion { | ||||
|             stopSelf(startId) | ||||
|         } | ||||
|  | ||||
|         subscription = Observable.using( | ||||
|                 { | ||||
|                     // Pause auto-gallery-update during restore | ||||
|                     if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { | ||||
|                         EHentaiUpdateWorker.cancelBackground(this) | ||||
|                     } | ||||
|                     db.lowLevel().beginTransaction() | ||||
|                 }, | ||||
|                 { getRestoreObservable(uri).doOnNext { db.lowLevel().setTransactionSuccessful() } }, | ||||
|                 { | ||||
|                     // Resume auto-gallery-update | ||||
|                     if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { | ||||
|                         EHentaiUpdateWorker.scheduleBackground(this) | ||||
|                     } | ||||
|                     executor.execute { db.lowLevel().endTransaction() } | ||||
|                 }) | ||||
|                 .doAfterTerminate { stopSelf(startId) } | ||||
|                 .subscribeOn(Schedulers.from(executor)) | ||||
|                 .subscribe() | ||||
|  | ||||
|         return Service.START_NOT_STICKY | ||||
|         return START_NOT_STICKY | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns an [Observable] containing restore process. | ||||
|      * Restores data from backup file. | ||||
|      * | ||||
|      * @param uri restore file | ||||
|      * @return [Observable<Manga>] | ||||
|      * @param uri backup file to restore | ||||
|      */ | ||||
|     private fun getRestoreObservable(uri: Uri): Observable<List<Manga>> { | ||||
|     private fun restoreBackup(uri: Uri) { | ||||
|         val startTime = System.currentTimeMillis() | ||||
|  | ||||
|         return Observable.just(Unit) | ||||
|                 .map { | ||||
|                     val reader = JsonReader(contentResolver.openInputStream(uri).bufferedReader()) | ||||
|                     val json = JsonParser().parse(reader).asJsonObject | ||||
|         val reader = JsonReader(contentResolver.openInputStream(uri)!!.bufferedReader()) | ||||
|         val json = JsonParser.parseReader(reader).asJsonObject | ||||
|  | ||||
|                     // Get parser version | ||||
|                     val version = json.get(VERSION)?.asInt ?: 1 | ||||
|         // Get parser version | ||||
|         val version = json.get(VERSION)?.asInt ?: 1 | ||||
|  | ||||
|                     // Initialize manager | ||||
|                     backupManager = BackupManager(this, version) | ||||
|         // Initialize manager | ||||
|         backupManager = BackupManager(this, version) | ||||
|  | ||||
|                     val mangasJson = json.get(MANGAS).asJsonArray | ||||
|         val mangasJson = json.get(MANGAS).asJsonArray | ||||
|  | ||||
|                     restoreAmount = mangasJson.size() + 1 // +1 for categories | ||||
|                     restoreProgress = 0 | ||||
|                     errors.clear() | ||||
|         restoreAmount = mangasJson.size() + 1 // +1 for categories | ||||
|         restoreProgress = 0 | ||||
|         errors.clear() | ||||
|  | ||||
|                     // Restore categories | ||||
|                     json.get(CATEGORIES)?.let { | ||||
|                         backupManager.restoreCategories(it.asJsonArray) | ||||
|                         restoreProgress += 1 | ||||
|                         showRestoreProgress(restoreProgress, restoreAmount, "Categories added", errors.size) | ||||
|                     } | ||||
|         // Restore categories | ||||
|         restoreCategories(json.get(CATEGORIES)) | ||||
|  | ||||
|                     mangasJson | ||||
|                 } | ||||
|                 .flatMap { Observable.from(it) } | ||||
|                 .concatMap { | ||||
|                     val obj = it.asJsonObject | ||||
|                     val manga = backupManager.parser.fromJson<MangaImpl>(obj.get(MANGA)) | ||||
|                     val chapters = backupManager.parser.fromJson<List<ChapterImpl>>(obj.get(CHAPTERS) ?: JsonArray()) | ||||
|                     val categories = backupManager.parser.fromJson<List<String>>(obj.get(CATEGORIES) ?: JsonArray()) | ||||
|                     val history = backupManager.parser.fromJson<List<DHistory>>(obj.get(HISTORY) ?: JsonArray()) | ||||
|                     val tracks = backupManager.parser.fromJson<List<TrackImpl>>(obj.get(TRACK) ?: JsonArray()) | ||||
|         // Restore individual manga | ||||
|         mangasJson.forEach { | ||||
|             restoreManga(it.asJsonObject) | ||||
|         } | ||||
|  | ||||
|                     // EXH --> | ||||
|                     val migrated = EXHMigrations.migrateBackupEntry( | ||||
|                             BackupEntry( | ||||
|                                     manga, | ||||
|                                     chapters, | ||||
|                                     categories, | ||||
|                                     history, | ||||
|                                     tracks | ||||
|                             ) | ||||
|                     ) | ||||
|         val endTime = System.currentTimeMillis() | ||||
|         val time = endTime - startTime | ||||
|  | ||||
|                     val observable = migrated.flatMap { (manga, chapters, categories, history, tracks) -> | ||||
|                         getMangaRestoreObservable(manga, chapters, categories, history, tracks) | ||||
|                     } | ||||
|                     // EXH <-- | ||||
|                     if (observable != null) { | ||||
|                         observable | ||||
|                     } else { | ||||
|                         errors.add(Date() to "${manga.title} - ${getString(R.string.source_not_found)}") | ||||
|                         restoreProgress += 1 | ||||
|                         val content = getString(R.string.dialog_restoring_source_not_found, manga.title.chop(15)) | ||||
|                         showRestoreProgress(restoreProgress, restoreAmount, manga.title, errors.size, content) | ||||
|                         Observable.just(manga) | ||||
|                     } | ||||
|                 } | ||||
|                 .toList() | ||||
|                 .doOnNext { | ||||
|                     val endTime = System.currentTimeMillis() | ||||
|                     val time = endTime - startTime | ||||
|                     val logFile = writeErrorLog() | ||||
|                     val completeIntent = Intent(BackupConst.INTENT_FILTER).apply { | ||||
|                         putExtra(BackupConst.EXTRA_TIME, time) | ||||
|                         putExtra(BackupConst.EXTRA_ERRORS, errors.size) | ||||
|                         putExtra(BackupConst.EXTRA_ERROR_FILE_PATH, logFile.parent) | ||||
|                         putExtra(BackupConst.EXTRA_ERROR_FILE, logFile.name) | ||||
|                         putExtra(BackupConst.ACTION, BackupConst.ACTION_RESTORE_COMPLETED_DIALOG) | ||||
|                     } | ||||
|                     sendLocalBroadcast(completeIntent) | ||||
|         val logFile = writeErrorLog() | ||||
|  | ||||
|                 } | ||||
|                 .doOnError { error -> | ||||
|                     // [EXH] | ||||
|                     XLog.w("> Failed to perform restore!", error) | ||||
|                     XLog.w("> (uri: %s)", uri) | ||||
|  | ||||
|                     writeErrorLog() | ||||
|                     val errorIntent = Intent(BackupConst.INTENT_FILTER).apply { | ||||
|                         putExtra(BackupConst.ACTION, BackupConst.ACTION_ERROR_RESTORE_DIALOG) | ||||
|                         putExtra(BackupConst.EXTRA_ERROR_MESSAGE, error.message) | ||||
|                     } | ||||
|                     sendLocalBroadcast(errorIntent) | ||||
|                 } | ||||
|                 .onErrorReturn { emptyList() } | ||||
|         notifier.showRestoreComplete(time, errors.size, logFile.parent, logFile.name) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Write errors to error log | ||||
|      */ | ||||
|     private fun writeErrorLog(): File { | ||||
|         try { | ||||
|             if (errors.isNotEmpty()) { | ||||
|                 val destFile = File(externalCacheDir, "tachiyomi_restore.log") | ||||
|                 val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.getDefault()) | ||||
|     private fun restoreCategories(categoriesJson: JsonElement) { | ||||
|         db.inTransaction { | ||||
|             backupManager.restoreCategories(categoriesJson.asJsonArray) | ||||
|  | ||||
|                 destFile.bufferedWriter().use { out -> | ||||
|                     errors.forEach { (date, message) -> | ||||
|                         out.write("[${sdf.format(date)}] $message\n") | ||||
|                     } | ||||
|                 } | ||||
|                 return destFile | ||||
|             } | ||||
|         } catch (e: Exception) { | ||||
|             // Empty | ||||
|             restoreProgress += 1 | ||||
|             showRestoreProgress(restoreProgress, restoreAmount, getString(R.string.categories)) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private fun restoreManga(mangaJson: JsonObject) { | ||||
|         db.inTransaction { | ||||
|             val manga = backupManager.parser.fromJson<MangaImpl>(mangaJson.get(MANGA)) | ||||
|             val chapters = backupManager.parser.fromJson<List<ChapterImpl>>( | ||||
|                 mangaJson.get(CHAPTERS) | ||||
|                     ?: JsonArray() | ||||
|             ) | ||||
|             val categories = backupManager.parser.fromJson<List<String>>( | ||||
|                 mangaJson.get(CATEGORIES) | ||||
|                     ?: JsonArray() | ||||
|             ) | ||||
|             val history = backupManager.parser.fromJson<List<DHistory>>( | ||||
|                 mangaJson.get(HISTORY) | ||||
|                     ?: JsonArray() | ||||
|             ) | ||||
|             val tracks = backupManager.parser.fromJson<List<TrackImpl>>( | ||||
|                 mangaJson.get(TRACK) | ||||
|                     ?: JsonArray() | ||||
|             ) | ||||
|  | ||||
|             if (job?.isActive != true) { | ||||
|                 throw Exception(getString(R.string.restoring_backup_canceled)) | ||||
|             } | ||||
|  | ||||
|             try { | ||||
|                 restoreMangaData(manga, chapters, categories, history, tracks) | ||||
|             } catch (e: Exception) { | ||||
|                 errors.add(Date() to "${manga.title} - ${getString(R.string.source_not_found)}") | ||||
|             } | ||||
|  | ||||
|             restoreProgress += 1 | ||||
|             showRestoreProgress(restoreProgress, restoreAmount, manga.title) | ||||
|         } | ||||
|         return File("") | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @@ -330,23 +296,26 @@ class BackupRestoreService : Service() { | ||||
|      * @param categories categories data from json | ||||
|      * @param history history data from json | ||||
|      * @param tracks tracking data from json | ||||
|      * @return [Observable] containing manga restore information | ||||
|      */ | ||||
|     private fun getMangaRestoreObservable(manga: Manga, chapters: List<Chapter>, | ||||
|                                           categories: List<String>, history: List<DHistory>, | ||||
|                                           tracks: List<Track>): Observable<Manga>? { | ||||
|     private fun restoreMangaData( | ||||
|         manga: Manga, | ||||
|         chapters: List<Chapter>, | ||||
|         categories: List<String>, | ||||
|         history: List<DHistory>, | ||||
|         tracks: List<Track> | ||||
|     ) { | ||||
|         // Get source | ||||
|         val source = backupManager.sourceManager.getOrStub(manga.source) | ||||
|         val dbManga = backupManager.getMangaFromDatabase(manga) | ||||
|  | ||||
|         return if (dbManga == null) { | ||||
|         if (dbManga == null) { | ||||
|             // Manga not in database | ||||
|             mangaFetchObservable(source, manga, chapters, categories, history, tracks) | ||||
|             restoreMangaFetch(source, manga, chapters, categories, history, tracks) | ||||
|         } else { // Manga in database | ||||
|             // Copy information from manga already in database | ||||
|             backupManager.restoreMangaNoFetch(manga, dbManga) | ||||
|             // Fetch rest of manga information | ||||
|             mangaNoFetchObservable(source, manga, chapters, categories, history, tracks) | ||||
|             restoreMangaNoFetch(source, manga, chapters, categories, history, tracks) | ||||
|         } | ||||
|     } | ||||
|  | ||||
| @@ -357,70 +326,58 @@ class BackupRestoreService : Service() { | ||||
|      * @param chapters chapters of manga that needs updating | ||||
|      * @param categories categories that need updating | ||||
|      */ | ||||
|     private fun mangaFetchObservable(source: Source, manga: Manga, chapters: List<Chapter>, | ||||
|                                      categories: List<String>, history: List<DHistory>, | ||||
|                                      tracks: List<Track>): Observable<Manga> { | ||||
|         if(source.id == EH_SOURCE_ID || source.id == EXH_SOURCE_ID) | ||||
|             throttleManager.throttle() | ||||
|  | ||||
|         return backupManager.restoreMangaFetchObservable(source, manga) | ||||
|                 .onErrorReturn { | ||||
|                     // [EXH] | ||||
|                     XLog.w("> Failed to restore manga!", it) | ||||
|                     XLog.w("> (source.id: %s, source.name: %s, manga.id: %s, manga.url: %s)", | ||||
|                             source.id, | ||||
|                             source.name, | ||||
|                             manga.id, | ||||
|                             manga.url) | ||||
|  | ||||
|                     errors.add(Date() to "${manga.title} - ${it.message}") | ||||
|                     manga | ||||
|                 } | ||||
|                 .filter { it.id != null } | ||||
|                 .flatMap { | ||||
|                     chapterFetchObservable(source, it, chapters) | ||||
|                             // Convert to the manga that contains new chapters. | ||||
|                             .map { manga } | ||||
|                 } | ||||
|                 .doOnNext { | ||||
|                     restoreExtraForManga(it, categories, history, tracks) | ||||
|                 } | ||||
|                 .flatMap { | ||||
|                     trackingFetchObservable(it, tracks) | ||||
|                             // Convert to the manga that contains new chapters. | ||||
|                             .map { manga } | ||||
|                 } | ||||
|                 .doOnCompleted { | ||||
|                     restoreProgress += 1 | ||||
|                     showRestoreProgress(restoreProgress, restoreAmount, manga.title, errors.size) | ||||
|                 } | ||||
|     private fun restoreMangaFetch( | ||||
|         source: Source, | ||||
|         manga: Manga, | ||||
|         chapters: List<Chapter>, | ||||
|         categories: List<String>, | ||||
|         history: List<DHistory>, | ||||
|         tracks: List<Track> | ||||
|     ) { | ||||
|         backupManager.restoreMangaFetchObservable(source, manga) | ||||
|             .onErrorReturn { | ||||
|                 errors.add(Date() to "${manga.title} - ${it.message}") | ||||
|                 manga | ||||
|             } | ||||
|             .filter { it.id != null } | ||||
|             .flatMap { | ||||
|                 chapterFetchObservable(source, it, chapters) | ||||
|                     // Convert to the manga that contains new chapters. | ||||
|                     .map { manga } | ||||
|             } | ||||
|             .doOnNext { | ||||
|                 restoreExtraForManga(it, categories, history, tracks) | ||||
|             } | ||||
|             .flatMap { | ||||
|                 trackingFetchObservable(it, tracks) | ||||
|             } | ||||
|             .subscribe() | ||||
|     } | ||||
|  | ||||
|     private fun mangaNoFetchObservable(source: Source, backupManga: Manga, chapters: List<Chapter>, | ||||
|                                        categories: List<String>, history: List<DHistory>, | ||||
|                                        tracks: List<Track>): Observable<Manga> { | ||||
|  | ||||
|         return Observable.just(backupManga) | ||||
|                 .flatMap { manga -> | ||||
|                     if (!backupManager.restoreChaptersForManga(manga, chapters)) { | ||||
|                         chapterFetchObservable(source, manga, chapters) | ||||
|                                 .map { manga } | ||||
|                     } else { | ||||
|                         Observable.just(manga) | ||||
|                     } | ||||
|                 } | ||||
|                 .doOnNext { | ||||
|                     restoreExtraForManga(it, categories, history, tracks) | ||||
|                 } | ||||
|                 .flatMap { manga -> | ||||
|                     trackingFetchObservable(manga, tracks) | ||||
|                             // Convert to the manga that contains new chapters. | ||||
|                             .map { manga } | ||||
|                 } | ||||
|                 .doOnCompleted { | ||||
|                     restoreProgress += 1 | ||||
|                     showRestoreProgress(restoreProgress, restoreAmount, backupManga.title, errors.size) | ||||
|     private fun restoreMangaNoFetch( | ||||
|         source: Source, | ||||
|         backupManga: Manga, | ||||
|         chapters: List<Chapter>, | ||||
|         categories: List<String>, | ||||
|         history: List<DHistory>, | ||||
|         tracks: List<Track> | ||||
|     ) { | ||||
|         Observable.just(backupManga) | ||||
|             .flatMap { manga -> | ||||
|                 if (!backupManager.restoreChaptersForManga(manga, chapters)) { | ||||
|                     chapterFetchObservable(source, manga, chapters) | ||||
|                         .map { manga } | ||||
|                 } else { | ||||
|                     Observable.just(manga) | ||||
|                 } | ||||
|             } | ||||
|             .doOnNext { | ||||
|                 restoreExtraForManga(it, categories, history, tracks) | ||||
|             } | ||||
|             .flatMap { manga -> | ||||
|                 trackingFetchObservable(manga, tracks) | ||||
|             } | ||||
|             .subscribe() | ||||
|     } | ||||
|  | ||||
|     private fun restoreExtraForManga(manga: Manga, categories: List<String>, history: List<DHistory>, tracks: List<Track>) { | ||||
| @@ -442,21 +399,12 @@ class BackupRestoreService : Service() { | ||||
|      * @return [Observable] that contains manga | ||||
|      */ | ||||
|     private fun chapterFetchObservable(source: Source, manga: Manga, chapters: List<Chapter>): Observable<Pair<List<Chapter>, List<Chapter>>> { | ||||
|         return backupManager.restoreChapterFetchObservable(source, manga, chapters, throttleManager) | ||||
|                 // If there's any error, return empty update and continue. | ||||
|                 .onErrorReturn { | ||||
|                     // [EXH] | ||||
|                     XLog.w("> Failed to restore chapter!", it) | ||||
|                     XLog.w("> (source.id: %s, source.name: %s, manga.id: %s, manga.url: %s, chapters.size: %s)", | ||||
|                             source.id, | ||||
|                             source.name, | ||||
|                             manga.id, | ||||
|                             manga.url, | ||||
|                             chapters.size) | ||||
|  | ||||
|                     errors.add(Date() to "${manga.title} - ${it.message}") | ||||
|                     Pair(emptyList(), emptyList()) | ||||
|                 } | ||||
|         return backupManager.restoreChapterFetchObservable(source, manga, chapters) | ||||
|             // If there's any error, return empty update and continue. | ||||
|             .onErrorReturn { | ||||
|                 errors.add(Date() to "${manga.title} - ${it.message}") | ||||
|                 Pair(emptyList(), emptyList()) | ||||
|             } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @@ -467,20 +415,20 @@ class BackupRestoreService : Service() { | ||||
|      */ | ||||
|     private fun trackingFetchObservable(manga: Manga, tracks: List<Track>): Observable<Track> { | ||||
|         return Observable.from(tracks) | ||||
|                 .concatMap { track -> | ||||
|                     val service = trackManager.getService(track.sync_id) | ||||
|                     if (service != null && service.isLogged) { | ||||
|                         service.refresh(track) | ||||
|                                 .doOnNext { db.insertTrack(it).executeAsBlocking() } | ||||
|                                 .onErrorReturn { | ||||
|                                     errors.add(Date() to "${manga.title} - ${it.message}") | ||||
|                                     track | ||||
|                                 } | ||||
|                     } else { | ||||
|                         errors.add(Date() to "${manga.title} - ${service?.name} not logged in") | ||||
|                         Observable.empty() | ||||
|                     } | ||||
|             .concatMap { track -> | ||||
|                 val service = trackManager.getService(track.sync_id) | ||||
|                 if (service != null && service.isLogged) { | ||||
|                     service.refresh(track) | ||||
|                         .doOnNext { db.insertTrack(it).executeAsBlocking() } | ||||
|                         .onErrorReturn { | ||||
|                             errors.add(Date() to "${manga.title} - ${it.message}") | ||||
|                             track | ||||
|                         } | ||||
|                 } else { | ||||
|                     errors.add(Date() to "${manga.title} - ${service?.name} not logged in") | ||||
|                     Observable.empty() | ||||
|                 } | ||||
|             } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @@ -490,16 +438,33 @@ class BackupRestoreService : Service() { | ||||
|      * @param amount total restoreAmount of manga | ||||
|      * @param title title of restored manga | ||||
|      */ | ||||
|     private fun showRestoreProgress(progress: Int, amount: Int, title: String, errors: Int, | ||||
|                                     content: String = getString(R.string.dialog_restoring_backup, title.chop(15))) { | ||||
|         val intent = Intent(BackupConst.INTENT_FILTER).apply { | ||||
|             putExtra(BackupConst.EXTRA_PROGRESS, progress) | ||||
|             putExtra(BackupConst.EXTRA_AMOUNT, amount) | ||||
|             putExtra(BackupConst.EXTRA_CONTENT, content) | ||||
|             putExtra(BackupConst.EXTRA_ERRORS, errors) | ||||
|             putExtra(BackupConst.ACTION, BackupConst.ACTION_SET_PROGRESS_DIALOG) | ||||
|         } | ||||
|         sendLocalBroadcast(intent) | ||||
|     private fun showRestoreProgress( | ||||
|         progress: Int, | ||||
|         amount: Int, | ||||
|         title: String | ||||
|     ) { | ||||
|         notifier.showRestoreProgress(title, progress, amount) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Write errors to error log | ||||
|      */ | ||||
|     private fun writeErrorLog(): File { | ||||
|         try { | ||||
|             if (errors.isNotEmpty()) { | ||||
|                 val destFile = File(externalCacheDir, "tachiyomi_restore.txt") | ||||
|                 val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.getDefault()) | ||||
|  | ||||
|                 destFile.bufferedWriter().use { out -> | ||||
|                     errors.forEach { (date, message) -> | ||||
|                         out.write("[${sdf.format(date)}] $message\n") | ||||
|                     } | ||||
|                 } | ||||
|                 return destFile | ||||
|             } | ||||
|         } catch (e: Exception) { | ||||
|             // Empty | ||||
|         } | ||||
|         return File("") | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -1,23 +1,25 @@ | ||||
| package eu.kanade.tachiyomi.data.backup.models | ||||
|  | ||||
| import java.text.SimpleDateFormat | ||||
| import java.util.* | ||||
|  | ||||
| /** | ||||
|  * Json values | ||||
|  */ | ||||
| object Backup { | ||||
|     const val CURRENT_VERSION = 2 | ||||
|     const val MANGA = "manga" | ||||
|     const val MANGAS = "mangas" | ||||
|     const val TRACK = "track" | ||||
|     const val CHAPTERS = "chapters" | ||||
|     const val CATEGORIES = "categories" | ||||
|     const val HISTORY = "history" | ||||
|     const val VERSION = "version" | ||||
|  | ||||
|     fun getDefaultFilename(): String { | ||||
|         val date = SimpleDateFormat("yyyy-MM-dd_HH-mm", Locale.getDefault()).format(Date()) | ||||
|         return "tachiyomi_$date.json" | ||||
|     } | ||||
| } | ||||
| package eu.kanade.tachiyomi.data.backup.models | ||||
|  | ||||
| import java.text.SimpleDateFormat | ||||
| import java.util.Date | ||||
| import java.util.Locale | ||||
|  | ||||
| /** | ||||
|  * Json values | ||||
|  */ | ||||
| object Backup { | ||||
|     const val CURRENT_VERSION = 2 | ||||
|     const val MANGA = "manga" | ||||
|     const val MANGAS = "mangas" | ||||
|     const val TRACK = "track" | ||||
|     const val CHAPTERS = "chapters" | ||||
|     const val CATEGORIES = "categories" | ||||
|     const val EXTENSIONS = "extensions" | ||||
|     const val HISTORY = "history" | ||||
|     const val VERSION = "version" | ||||
|  | ||||
|     fun getDefaultFilename(): String { | ||||
|         val date = SimpleDateFormat("yyyy-MM-dd_HH-mm", Locale.getDefault()).format(Date()) | ||||
|         return "tachiyomi_$date.json" | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -1,3 +1,3 @@ | ||||
| package eu.kanade.tachiyomi.data.backup.models | ||||
|  | ||||
| data class DHistory(val url: String,val lastRead: Long) | ||||
| data class DHistory(val url: String, val lastRead: Long) | ||||
|   | ||||
| @@ -28,4 +28,4 @@ object CategoryTypeAdapter { | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -43,9 +43,7 @@ object ChapterTypeAdapter { | ||||
|                 beginObject() | ||||
|                 while (hasNext()) { | ||||
|                     if (peek() == JsonToken.NAME) { | ||||
|                         val name = nextName() | ||||
|  | ||||
|                         when (name) { | ||||
|                         when (nextName()) { | ||||
|                             URL -> chapter.url = nextString() | ||||
|                             READ -> chapter.read = nextInt() == 1 | ||||
|                             BOOKMARK -> chapter.bookmark = nextInt() == 1 | ||||
| @@ -58,4 +56,4 @@ object ChapterTypeAdapter { | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -29,4 +29,4 @@ object HistoryTypeAdapter { | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -34,4 +34,4 @@ object MangaTypeAdapter { | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -41,9 +41,7 @@ object TrackTypeAdapter { | ||||
|                 beginObject() | ||||
|                 while (hasNext()) { | ||||
|                     if (peek() == JsonToken.NAME) { | ||||
|                         val name = nextName() | ||||
|  | ||||
|                         when (name) { | ||||
|                         when (nextName()) { | ||||
|                             TITLE -> track.title = nextString() | ||||
|                             SYNC -> track.sync_id = nextInt() | ||||
|                             MEDIA -> track.media_id = nextInt() | ||||
| @@ -58,4 +56,4 @@ object TrackTypeAdapter { | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -9,15 +9,15 @@ import eu.kanade.tachiyomi.data.database.models.Chapter | ||||
| import eu.kanade.tachiyomi.data.preference.PreferencesHelper | ||||
| import eu.kanade.tachiyomi.data.preference.getOrDefault | ||||
| import eu.kanade.tachiyomi.source.model.Page | ||||
| import eu.kanade.tachiyomi.util.DiskUtil | ||||
| import eu.kanade.tachiyomi.util.saveTo | ||||
| import eu.kanade.tachiyomi.util.storage.DiskUtil | ||||
| import eu.kanade.tachiyomi.util.storage.saveTo | ||||
| import java.io.File | ||||
| import java.io.IOException | ||||
| import okhttp3.Response | ||||
| import okio.buffer | ||||
| import okio.sink | ||||
| import rx.Observable | ||||
| import uy.kohesive.injekt.injectLazy | ||||
| import java.io.File | ||||
| import java.io.IOException | ||||
|  | ||||
| /** | ||||
|  * Class used to create chapter cache | ||||
| @@ -29,6 +29,7 @@ import java.io.IOException | ||||
|  * @constructor creates an instance of the chapter cache. | ||||
|  */ | ||||
| class ChapterCache(private val context: Context) { | ||||
|  | ||||
|     companion object { | ||||
|         /** Name of cache directory.  */ | ||||
|         const val PARAMETER_CACHE_DIRECTORY = "chapter_disk_cache" | ||||
| @@ -96,16 +97,17 @@ class ChapterCache(private val context: Context) { | ||||
|      */ | ||||
|     fun removeFileFromCache(file: String): Boolean { | ||||
|         // Make sure we don't delete the journal file (keeps track of cache). | ||||
|         if (file == "journal" || file.startsWith("journal.")) | ||||
|         if (file == "journal" || file.startsWith("journal.")) { | ||||
|             return false | ||||
|         } | ||||
|  | ||||
|         try { | ||||
|         return try { | ||||
|             // Remove the extension from the file to get the key of the cache | ||||
|             val key = file.substringBeforeLast(".") | ||||
|             // Remove file from cache. | ||||
|             return diskCache.remove(key) | ||||
|             diskCache.remove(key) | ||||
|         } catch (e: Exception) { | ||||
|             return false | ||||
|             false | ||||
|         } | ||||
|     } | ||||
|  | ||||
| @@ -154,7 +156,6 @@ class ChapterCache(private val context: Context) { | ||||
|             diskCache.flush() | ||||
|             editor.commit() | ||||
|             editor.abortUnlessCommitted() | ||||
|  | ||||
|         } catch (e: Exception) { | ||||
|             // Ignore. | ||||
|         } finally { | ||||
| @@ -169,10 +170,10 @@ class ChapterCache(private val context: Context) { | ||||
|      * @return true if in cache otherwise false. | ||||
|      */ | ||||
|     fun isImageInCache(imageUrl: String): Boolean { | ||||
|         try { | ||||
|             return diskCache.get(DiskUtil.hashKeyForDisk(imageUrl)) != null | ||||
|         return try { | ||||
|             diskCache.get(DiskUtil.hashKeyForDisk(imageUrl)) != null | ||||
|         } catch (e: IOException) { | ||||
|             return false | ||||
|             false | ||||
|         } | ||||
|     } | ||||
|  | ||||
| @@ -190,7 +191,7 @@ class ChapterCache(private val context: Context) { | ||||
|  | ||||
|     /** | ||||
|      * Add image to cache. | ||||
|      *  | ||||
|      * | ||||
|      * @param imageUrl url of image. | ||||
|      * @param response http response from page. | ||||
|      * @throws IOException image error. | ||||
| @@ -220,4 +221,3 @@ class ChapterCache(private val context: Context) { | ||||
|         return "${chapter.manga_id}${chapter.url}" | ||||
|     } | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| package eu.kanade.tachiyomi.data.cache | ||||
|  | ||||
| import android.content.Context | ||||
| import eu.kanade.tachiyomi.util.DiskUtil | ||||
| import eu.kanade.tachiyomi.util.storage.DiskUtil | ||||
| import java.io.File | ||||
| import java.io.IOException | ||||
| import java.io.InputStream | ||||
| @@ -20,8 +20,8 @@ class CoverCache(private val context: Context) { | ||||
|     /** | ||||
|      * Cache directory used for cache management. | ||||
|      */ | ||||
|     private val cacheDir = context.getExternalFilesDir("covers") ?: | ||||
|             File(context.filesDir, "covers").also { it.mkdirs() } | ||||
|     private val cacheDir = context.getExternalFilesDir("covers") | ||||
|         ?: File(context.filesDir, "covers").also { it.mkdirs() } | ||||
|  | ||||
|     /** | ||||
|      * Returns the cover from cache. | ||||
| @@ -37,7 +37,7 @@ class CoverCache(private val context: Context) { | ||||
|      * Copy the given stream to this cache. | ||||
|      * | ||||
|      * @param thumbnailUrl url of the thumbnail. | ||||
|      * @param inputStream  the stream to copy. | ||||
|      * @param inputStream the stream to copy. | ||||
|      * @throws IOException if there's any error. | ||||
|      */ | ||||
|     @Throws(IOException::class) | ||||
| @@ -56,12 +56,12 @@ class CoverCache(private val context: Context) { | ||||
|      */ | ||||
|     fun deleteFromCache(thumbnailUrl: String?): Boolean { | ||||
|         // Check if url is empty. | ||||
|         if (thumbnailUrl.isNullOrEmpty()) | ||||
|         if (thumbnailUrl.isNullOrEmpty()) { | ||||
|             return false | ||||
|         } | ||||
|  | ||||
|         // Remove file. | ||||
|         val file = getCoverFile(thumbnailUrl) | ||||
|         return file.exists() && file.delete() | ||||
|     } | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -3,46 +3,51 @@ package eu.kanade.tachiyomi.data.database | ||||
| import android.content.Context | ||||
| import androidx.sqlite.db.SupportSQLiteOpenHelper | ||||
| import com.pushtorefresh.storio.sqlite.impl.DefaultStorIOSQLite | ||||
| import eu.kanade.tachiyomi.data.database.mappers.* | ||||
| import eu.kanade.tachiyomi.data.database.models.* | ||||
| import eu.kanade.tachiyomi.data.database.queries.* | ||||
| import exh.metadata.sql.mappers.SearchMetadataTypeMapping | ||||
| import exh.metadata.sql.mappers.SearchTagTypeMapping | ||||
| import exh.metadata.sql.mappers.SearchTitleTypeMapping | ||||
| import exh.metadata.sql.models.SearchMetadata | ||||
| import exh.metadata.sql.models.SearchTag | ||||
| import exh.metadata.sql.models.SearchTitle | ||||
| import exh.metadata.sql.queries.SearchMetadataQueries | ||||
| import exh.metadata.sql.queries.SearchTagQueries | ||||
| import exh.metadata.sql.queries.SearchTitleQueries | ||||
| import eu.kanade.tachiyomi.data.database.mappers.CategoryTypeMapping | ||||
| import eu.kanade.tachiyomi.data.database.mappers.ChapterTypeMapping | ||||
| import eu.kanade.tachiyomi.data.database.mappers.HistoryTypeMapping | ||||
| import eu.kanade.tachiyomi.data.database.mappers.MangaCategoryTypeMapping | ||||
| import eu.kanade.tachiyomi.data.database.mappers.MangaTypeMapping | ||||
| import eu.kanade.tachiyomi.data.database.mappers.TrackTypeMapping | ||||
| import eu.kanade.tachiyomi.data.database.models.Category | ||||
| 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.MangaCategory | ||||
| import eu.kanade.tachiyomi.data.database.models.Track | ||||
| import eu.kanade.tachiyomi.data.database.queries.CategoryQueries | ||||
| import eu.kanade.tachiyomi.data.database.queries.ChapterQueries | ||||
| import eu.kanade.tachiyomi.data.database.queries.HistoryQueries | ||||
| import eu.kanade.tachiyomi.data.database.queries.MangaCategoryQueries | ||||
| import eu.kanade.tachiyomi.data.database.queries.MangaQueries | ||||
| import eu.kanade.tachiyomi.data.database.queries.TrackQueries | ||||
| import io.requery.android.database.sqlite.RequerySQLiteOpenHelperFactory | ||||
|  | ||||
| /** | ||||
|  * This class provides operations to manage the database through its interfaces. | ||||
|  */ | ||||
| open class DatabaseHelper(context: Context) | ||||
|     : MangaQueries, ChapterQueries, TrackQueries, CategoryQueries, MangaCategoryQueries, HistoryQueries, | ||||
|         /* EXH --> */ SearchMetadataQueries, SearchTagQueries, SearchTitleQueries /* EXH <-- */ | ||||
| { | ||||
| open class DatabaseHelper(context: Context) : | ||||
|     MangaQueries, ChapterQueries, TrackQueries, CategoryQueries, MangaCategoryQueries, HistoryQueries, /* EXH --> */ SearchMetadataQueries, SearchTagQueries, SearchTitleQueries /* EXH <-- */ { | ||||
|  | ||||
|     private val configuration = SupportSQLiteOpenHelper.Configuration.builder(context) | ||||
|             .name(DbOpenCallback.DATABASE_NAME) | ||||
|             .callback(DbOpenCallback()) | ||||
|             .build() | ||||
|  | ||||
|     override val db = DefaultStorIOSQLite.builder() | ||||
|             .sqliteOpenHelper(RequerySQLiteOpenHelperFactory().create(configuration)) | ||||
|             .addTypeMapping(Manga::class.java, MangaTypeMapping()) | ||||
|             .addTypeMapping(Chapter::class.java, ChapterTypeMapping()) | ||||
|             .addTypeMapping(Track::class.java, TrackTypeMapping()) | ||||
|             .addTypeMapping(Category::class.java, CategoryTypeMapping()) | ||||
|             .addTypeMapping(MangaCategory::class.java, MangaCategoryTypeMapping()) | ||||
|             .addTypeMapping(History::class.java, HistoryTypeMapping()) | ||||
|             // EXH --> | ||||
|             .addTypeMapping(SearchMetadata::class.java, SearchMetadataTypeMapping()) | ||||
|             .addTypeMapping(SearchTag::class.java, SearchTagTypeMapping()) | ||||
|             .addTypeMapping(SearchTitle::class.java, SearchTitleTypeMapping()) | ||||
|             // EXH <-- | ||||
|             .build() | ||||
|         .sqliteOpenHelper(RequerySQLiteOpenHelperFactory().create(configuration)) | ||||
|         .addTypeMapping(Manga::class.java, MangaTypeMapping()) | ||||
|         .addTypeMapping(Chapter::class.java, ChapterTypeMapping()) | ||||
|         .addTypeMapping(Track::class.java, TrackTypeMapping()) | ||||
|         .addTypeMapping(Category::class.java, CategoryTypeMapping()) | ||||
|         .addTypeMapping(MangaCategory::class.java, MangaCategoryTypeMapping()) | ||||
|         .addTypeMapping(History::class.java, HistoryTypeMapping()) | ||||
|         // EXH --> | ||||
|         .addTypeMapping(SearchMetadata::class.java, SearchMetadataTypeMapping()) | ||||
|         .addTypeMapping(SearchTag::class.java, SearchTagTypeMapping()) | ||||
|         .addTypeMapping(SearchTitle::class.java, SearchTitleTypeMapping()) | ||||
|         // EXH <-- | ||||
|         .build() | ||||
|  | ||||
|     inline fun inTransaction(block: () -> Unit) = db.inTransaction(block) | ||||
|  | ||||
|   | ||||
| @@ -22,4 +22,3 @@ inline fun <T> StorIOSQLite.inTransactionReturn(block: () -> T): T { | ||||
|         lowLevel().endTransaction() | ||||
|     } | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -2,7 +2,12 @@ package eu.kanade.tachiyomi.data.database | ||||
|  | ||||
| import androidx.sqlite.db.SupportSQLiteDatabase | ||||
| import androidx.sqlite.db.SupportSQLiteOpenHelper | ||||
| import eu.kanade.tachiyomi.data.database.tables.* | ||||
| import eu.kanade.tachiyomi.data.database.tables.CategoryTable | ||||
| import eu.kanade.tachiyomi.data.database.tables.ChapterTable | ||||
| import eu.kanade.tachiyomi.data.database.tables.HistoryTable | ||||
| import eu.kanade.tachiyomi.data.database.tables.MangaCategoryTable | ||||
| import eu.kanade.tachiyomi.data.database.tables.MangaTable | ||||
| import eu.kanade.tachiyomi.data.database.tables.TrackTable | ||||
| import exh.metadata.sql.tables.SearchMetadataTable | ||||
| import exh.metadata.sql.tables.SearchTagTable | ||||
| import exh.metadata.sql.tables.SearchTitleTable | ||||
| @@ -18,7 +23,7 @@ class DbOpenCallback : SupportSQLiteOpenHelper.Callback(DATABASE_VERSION) { | ||||
|         /** | ||||
|          * Version of the database. | ||||
|          */ | ||||
|         const val DATABASE_VERSION = 9 // [EXH] | ||||
|         const val DATABASE_VERSION = 0 // [SY] | ||||
|     } | ||||
|  | ||||
|     override fun onCreate(db: SupportSQLiteDatabase) = with(db) { | ||||
| @@ -51,54 +56,18 @@ class DbOpenCallback : SupportSQLiteOpenHelper.Callback(DATABASE_VERSION) { | ||||
|     } | ||||
|  | ||||
|     override fun onUpgrade(db: SupportSQLiteDatabase, oldVersion: Int, newVersion: Int) { | ||||
|         if (oldVersion < 2) { | ||||
|         if (oldVersion < 0) { | ||||
|             db.execSQL(ChapterTable.sourceOrderUpdateQuery) | ||||
|  | ||||
|             // Fix kissmanga covers after supporting cloudflare | ||||
|             db.execSQL("""UPDATE mangas SET thumbnail_url = | ||||
|                     REPLACE(thumbnail_url, '93.174.95.110', 'kissmanga.com') WHERE source = 4""") | ||||
|             db.execSQL( | ||||
|                 """UPDATE mangas SET thumbnail_url = | ||||
|                     REPLACE(thumbnail_url, '93.174.95.110', 'kissmanga.com') WHERE source = 4""" | ||||
|             ) | ||||
|         } | ||||
|         if (oldVersion < 3) { | ||||
|             // Initialize history tables | ||||
|             db.execSQL(HistoryTable.createTableQuery) | ||||
|             db.execSQL(HistoryTable.createChapterIdIndexQuery) | ||||
|         } | ||||
|         if (oldVersion < 4) { | ||||
|             db.execSQL(ChapterTable.bookmarkUpdateQuery) | ||||
|         } | ||||
|         if (oldVersion < 5) { | ||||
|             db.execSQL(ChapterTable.addScanlator) | ||||
|         } | ||||
|         if (oldVersion < 6) { | ||||
|             db.execSQL(TrackTable.addTrackingUrl) | ||||
|         } | ||||
|         if (oldVersion < 7) { | ||||
|             db.execSQL(TrackTable.addLibraryId) | ||||
|         } | ||||
|         if (oldVersion < 8) { | ||||
|             db.execSQL("DROP INDEX IF EXISTS mangas_favorite_index") | ||||
|             db.execSQL(MangaTable.createLibraryIndexQuery) | ||||
|             db.execSQL(ChapterTable.createUnreadChaptersIndexQuery) | ||||
|         } | ||||
|         // EXH --> | ||||
|         if (oldVersion < 9) { | ||||
|             db.execSQL(SearchMetadataTable.createTableQuery) | ||||
|             db.execSQL(SearchTagTable.createTableQuery) | ||||
|             db.execSQL(SearchTitleTable.createTableQuery) | ||||
|  | ||||
|             db.execSQL(SearchMetadataTable.createUploaderIndexQuery) | ||||
|             db.execSQL(SearchMetadataTable.createIndexedExtraIndexQuery) | ||||
|             db.execSQL(SearchTagTable.createMangaIdIndexQuery) | ||||
|             db.execSQL(SearchTagTable.createNamespaceNameIndexQuery) | ||||
|             db.execSQL(SearchTitleTable.createMangaIdIndexQuery) | ||||
|             db.execSQL(SearchTitleTable.createTitleIndexQuery) | ||||
|         } | ||||
|         // Remember to increment any Tachiyomi database upgrades after this | ||||
|         // EXH <-- | ||||
|     } | ||||
|  | ||||
|     override fun onConfigure(db: SupportSQLiteDatabase) { | ||||
|         db.setForeignKeyConstraintsEnabled(true) | ||||
|     } | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -5,5 +5,4 @@ import com.pushtorefresh.storio.sqlite.impl.DefaultStorIOSQLite | ||||
| interface DbProvider { | ||||
|  | ||||
|     val db: DefaultStorIOSQLite | ||||
|  | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -18,22 +18,22 @@ import eu.kanade.tachiyomi.data.database.tables.CategoryTable.COL_ORDER | ||||
| import eu.kanade.tachiyomi.data.database.tables.CategoryTable.TABLE | ||||
|  | ||||
| class CategoryTypeMapping : SQLiteTypeMapping<Category>( | ||||
|         CategoryPutResolver(), | ||||
|         CategoryGetResolver(), | ||||
|         CategoryDeleteResolver() | ||||
|     CategoryPutResolver(), | ||||
|     CategoryGetResolver(), | ||||
|     CategoryDeleteResolver() | ||||
| ) | ||||
|  | ||||
| class CategoryPutResolver : DefaultPutResolver<Category>() { | ||||
|  | ||||
|     override fun mapToInsertQuery(obj: Category) = InsertQuery.builder() | ||||
|             .table(TABLE) | ||||
|             .build() | ||||
|         .table(TABLE) | ||||
|         .build() | ||||
|  | ||||
|     override fun mapToUpdateQuery(obj: Category) = UpdateQuery.builder() | ||||
|             .table(TABLE) | ||||
|             .where("$COL_ID = ?") | ||||
|             .whereArgs(obj.id) | ||||
|             .build() | ||||
|         .table(TABLE) | ||||
|         .where("$COL_ID = ?") | ||||
|         .whereArgs(obj.id) | ||||
|         .build() | ||||
|  | ||||
|     override fun mapToContentValues(obj: Category) = ContentValues(4).apply { | ||||
|         put(COL_ID, obj.id) | ||||
| @@ -56,8 +56,8 @@ class CategoryGetResolver : DefaultGetResolver<Category>() { | ||||
| class CategoryDeleteResolver : DefaultDeleteResolver<Category>() { | ||||
|  | ||||
|     override fun mapToDeleteQuery(obj: Category) = DeleteQuery.builder() | ||||
|             .table(TABLE) | ||||
|             .where("$COL_ID = ?") | ||||
|             .whereArgs(obj.id) | ||||
|             .build() | ||||
|         .table(TABLE) | ||||
|         .where("$COL_ID = ?") | ||||
|         .whereArgs(obj.id) | ||||
|         .build() | ||||
| } | ||||
|   | ||||
| @@ -26,22 +26,22 @@ import eu.kanade.tachiyomi.data.database.tables.ChapterTable.COL_URL | ||||
| import eu.kanade.tachiyomi.data.database.tables.ChapterTable.TABLE | ||||
|  | ||||
| class ChapterTypeMapping : SQLiteTypeMapping<Chapter>( | ||||
|         ChapterPutResolver(), | ||||
|         ChapterGetResolver(), | ||||
|         ChapterDeleteResolver() | ||||
|     ChapterPutResolver(), | ||||
|     ChapterGetResolver(), | ||||
|     ChapterDeleteResolver() | ||||
| ) | ||||
|  | ||||
| class ChapterPutResolver : DefaultPutResolver<Chapter>() { | ||||
|  | ||||
|     override fun mapToInsertQuery(obj: Chapter) = InsertQuery.builder() | ||||
|             .table(TABLE) | ||||
|             .build() | ||||
|         .table(TABLE) | ||||
|         .build() | ||||
|  | ||||
|     override fun mapToUpdateQuery(obj: Chapter) = UpdateQuery.builder() | ||||
|             .table(TABLE) | ||||
|             .where("$COL_ID = ?") | ||||
|             .whereArgs(obj.id) | ||||
|             .build() | ||||
|         .table(TABLE) | ||||
|         .where("$COL_ID = ?") | ||||
|         .whereArgs(obj.id) | ||||
|         .build() | ||||
|  | ||||
|     override fun mapToContentValues(obj: Chapter) = ContentValues(11).apply { | ||||
|         put(COL_ID, obj.id) | ||||
| @@ -80,9 +80,8 @@ class ChapterGetResolver : DefaultGetResolver<Chapter>() { | ||||
| class ChapterDeleteResolver : DefaultDeleteResolver<Chapter>() { | ||||
|  | ||||
|     override fun mapToDeleteQuery(obj: Chapter) = DeleteQuery.builder() | ||||
|             .table(TABLE) | ||||
|             .where("$COL_ID = ?") | ||||
|             .whereArgs(obj.id) | ||||
|             .build() | ||||
|         .table(TABLE) | ||||
|         .where("$COL_ID = ?") | ||||
|         .whereArgs(obj.id) | ||||
|         .build() | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -18,22 +18,22 @@ import eu.kanade.tachiyomi.data.database.tables.HistoryTable.COL_TIME_READ | ||||
| import eu.kanade.tachiyomi.data.database.tables.HistoryTable.TABLE | ||||
|  | ||||
| class HistoryTypeMapping : SQLiteTypeMapping<History>( | ||||
|         HistoryPutResolver(), | ||||
|         HistoryGetResolver(), | ||||
|         HistoryDeleteResolver() | ||||
|     HistoryPutResolver(), | ||||
|     HistoryGetResolver(), | ||||
|     HistoryDeleteResolver() | ||||
| ) | ||||
|  | ||||
| open class HistoryPutResolver : DefaultPutResolver<History>() { | ||||
|  | ||||
|     override fun mapToInsertQuery(obj: History) = InsertQuery.builder() | ||||
|             .table(TABLE) | ||||
|             .build() | ||||
|         .table(TABLE) | ||||
|         .build() | ||||
|  | ||||
|     override fun mapToUpdateQuery(obj: History) = UpdateQuery.builder() | ||||
|             .table(TABLE) | ||||
|             .where("$COL_ID = ?") | ||||
|             .whereArgs(obj.id) | ||||
|             .build() | ||||
|         .table(TABLE) | ||||
|         .where("$COL_ID = ?") | ||||
|         .whereArgs(obj.id) | ||||
|         .build() | ||||
|  | ||||
|     override fun mapToContentValues(obj: History) = ContentValues(4).apply { | ||||
|         put(COL_ID, obj.id) | ||||
| @@ -56,8 +56,8 @@ class HistoryGetResolver : DefaultGetResolver<History>() { | ||||
| class HistoryDeleteResolver : DefaultDeleteResolver<History>() { | ||||
|  | ||||
|     override fun mapToDeleteQuery(obj: History) = DeleteQuery.builder() | ||||
|             .table(TABLE) | ||||
|             .where("$COL_ID = ?") | ||||
|             .whereArgs(obj.id) | ||||
|             .build() | ||||
|         .table(TABLE) | ||||
|         .where("$COL_ID = ?") | ||||
|         .whereArgs(obj.id) | ||||
|         .build() | ||||
| } | ||||
|   | ||||
| @@ -16,22 +16,22 @@ import eu.kanade.tachiyomi.data.database.tables.MangaCategoryTable.COL_MANGA_ID | ||||
| import eu.kanade.tachiyomi.data.database.tables.MangaCategoryTable.TABLE | ||||
|  | ||||
| class MangaCategoryTypeMapping : SQLiteTypeMapping<MangaCategory>( | ||||
|         MangaCategoryPutResolver(), | ||||
|         MangaCategoryGetResolver(), | ||||
|         MangaCategoryDeleteResolver() | ||||
|     MangaCategoryPutResolver(), | ||||
|     MangaCategoryGetResolver(), | ||||
|     MangaCategoryDeleteResolver() | ||||
| ) | ||||
|  | ||||
| class MangaCategoryPutResolver : DefaultPutResolver<MangaCategory>() { | ||||
|  | ||||
|     override fun mapToInsertQuery(obj: MangaCategory) = InsertQuery.builder() | ||||
|             .table(TABLE) | ||||
|             .build() | ||||
|         .table(TABLE) | ||||
|         .build() | ||||
|  | ||||
|     override fun mapToUpdateQuery(obj: MangaCategory) = UpdateQuery.builder() | ||||
|             .table(TABLE) | ||||
|             .where("$COL_ID = ?") | ||||
|             .whereArgs(obj.id) | ||||
|             .build() | ||||
|         .table(TABLE) | ||||
|         .where("$COL_ID = ?") | ||||
|         .whereArgs(obj.id) | ||||
|         .build() | ||||
|  | ||||
|     override fun mapToContentValues(obj: MangaCategory) = ContentValues(3).apply { | ||||
|         put(COL_ID, obj.id) | ||||
| @@ -52,8 +52,8 @@ class MangaCategoryGetResolver : DefaultGetResolver<MangaCategory>() { | ||||
| class MangaCategoryDeleteResolver : DefaultDeleteResolver<MangaCategory>() { | ||||
|  | ||||
|     override fun mapToDeleteQuery(obj: MangaCategory) = DeleteQuery.builder() | ||||
|             .table(TABLE) | ||||
|             .where("$COL_ID = ?") | ||||
|             .whereArgs(obj.id) | ||||
|             .build() | ||||
|         .table(TABLE) | ||||
|         .where("$COL_ID = ?") | ||||
|         .whereArgs(obj.id) | ||||
|         .build() | ||||
| } | ||||
|   | ||||
| @@ -29,22 +29,22 @@ import eu.kanade.tachiyomi.data.database.tables.MangaTable.COL_VIEWER | ||||
| import eu.kanade.tachiyomi.data.database.tables.MangaTable.TABLE | ||||
|  | ||||
| class MangaTypeMapping : SQLiteTypeMapping<Manga>( | ||||
|         MangaPutResolver(), | ||||
|         MangaGetResolver(), | ||||
|         MangaDeleteResolver() | ||||
|     MangaPutResolver(), | ||||
|     MangaGetResolver(), | ||||
|     MangaDeleteResolver() | ||||
| ) | ||||
|  | ||||
| class MangaPutResolver : DefaultPutResolver<Manga>() { | ||||
|  | ||||
|     override fun mapToInsertQuery(obj: Manga) = InsertQuery.builder() | ||||
|             .table(TABLE) | ||||
|             .build() | ||||
|         .table(TABLE) | ||||
|         .build() | ||||
|  | ||||
|     override fun mapToUpdateQuery(obj: Manga) = UpdateQuery.builder() | ||||
|             .table(TABLE) | ||||
|             .where("$COL_ID = ?") | ||||
|             .whereArgs(obj.id) | ||||
|             .build() | ||||
|         .table(TABLE) | ||||
|         .where("$COL_ID = ?") | ||||
|         .whereArgs(obj.id) | ||||
|         .build() | ||||
|  | ||||
|     override fun mapToContentValues(obj: Manga) = ContentValues(15).apply { | ||||
|         put(COL_ID, obj.id) | ||||
| @@ -95,8 +95,8 @@ open class MangaGetResolver : DefaultGetResolver<Manga>(), BaseMangaGetResolver | ||||
| class MangaDeleteResolver : DefaultDeleteResolver<Manga>() { | ||||
|  | ||||
|     override fun mapToDeleteQuery(obj: Manga) = DeleteQuery.builder() | ||||
|             .table(TABLE) | ||||
|             .where("$COL_ID = ?") | ||||
|             .whereArgs(obj.id) | ||||
|             .build() | ||||
|         .table(TABLE) | ||||
|         .where("$COL_ID = ?") | ||||
|         .whereArgs(obj.id) | ||||
|         .build() | ||||
| } | ||||
|   | ||||
| @@ -11,12 +11,14 @@ import com.pushtorefresh.storio.sqlite.queries.InsertQuery | ||||
| import com.pushtorefresh.storio.sqlite.queries.UpdateQuery | ||||
| 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_FINISH_DATE | ||||
| 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_LIBRARY_ID | ||||
| import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_MANGA_ID | ||||
| import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_MEDIA_ID | ||||
| import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_SCORE | ||||
| import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_START_DATE | ||||
| 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 | ||||
| @@ -25,22 +27,22 @@ import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_TRACKING_URL | ||||
| import eu.kanade.tachiyomi.data.database.tables.TrackTable.TABLE | ||||
|  | ||||
| class TrackTypeMapping : SQLiteTypeMapping<Track>( | ||||
|         TrackPutResolver(), | ||||
|         TrackGetResolver(), | ||||
|         TrackDeleteResolver() | ||||
|     TrackPutResolver(), | ||||
|     TrackGetResolver(), | ||||
|     TrackDeleteResolver() | ||||
| ) | ||||
|  | ||||
| class TrackPutResolver : DefaultPutResolver<Track>() { | ||||
|  | ||||
|     override fun mapToInsertQuery(obj: Track) = InsertQuery.builder() | ||||
|             .table(TABLE) | ||||
|             .build() | ||||
|         .table(TABLE) | ||||
|         .build() | ||||
|  | ||||
|     override fun mapToUpdateQuery(obj: Track) = UpdateQuery.builder() | ||||
|             .table(TABLE) | ||||
|             .where("$COL_ID = ?") | ||||
|             .whereArgs(obj.id) | ||||
|             .build() | ||||
|         .table(TABLE) | ||||
|         .where("$COL_ID = ?") | ||||
|         .whereArgs(obj.id) | ||||
|         .build() | ||||
|  | ||||
|     override fun mapToContentValues(obj: Track) = ContentValues(10).apply { | ||||
|         put(COL_ID, obj.id) | ||||
| @@ -54,7 +56,8 @@ class TrackPutResolver : DefaultPutResolver<Track>() { | ||||
|         put(COL_STATUS, obj.status) | ||||
|         put(COL_TRACKING_URL, obj.tracking_url) | ||||
|         put(COL_SCORE, obj.score) | ||||
|  | ||||
|         put(COL_START_DATE, obj.started_reading_date) | ||||
|         put(COL_FINISH_DATE, obj.finished_reading_date) | ||||
|     } | ||||
| } | ||||
|  | ||||
| @@ -72,14 +75,16 @@ class TrackGetResolver : DefaultGetResolver<Track>() { | ||||
|         status = cursor.getInt(cursor.getColumnIndex(COL_STATUS)) | ||||
|         score = cursor.getFloat(cursor.getColumnIndex(COL_SCORE)) | ||||
|         tracking_url = cursor.getString(cursor.getColumnIndex(COL_TRACKING_URL)) | ||||
|         started_reading_date = cursor.getLong(cursor.getColumnIndex(COL_START_DATE)) | ||||
|         finished_reading_date = cursor.getLong(cursor.getColumnIndex(COL_FINISH_DATE)) | ||||
|     } | ||||
| } | ||||
|  | ||||
| class TrackDeleteResolver : DefaultDeleteResolver<Track>() { | ||||
|  | ||||
|     override fun mapToDeleteQuery(obj: Track) = DeleteQuery.builder() | ||||
|             .table(TABLE) | ||||
|             .where("$COL_ID = ?") | ||||
|             .whereArgs(obj.id) | ||||
|             .build() | ||||
|         .table(TABLE) | ||||
|         .where("$COL_ID = ?") | ||||
|         .whereArgs(obj.id) | ||||
|         .build() | ||||
| } | ||||
|   | ||||
| @@ -23,5 +23,4 @@ interface Category : Serializable { | ||||
|  | ||||
|         fun createDefault(): Category = create("Default").apply { id = 0 } | ||||
|     } | ||||
|  | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -22,5 +22,4 @@ class CategoryImpl : Category { | ||||
|     override fun hashCode(): Int { | ||||
|         return name.hashCode() | ||||
|     } | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -37,5 +37,4 @@ class ChapterImpl : Chapter { | ||||
|     override fun hashCode(): Int { | ||||
|         return url.hashCode() | ||||
|     } | ||||
|  | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -35,7 +35,7 @@ interface History : Serializable { | ||||
|          * @param chapter chapter object | ||||
|          * @return history object | ||||
|          */ | ||||
|         fun create(chapter: Chapter): History =  HistoryImpl().apply { | ||||
|         fun create(chapter: Chapter): History = HistoryImpl().apply { | ||||
|             this.chapter_id = chapter.id!! | ||||
|         } | ||||
|     } | ||||
|   | ||||
| @@ -5,5 +5,4 @@ class LibraryManga : MangaImpl() { | ||||
|     var unread: Int = 0 | ||||
|  | ||||
|     var category: Int = 0 | ||||
|  | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -28,6 +28,10 @@ interface Manga : SManga { | ||||
|         return chapter_flags and SORT_MASK == SORT_DESC | ||||
|     } | ||||
|  | ||||
|     fun getGenres(): List<String>? { | ||||
|         return genre?.split(", ")?.map { it.trim() } | ||||
|     } | ||||
|  | ||||
|     // Used to display the chapter's title one way or another | ||||
|     var displayMode: Int | ||||
|         get() = chapter_flags and DISPLAY_MASK | ||||
| @@ -88,5 +92,4 @@ interface Manga : SManga { | ||||
|             this.source = source | ||||
|         } | ||||
|     } | ||||
|  | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -17,5 +17,4 @@ class MangaCategory { | ||||
|             return mc | ||||
|         } | ||||
|     } | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -5,6 +5,6 @@ package eu.kanade.tachiyomi.data.database.models | ||||
|  * | ||||
|  * @param manga object containing manga | ||||
|  * @param chapter object containing chater | ||||
|  * @param history      object containing history | ||||
|  * @param history object containing history | ||||
|  */ | ||||
| data class MangaChapterHistory(val manga: Manga, val chapter: Chapter, val history: History) | ||||
|   | ||||
| @@ -39,11 +39,9 @@ open class MangaImpl : Manga { | ||||
|         val manga = other as Manga | ||||
|  | ||||
|         return url == manga.url | ||||
|  | ||||
|     } | ||||
|  | ||||
|     override fun hashCode(): Int { | ||||
|         return url.hashCode() | ||||
|     } | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -24,12 +24,18 @@ interface Track : Serializable { | ||||
|  | ||||
|     var status: Int | ||||
|  | ||||
|     var started_reading_date: Long | ||||
|  | ||||
|     var finished_reading_date: Long | ||||
|  | ||||
|     var tracking_url: String | ||||
|  | ||||
|     fun copyPersonalFrom(other: Track) { | ||||
|         last_chapter_read = other.last_chapter_read | ||||
|         score = other.score | ||||
|         status = other.status | ||||
|         started_reading_date = other.started_reading_date | ||||
|         finished_reading_date = other.finished_reading_date | ||||
|     } | ||||
|  | ||||
|     companion object { | ||||
| @@ -37,5 +43,4 @@ interface Track : Serializable { | ||||
|             sync_id = serviceId | ||||
|         } | ||||
|     } | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -22,6 +22,10 @@ class TrackImpl : Track { | ||||
|  | ||||
|     override var status: Int = 0 | ||||
|  | ||||
|     override var started_reading_date: Long = 0 | ||||
|  | ||||
|     override var finished_reading_date: Long = 0 | ||||
|  | ||||
|     override var tracking_url: String = "" | ||||
|  | ||||
|     override fun equals(other: Any?): Boolean { | ||||
| @@ -41,5 +45,4 @@ class TrackImpl : Track { | ||||
|         result = 31 * result + media_id | ||||
|         return result | ||||
|     } | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -10,20 +10,24 @@ import eu.kanade.tachiyomi.data.database.tables.CategoryTable | ||||
| interface CategoryQueries : DbProvider { | ||||
|  | ||||
|     fun getCategories() = db.get() | ||||
|             .listOfObjects(Category::class.java) | ||||
|             .withQuery(Query.builder() | ||||
|                     .table(CategoryTable.TABLE) | ||||
|                     .orderBy(CategoryTable.COL_ORDER) | ||||
|                     .build()) | ||||
|             .prepare() | ||||
|         .listOfObjects(Category::class.java) | ||||
|         .withQuery( | ||||
|             Query.builder() | ||||
|                 .table(CategoryTable.TABLE) | ||||
|                 .orderBy(CategoryTable.COL_ORDER) | ||||
|                 .build() | ||||
|         ) | ||||
|         .prepare() | ||||
|  | ||||
|     fun getCategoriesForManga(manga: Manga) = db.get() | ||||
|             .listOfObjects(Category::class.java) | ||||
|             .withQuery(RawQuery.builder() | ||||
|                     .query(getCategoriesForMangaQuery()) | ||||
|                     .args(manga.id) | ||||
|                     .build()) | ||||
|             .prepare() | ||||
|         .listOfObjects(Category::class.java) | ||||
|         .withQuery( | ||||
|             RawQuery.builder() | ||||
|                 .query(getCategoriesForMangaQuery()) | ||||
|                 .args(manga.id) | ||||
|                 .build() | ||||
|         ) | ||||
|         .prepare() | ||||
|  | ||||
|     fun insertCategory(category: Category) = db.put().`object`(category).prepare() | ||||
|  | ||||
| @@ -32,5 +36,4 @@ interface CategoryQueries : DbProvider { | ||||
|     fun deleteCategory(category: Category) = db.delete().`object`(category).prepare() | ||||
|  | ||||
|     fun deleteCategories(categories: List<Category>) = db.delete().objects(categories).prepare() | ||||
|  | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -11,7 +11,7 @@ import eu.kanade.tachiyomi.data.database.resolvers.ChapterProgressPutResolver | ||||
| import eu.kanade.tachiyomi.data.database.resolvers.ChapterSourceOrderPutResolver | ||||
| import eu.kanade.tachiyomi.data.database.resolvers.MangaChapterGetResolver | ||||
| import eu.kanade.tachiyomi.data.database.tables.ChapterTable | ||||
| import java.util.* | ||||
| import java.util.Date | ||||
|  | ||||
| interface ChapterQueries : DbProvider { | ||||
|  | ||||
| @@ -27,32 +27,49 @@ interface ChapterQueries : DbProvider { | ||||
|             .prepare() | ||||
|  | ||||
|     fun getRecentChapters(date: Date) = db.get() | ||||
|             .listOfObjects(MangaChapter::class.java) | ||||
|             .withQuery(RawQuery.builder() | ||||
|                     .query(getRecentsQuery()) | ||||
|                     .args(date.time) | ||||
|                     .observesTables(ChapterTable.TABLE) | ||||
|                     .build()) | ||||
|             .withGetResolver(MangaChapterGetResolver.INSTANCE) | ||||
|             .prepare() | ||||
|         .listOfObjects(MangaChapter::class.java) | ||||
|         .withQuery( | ||||
|             RawQuery.builder() | ||||
|                 .query(getRecentsQuery()) | ||||
|                 .args(date.time) | ||||
|                 .observesTables(ChapterTable.TABLE) | ||||
|                 .build() | ||||
|         ) | ||||
|         .withGetResolver(MangaChapterGetResolver.INSTANCE) | ||||
|         .prepare() | ||||
|  | ||||
|     fun getChapter(id: Long) = db.get() | ||||
|             .`object`(Chapter::class.java) | ||||
|             .withQuery(Query.builder() | ||||
|                     .table(ChapterTable.TABLE) | ||||
|                     .where("${ChapterTable.COL_ID} = ?") | ||||
|                     .whereArgs(id) | ||||
|                     .build()) | ||||
|             .prepare() | ||||
|         .`object`(Chapter::class.java) | ||||
|         .withQuery( | ||||
|             Query.builder() | ||||
|                 .table(ChapterTable.TABLE) | ||||
|                 .where("${ChapterTable.COL_ID} = ?") | ||||
|                 .whereArgs(id) | ||||
|                 .build() | ||||
|         ) | ||||
|         .prepare() | ||||
|  | ||||
|     fun getChapter(url: String) = db.get() | ||||
|             .`object`(Chapter::class.java) | ||||
|             .withQuery(Query.builder() | ||||
|                     .table(ChapterTable.TABLE) | ||||
|                     .where("${ChapterTable.COL_URL} = ?") | ||||
|                     .whereArgs(url) | ||||
|                     .build()) | ||||
|             .prepare() | ||||
|         .`object`(Chapter::class.java) | ||||
|         .withQuery( | ||||
|             Query.builder() | ||||
|                 .table(ChapterTable.TABLE) | ||||
|                 .where("${ChapterTable.COL_URL} = ?") | ||||
|                 .whereArgs(url) | ||||
|                 .build() | ||||
|         ) | ||||
|         .prepare() | ||||
|  | ||||
|     fun getChapter(url: String, mangaId: Long) = db.get() | ||||
|         .`object`(Chapter::class.java) | ||||
|         .withQuery( | ||||
|             Query.builder() | ||||
|                 .table(ChapterTable.TABLE) | ||||
|                 .where("${ChapterTable.COL_URL} = ? AND ${ChapterTable.COL_MANGA_ID} = ?") | ||||
|                 .whereArgs(url, mangaId) | ||||
|                 .build() | ||||
|         ) | ||||
|         .prepare() | ||||
|  | ||||
|     fun getChapters(url: String) = db.get() | ||||
|             .listOfObjects(Chapter::class.java) | ||||
| @@ -73,23 +90,22 @@ interface ChapterQueries : DbProvider { | ||||
|     fun deleteChapters(chapters: List<Chapter>) = db.delete().objects(chapters).prepare() | ||||
|  | ||||
|     fun updateChaptersBackup(chapters: List<Chapter>) = db.put() | ||||
|             .objects(chapters) | ||||
|             .withPutResolver(ChapterBackupPutResolver()) | ||||
|             .prepare() | ||||
|         .objects(chapters) | ||||
|         .withPutResolver(ChapterBackupPutResolver()) | ||||
|         .prepare() | ||||
|  | ||||
|     fun updateChapterProgress(chapter: Chapter) = db.put() | ||||
|             .`object`(chapter) | ||||
|             .withPutResolver(ChapterProgressPutResolver()) | ||||
|             .prepare() | ||||
|         .`object`(chapter) | ||||
|         .withPutResolver(ChapterProgressPutResolver()) | ||||
|         .prepare() | ||||
|  | ||||
|     fun updateChaptersProgress(chapters: List<Chapter>) = db.put() | ||||
|             .objects(chapters) | ||||
|             .withPutResolver(ChapterProgressPutResolver()) | ||||
|             .prepare() | ||||
|         .objects(chapters) | ||||
|         .withPutResolver(ChapterProgressPutResolver()) | ||||
|         .prepare() | ||||
|  | ||||
|     fun fixChaptersSourceOrder(chapters: List<Chapter>) = db.put() | ||||
|             .objects(chapters) | ||||
|             .withPutResolver(ChapterSourceOrderPutResolver()) | ||||
|             .prepare() | ||||
|  | ||||
| } | ||||
|         .objects(chapters) | ||||
|         .withPutResolver(ChapterSourceOrderPutResolver()) | ||||
|         .prepare() | ||||
| } | ||||
|   | ||||
| @@ -8,7 +8,7 @@ import eu.kanade.tachiyomi.data.database.models.MangaChapterHistory | ||||
| import eu.kanade.tachiyomi.data.database.resolvers.HistoryLastReadPutResolver | ||||
| import eu.kanade.tachiyomi.data.database.resolvers.MangaChapterHistoryGetResolver | ||||
| import eu.kanade.tachiyomi.data.database.tables.HistoryTable | ||||
| import java.util.* | ||||
| import java.util.Date | ||||
|  | ||||
| interface HistoryQueries : DbProvider { | ||||
|  | ||||
| @@ -23,32 +23,38 @@ interface HistoryQueries : DbProvider { | ||||
|      * @param date recent date range | ||||
|      */ | ||||
|     fun getRecentManga(date: Date) = db.get() | ||||
|             .listOfObjects(MangaChapterHistory::class.java) | ||||
|             .withQuery(RawQuery.builder() | ||||
|                     .query(getRecentMangasQuery()) | ||||
|                     .args(date.time) | ||||
|                     .observesTables(HistoryTable.TABLE) | ||||
|                     .build()) | ||||
|             .withGetResolver(MangaChapterHistoryGetResolver.INSTANCE) | ||||
|             .prepare() | ||||
|         .listOfObjects(MangaChapterHistory::class.java) | ||||
|         .withQuery( | ||||
|             RawQuery.builder() | ||||
|                 .query(getRecentMangasQuery()) | ||||
|                 .args(date.time) | ||||
|                 .observesTables(HistoryTable.TABLE) | ||||
|                 .build() | ||||
|         ) | ||||
|         .withGetResolver(MangaChapterHistoryGetResolver.INSTANCE) | ||||
|         .prepare() | ||||
|  | ||||
|     fun getHistoryByMangaId(mangaId: Long) = db.get() | ||||
|             .listOfObjects(History::class.java) | ||||
|             .withQuery(RawQuery.builder() | ||||
|                     .query(getHistoryByMangaId()) | ||||
|                     .args(mangaId) | ||||
|                     .observesTables(HistoryTable.TABLE) | ||||
|                     .build()) | ||||
|             .prepare() | ||||
|         .listOfObjects(History::class.java) | ||||
|         .withQuery( | ||||
|             RawQuery.builder() | ||||
|                 .query(getHistoryByMangaId()) | ||||
|                 .args(mangaId) | ||||
|                 .observesTables(HistoryTable.TABLE) | ||||
|                 .build() | ||||
|         ) | ||||
|         .prepare() | ||||
|  | ||||
|     fun getHistoryByChapterUrl(chapterUrl: String) = db.get() | ||||
|             .`object`(History::class.java) | ||||
|             .withQuery(RawQuery.builder() | ||||
|                     .query(getHistoryByChapterUrl()) | ||||
|                     .args(chapterUrl) | ||||
|                     .observesTables(HistoryTable.TABLE) | ||||
|                     .build()) | ||||
|             .prepare() | ||||
|         .`object`(History::class.java) | ||||
|         .withQuery( | ||||
|             RawQuery.builder() | ||||
|                 .query(getHistoryByChapterUrl()) | ||||
|                 .args(chapterUrl) | ||||
|                 .observesTables(HistoryTable.TABLE) | ||||
|                 .build() | ||||
|         ) | ||||
|         .prepare() | ||||
|  | ||||
|     /** | ||||
|      * Updates the history last read. | ||||
| @@ -56,9 +62,9 @@ interface HistoryQueries : DbProvider { | ||||
|      * @param history history object | ||||
|      */ | ||||
|     fun updateHistoryLastRead(history: History) = db.put() | ||||
|             .`object`(history) | ||||
|             .withPutResolver(HistoryLastReadPutResolver()) | ||||
|             .prepare() | ||||
|         .`object`(history) | ||||
|         .withPutResolver(HistoryLastReadPutResolver()) | ||||
|         .prepare() | ||||
|  | ||||
|     /** | ||||
|      * Updates the history last read. | ||||
| @@ -66,21 +72,25 @@ interface HistoryQueries : DbProvider { | ||||
|      * @param historyList history object list | ||||
|      */ | ||||
|     fun updateHistoryLastRead(historyList: List<History>) = db.put() | ||||
|             .objects(historyList) | ||||
|             .withPutResolver(HistoryLastReadPutResolver()) | ||||
|             .prepare() | ||||
|         .objects(historyList) | ||||
|         .withPutResolver(HistoryLastReadPutResolver()) | ||||
|         .prepare() | ||||
|  | ||||
|     fun deleteHistory() = db.delete() | ||||
|             .byQuery(DeleteQuery.builder() | ||||
|                     .table(HistoryTable.TABLE) | ||||
|                     .build()) | ||||
|             .prepare() | ||||
|         .byQuery( | ||||
|             DeleteQuery.builder() | ||||
|                 .table(HistoryTable.TABLE) | ||||
|                 .build() | ||||
|         ) | ||||
|         .prepare() | ||||
|  | ||||
|     fun deleteHistoryNoLastRead() = db.delete() | ||||
|             .byQuery(DeleteQuery.builder() | ||||
|                     .table(HistoryTable.TABLE) | ||||
|                     .where("${HistoryTable.COL_LAST_READ} = ?") | ||||
|                     .whereArgs(0) | ||||
|                     .build()) | ||||
|             .prepare() | ||||
|         .byQuery( | ||||
|             DeleteQuery.builder() | ||||
|                 .table(HistoryTable.TABLE) | ||||
|                 .where("${HistoryTable.COL_LAST_READ} = ?") | ||||
|                 .whereArgs(0) | ||||
|                 .build() | ||||
|         ) | ||||
|         .prepare() | ||||
| } | ||||
|   | ||||
| @@ -15,12 +15,14 @@ interface MangaCategoryQueries : DbProvider { | ||||
|     fun insertMangasCategories(mangasCategories: List<MangaCategory>) = db.put().objects(mangasCategories).prepare() | ||||
|  | ||||
|     fun deleteOldMangasCategories(mangas: List<Manga>) = db.delete() | ||||
|             .byQuery(DeleteQuery.builder() | ||||
|                     .table(MangaCategoryTable.TABLE) | ||||
|                     .where("${MangaCategoryTable.COL_MANGA_ID} IN (${Queries.placeholders(mangas.size)})") | ||||
|                     .whereArgs(*mangas.map { it.id }.toTypedArray()) | ||||
|                     .build()) | ||||
|             .prepare() | ||||
|         .byQuery( | ||||
|             DeleteQuery.builder() | ||||
|                 .table(MangaCategoryTable.TABLE) | ||||
|                 .where("${MangaCategoryTable.COL_MANGA_ID} IN (${Queries.placeholders(mangas.size)})") | ||||
|                 .whereArgs(*mangas.map { it.id }.toTypedArray()) | ||||
|                 .build() | ||||
|         ) | ||||
|         .prepare() | ||||
|  | ||||
|     fun setMangaCategories(mangasCategories: List<MangaCategory>, mangas: List<Manga>) { | ||||
|         db.inTransaction { | ||||
| @@ -32,5 +34,4 @@ interface MangaCategoryQueries : DbProvider { | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -6,7 +6,12 @@ import com.pushtorefresh.storio.sqlite.queries.RawQuery | ||||
| import eu.kanade.tachiyomi.data.database.DbProvider | ||||
| import eu.kanade.tachiyomi.data.database.models.LibraryManga | ||||
| import eu.kanade.tachiyomi.data.database.models.Manga | ||||
| import eu.kanade.tachiyomi.data.database.resolvers.* | ||||
| import eu.kanade.tachiyomi.data.database.resolvers.LibraryMangaGetResolver | ||||
| import eu.kanade.tachiyomi.data.database.resolvers.MangaFavoritePutResolver | ||||
| import eu.kanade.tachiyomi.data.database.resolvers.MangaFlagsPutResolver | ||||
| import eu.kanade.tachiyomi.data.database.resolvers.MangaLastUpdatedPutResolver | ||||
| import eu.kanade.tachiyomi.data.database.resolvers.MangaTitlePutResolver | ||||
| import eu.kanade.tachiyomi.data.database.resolvers.MangaViewerPutResolver | ||||
| import eu.kanade.tachiyomi.data.database.tables.CategoryTable | ||||
| import eu.kanade.tachiyomi.data.database.tables.ChapterTable | ||||
| import eu.kanade.tachiyomi.data.database.tables.MangaCategoryTable | ||||
| @@ -16,107 +21,140 @@ import exh.metadata.sql.tables.SearchMetadataTable | ||||
| interface MangaQueries : DbProvider { | ||||
|  | ||||
|     fun getMangas() = db.get() | ||||
|             .listOfObjects(Manga::class.java) | ||||
|             .withQuery(Query.builder() | ||||
|                     .table(MangaTable.TABLE) | ||||
|                     .build()) | ||||
|             .prepare() | ||||
|         .listOfObjects(Manga::class.java) | ||||
|         .withQuery( | ||||
|             Query.builder() | ||||
|                 .table(MangaTable.TABLE) | ||||
|                 .build() | ||||
|         ) | ||||
|         .prepare() | ||||
|  | ||||
|     fun getLibraryMangas() = db.get() | ||||
|             .listOfObjects(LibraryManga::class.java) | ||||
|             .withQuery(RawQuery.builder() | ||||
|                     .query(libraryQuery) | ||||
|                     .observesTables(MangaTable.TABLE, ChapterTable.TABLE, MangaCategoryTable.TABLE, CategoryTable.TABLE) | ||||
|                     .build()) | ||||
|             .withGetResolver(LibraryMangaGetResolver.INSTANCE) | ||||
|             .prepare() | ||||
|         .listOfObjects(LibraryManga::class.java) | ||||
|         .withQuery( | ||||
|             RawQuery.builder() | ||||
|                 .query(libraryQuery) | ||||
|                 .observesTables(MangaTable.TABLE, ChapterTable.TABLE, MangaCategoryTable.TABLE, CategoryTable.TABLE) | ||||
|                 .build() | ||||
|         ) | ||||
|         .withGetResolver(LibraryMangaGetResolver.INSTANCE) | ||||
|         .prepare() | ||||
|  | ||||
|     fun getFavoriteMangas() = db.get() | ||||
|             .listOfObjects(Manga::class.java) | ||||
|             .withQuery(Query.builder() | ||||
|                     .table(MangaTable.TABLE) | ||||
|                     .where("${MangaTable.COL_FAVORITE} = ?") | ||||
|                     .whereArgs(1) | ||||
|                     .orderBy(MangaTable.COL_TITLE) | ||||
|                     .build()) | ||||
|             .prepare() | ||||
|         .listOfObjects(Manga::class.java) | ||||
|         .withQuery( | ||||
|             Query.builder() | ||||
|                 .table(MangaTable.TABLE) | ||||
|                 .where("${MangaTable.COL_FAVORITE} = ?") | ||||
|                 .whereArgs(1) | ||||
|                 .orderBy(MangaTable.COL_TITLE) | ||||
|                 .build() | ||||
|         ) | ||||
|         .prepare() | ||||
|  | ||||
|     fun getManga(url: String, sourceId: Long) = db.get() | ||||
|             .`object`(Manga::class.java) | ||||
|             .withQuery(Query.builder() | ||||
|                     .table(MangaTable.TABLE) | ||||
|                     .where("${MangaTable.COL_URL} = ? AND ${MangaTable.COL_SOURCE} = ?") | ||||
|                     .whereArgs(url, sourceId) | ||||
|                     .build()) | ||||
|             .prepare() | ||||
|         .`object`(Manga::class.java) | ||||
|         .withQuery( | ||||
|             Query.builder() | ||||
|                 .table(MangaTable.TABLE) | ||||
|                 .where("${MangaTable.COL_URL} = ? AND ${MangaTable.COL_SOURCE} = ?") | ||||
|                 .whereArgs(url, sourceId) | ||||
|                 .build() | ||||
|         ) | ||||
|         .prepare() | ||||
|  | ||||
|     fun getManga(id: Long) = db.get() | ||||
|             .`object`(Manga::class.java) | ||||
|             .withQuery(Query.builder() | ||||
|                     .table(MangaTable.TABLE) | ||||
|                     .where("${MangaTable.COL_ID} = ?") | ||||
|                     .whereArgs(id) | ||||
|                     .build()) | ||||
|             .prepare() | ||||
|         .`object`(Manga::class.java) | ||||
|         .withQuery( | ||||
|             Query.builder() | ||||
|                 .table(MangaTable.TABLE) | ||||
|                 .where("${MangaTable.COL_ID} = ?") | ||||
|                 .whereArgs(id) | ||||
|                 .build() | ||||
|         ) | ||||
|         .prepare() | ||||
|  | ||||
|     fun insertManga(manga: Manga) = db.put().`object`(manga).prepare() | ||||
|  | ||||
|     fun insertMangas(mangas: List<Manga>) = db.put().objects(mangas).prepare() | ||||
|  | ||||
|     fun updateFlags(manga: Manga) = db.put() | ||||
|             .`object`(manga) | ||||
|             .withPutResolver(MangaFlagsPutResolver()) | ||||
|             .prepare() | ||||
|         .`object`(manga) | ||||
|         .withPutResolver(MangaFlagsPutResolver()) | ||||
|         .prepare() | ||||
|  | ||||
|     fun updateLastUpdated(manga: Manga) = db.put() | ||||
|             .`object`(manga) | ||||
|             .withPutResolver(MangaLastUpdatedPutResolver()) | ||||
|             .prepare() | ||||
|         .`object`(manga) | ||||
|         .withPutResolver(MangaLastUpdatedPutResolver()) | ||||
|         .prepare() | ||||
|  | ||||
|     fun updateMangaFavorite(manga: Manga) = db.put() | ||||
|             .`object`(manga) | ||||
|             .withPutResolver(MangaFavoritePutResolver()) | ||||
|             .prepare() | ||||
|         .`object`(manga) | ||||
|         .withPutResolver(MangaFavoritePutResolver()) | ||||
|         .prepare() | ||||
|  | ||||
|     fun updateMangaViewer(manga: Manga) = db.put() | ||||
|             .`object`(manga) | ||||
|             .withPutResolver(MangaViewerPutResolver()) | ||||
|             .prepare() | ||||
|         .`object`(manga) | ||||
|         .withPutResolver(MangaViewerPutResolver()) | ||||
|         .prepare() | ||||
|  | ||||
|     fun updateMangaTitle(manga: Manga) = db.put() | ||||
|             .`object`(manga) | ||||
|             .withPutResolver(MangaTitlePutResolver()) | ||||
|             .prepare() | ||||
|         .`object`(manga) | ||||
|         .withPutResolver(MangaTitlePutResolver()) | ||||
|         .prepare() | ||||
|  | ||||
|     fun deleteManga(manga: Manga) = db.delete().`object`(manga).prepare() | ||||
|  | ||||
|     fun deleteMangas(mangas: List<Manga>) = db.delete().objects(mangas).prepare() | ||||
|  | ||||
|     fun deleteMangasNotInLibrary() = db.delete() | ||||
|             .byQuery(DeleteQuery.builder() | ||||
|                     .table(MangaTable.TABLE) | ||||
|                     .where("${MangaTable.COL_FAVORITE} = ?") | ||||
|                     .whereArgs(0) | ||||
|                     .build()) | ||||
|             .prepare() | ||||
|         .byQuery( | ||||
|             DeleteQuery.builder() | ||||
|                 .table(MangaTable.TABLE) | ||||
|                 .where("${MangaTable.COL_FAVORITE} = ?") | ||||
|                 .whereArgs(0) | ||||
|                 .build() | ||||
|         ) | ||||
|         .prepare() | ||||
|  | ||||
|     fun deleteMangas() = db.delete() | ||||
|             .byQuery(DeleteQuery.builder() | ||||
|                     .table(MangaTable.TABLE) | ||||
|                     .build()) | ||||
|             .prepare() | ||||
|         .byQuery( | ||||
|             DeleteQuery.builder() | ||||
|                 .table(MangaTable.TABLE) | ||||
|                 .build() | ||||
|         ) | ||||
|         .prepare() | ||||
|  | ||||
|     fun getLastReadManga() = db.get() | ||||
|             .listOfObjects(Manga::class.java) | ||||
|             .withQuery(RawQuery.builder() | ||||
|                     .query(getLastReadMangaQuery()) | ||||
|                     .observesTables(MangaTable.TABLE) | ||||
|                     .build()) | ||||
|             .prepare() | ||||
|         .listOfObjects(Manga::class.java) | ||||
|         .withQuery( | ||||
|             RawQuery.builder() | ||||
|                 .query(getLastReadMangaQuery()) | ||||
|                 .observesTables(MangaTable.TABLE) | ||||
|                 .build() | ||||
|         ) | ||||
|         .prepare() | ||||
|  | ||||
|     fun getTotalChapterManga() = db.get().listOfObjects(Manga::class.java) | ||||
|             .withQuery(RawQuery.builder().query(getTotalChapterMangaQuery()).observesTables(MangaTable.TABLE).build()).prepare(); | ||||
|     fun getTotalChapterManga() = db.get() | ||||
|         .listOfObjects(Manga::class.java) | ||||
|         .withQuery( | ||||
|             RawQuery.builder() | ||||
|                 .query(getTotalChapterMangaQuery()) | ||||
|                 .observesTables(MangaTable.TABLE) | ||||
|                 .build() | ||||
|         ) | ||||
|         .prepare() | ||||
|  | ||||
|     fun getLatestChapterManga() = db.get() | ||||
|         .listOfObjects(Manga::class.java) | ||||
|         .withQuery( | ||||
|             RawQuery.builder() | ||||
|                 .query(getLatestChapterMangaQuery()) | ||||
|                 .observesTables(MangaTable.TABLE) | ||||
|                 .build() | ||||
|         ) | ||||
|         .prepare() | ||||
|          | ||||
|     fun getMangaWithMetadata() = db.get() | ||||
|             .listOfObjects(Manga::class.java) | ||||
|             .withQuery(RawQuery.builder() | ||||
|   | ||||
| @@ -9,7 +9,8 @@ import eu.kanade.tachiyomi.data.database.tables.MangaTable as Manga | ||||
| /** | ||||
|  * Query to get the manga from the library, with their categories and unread count. | ||||
|  */ | ||||
| val libraryQuery = """ | ||||
| val libraryQuery = | ||||
|     """ | ||||
|     SELECT M.*, COALESCE(MC.${MangaCategory.COL_CATEGORY_ID}, 0) AS ${Manga.COL_CATEGORY} | ||||
|     FROM ( | ||||
|         SELECT ${Manga.TABLE}.*, COALESCE(C.unread, 0) AS ${Manga.COL_UNREAD} | ||||
| @@ -33,7 +34,8 @@ val libraryQuery = """ | ||||
| /** | ||||
|  * Query to get the recent chapters of manga from the library up to a date. | ||||
|  */ | ||||
| fun getRecentsQuery() = """ | ||||
| fun getRecentsQuery() = | ||||
|     """ | ||||
|     SELECT ${Manga.TABLE}.${Manga.COL_URL} as mangaUrl, * FROM ${Manga.TABLE} JOIN ${Chapter.TABLE} | ||||
|     ON ${Manga.TABLE}.${Manga.COL_ID} = ${Chapter.TABLE}.${Chapter.COL_MANGA_ID} | ||||
|     WHERE ${Manga.COL_FAVORITE} = 1 AND ${Chapter.COL_DATE_UPLOAD} > ? | ||||
| @@ -47,7 +49,8 @@ fun getRecentsQuery() = """ | ||||
|  * and are read after the given time period | ||||
|  * @return return limit is 25 | ||||
|  */ | ||||
| fun getRecentMangasQuery() = """ | ||||
| fun getRecentMangasQuery() = | ||||
|     """ | ||||
|     SELECT ${Manga.TABLE}.${Manga.COL_URL} as mangaUrl, ${Manga.TABLE}.*, ${Chapter.TABLE}.*, ${History.TABLE}.* | ||||
|     FROM ${Manga.TABLE} | ||||
|     JOIN ${Chapter.TABLE} | ||||
| @@ -65,7 +68,8 @@ fun getRecentMangasQuery() = """ | ||||
|     LIMIT 25 | ||||
| """ | ||||
|  | ||||
| fun getHistoryByMangaId() = """ | ||||
| fun getHistoryByMangaId() = | ||||
|     """ | ||||
|     SELECT ${History.TABLE}.* | ||||
|     FROM ${History.TABLE} | ||||
|     JOIN ${Chapter.TABLE} | ||||
| @@ -73,7 +77,8 @@ fun getHistoryByMangaId() = """ | ||||
|     WHERE ${Chapter.TABLE}.${Chapter.COL_MANGA_ID} = ? AND ${History.TABLE}.${History.COL_CHAPTER_ID} = ${Chapter.TABLE}.${Chapter.COL_ID} | ||||
| """ | ||||
|  | ||||
| fun getHistoryByChapterUrl() = """ | ||||
| fun getHistoryByChapterUrl() = | ||||
|     """ | ||||
|     SELECT ${History.TABLE}.* | ||||
|     FROM ${History.TABLE} | ||||
|     JOIN ${Chapter.TABLE} | ||||
| @@ -81,7 +86,8 @@ fun getHistoryByChapterUrl() = """ | ||||
|     WHERE ${Chapter.TABLE}.${Chapter.COL_URL} = ? AND ${History.TABLE}.${History.COL_CHAPTER_ID} = ${Chapter.TABLE}.${Chapter.COL_ID} | ||||
| """ | ||||
|  | ||||
| fun getLastReadMangaQuery() = """ | ||||
| fun getLastReadMangaQuery() = | ||||
|     """ | ||||
|     SELECT ${Manga.TABLE}.*, MAX(${History.TABLE}.${History.COL_LAST_READ}) AS max | ||||
|     FROM ${Manga.TABLE} | ||||
|     JOIN ${Chapter.TABLE} | ||||
| @@ -93,7 +99,8 @@ fun getLastReadMangaQuery() = """ | ||||
|     ORDER BY max DESC | ||||
| """ | ||||
|  | ||||
| fun getTotalChapterMangaQuery()= """ | ||||
| fun getTotalChapterMangaQuery() = | ||||
|     """ | ||||
|     SELECT ${Manga.TABLE}.* | ||||
|     FROM ${Manga.TABLE} | ||||
|     JOIN ${Chapter.TABLE} | ||||
| @@ -102,12 +109,23 @@ fun getTotalChapterMangaQuery()= """ | ||||
|     ORDER by COUNT(*) | ||||
| """ | ||||
|  | ||||
| fun getLatestChapterMangaQuery() = | ||||
|     """ | ||||
|     SELECT ${Manga.TABLE}.*, MAX(${Chapter.TABLE}.${Chapter.COL_DATE_UPLOAD}) AS max | ||||
|     FROM ${Manga.TABLE} | ||||
|     JOIN ${Chapter.TABLE} | ||||
|     ON ${Manga.TABLE}.${Manga.COL_ID} = ${Chapter.TABLE}.${Chapter.COL_MANGA_ID} | ||||
|     GROUP BY ${Manga.TABLE}.${Manga.COL_ID} | ||||
|     ORDER by max DESC | ||||
| """ | ||||
|  | ||||
| /** | ||||
|  * Query to get the categories for a manga. | ||||
|  */ | ||||
| fun getCategoriesForMangaQuery() = """ | ||||
| fun getCategoriesForMangaQuery() = | ||||
|     """ | ||||
|     SELECT ${Category.TABLE}.* FROM ${Category.TABLE} | ||||
|     JOIN ${MangaCategory.TABLE} ON ${Category.TABLE}.${Category.COL_ID} = | ||||
|     ${MangaCategory.TABLE}.${MangaCategory.COL_CATEGORY_ID} | ||||
|     WHERE ${MangaCategory.COL_MANGA_ID} = ? | ||||
| """ | ||||
| """ | ||||
|   | ||||
| @@ -1,34 +1,37 @@ | ||||
| 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() | ||||
|  | ||||
| } | ||||
| 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() | ||||
| } | ||||
|   | ||||
| @@ -20,16 +20,14 @@ class ChapterBackupPutResolver : PutResolver<Chapter>() { | ||||
|     } | ||||
|  | ||||
|     fun mapToUpdateQuery(chapter: Chapter) = UpdateQuery.builder() | ||||
|             .table(ChapterTable.TABLE) | ||||
|             .where("${ChapterTable.COL_URL} = ?") | ||||
|             .whereArgs(chapter.url) | ||||
|             .build() | ||||
|         .table(ChapterTable.TABLE) | ||||
|         .where("${ChapterTable.COL_URL} = ?") | ||||
|         .whereArgs(chapter.url) | ||||
|         .build() | ||||
|  | ||||
|     fun mapToContentValues(chapter: Chapter) = ContentValues(3).apply { | ||||
|         put(ChapterTable.COL_READ, chapter.read) | ||||
|         put(ChapterTable.COL_BOOKMARK, chapter.bookmark) | ||||
|         put(ChapterTable.COL_LAST_PAGE_READ, chapter.last_page_read) | ||||
|     } | ||||
|  | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -20,16 +20,14 @@ class ChapterProgressPutResolver : PutResolver<Chapter>() { | ||||
|     } | ||||
|  | ||||
|     fun mapToUpdateQuery(chapter: Chapter) = UpdateQuery.builder() | ||||
|             .table(ChapterTable.TABLE) | ||||
|             .where("${ChapterTable.COL_ID} = ?") | ||||
|             .whereArgs(chapter.id) | ||||
|             .build() | ||||
|         .table(ChapterTable.TABLE) | ||||
|         .where("${ChapterTable.COL_ID} = ?") | ||||
|         .whereArgs(chapter.id) | ||||
|         .build() | ||||
|  | ||||
|     fun mapToContentValues(chapter: Chapter) = ContentValues(3).apply { | ||||
|         put(ChapterTable.COL_READ, chapter.read) | ||||
|         put(ChapterTable.COL_BOOKMARK, chapter.bookmark) | ||||
|         put(ChapterTable.COL_LAST_PAGE_READ, chapter.last_page_read) | ||||
|     } | ||||
|  | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -20,13 +20,12 @@ class ChapterSourceOrderPutResolver : PutResolver<Chapter>() { | ||||
|     } | ||||
|  | ||||
|     fun mapToUpdateQuery(chapter: Chapter) = UpdateQuery.builder() | ||||
|             .table(ChapterTable.TABLE) | ||||
|             .where("${ChapterTable.COL_URL} = ? AND ${ChapterTable.COL_MANGA_ID} = ?") | ||||
|             .whereArgs(chapter.url, chapter.manga_id) | ||||
|             .build() | ||||
|         .table(ChapterTable.TABLE) | ||||
|         .where("${ChapterTable.COL_URL} = ? AND ${ChapterTable.COL_MANGA_ID} = ?") | ||||
|         .whereArgs(chapter.url, chapter.manga_id) | ||||
|         .build() | ||||
|  | ||||
|     fun mapToContentValues(chapter: Chapter) = ContentValues(1).apply { | ||||
|         put(ChapterTable.COL_SOURCE_ORDER, chapter.source_order) | ||||
|     } | ||||
|  | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -19,25 +19,25 @@ class HistoryLastReadPutResolver : HistoryPutResolver() { | ||||
|     override fun performPut(@NonNull db: StorIOSQLite, @NonNull history: History): PutResult = db.inTransactionReturn { | ||||
|         val updateQuery = mapToUpdateQuery(history) | ||||
|  | ||||
|         val cursor = db.lowLevel().query(Query.builder() | ||||
|         val cursor = db.lowLevel().query( | ||||
|             Query.builder() | ||||
|                 .table(updateQuery.table()) | ||||
|                 .where(updateQuery.where()) | ||||
|                 .whereArgs(updateQuery.whereArgs()) | ||||
|                 .build()) | ||||
|                 .build() | ||||
|         ) | ||||
|  | ||||
|         val putResult: PutResult | ||||
|  | ||||
|         try { | ||||
|             if (cursor.count == 0) { | ||||
|         putResult = cursor.use { putCursor -> | ||||
|             if (putCursor.count == 0) { | ||||
|                 val insertQuery = mapToInsertQuery(history) | ||||
|                 val insertedId = db.lowLevel().insert(insertQuery, mapToContentValues(history)) | ||||
|                 putResult = PutResult.newInsertResult(insertedId, insertQuery.table()) | ||||
|                 PutResult.newInsertResult(insertedId, insertQuery.table()) | ||||
|             } else { | ||||
|                 val numberOfRowsUpdated = db.lowLevel().update(updateQuery, mapToUpdateContentValues(history)) | ||||
|                 putResult = PutResult.newUpdateResult(numberOfRowsUpdated, updateQuery.table()) | ||||
|                 PutResult.newUpdateResult(numberOfRowsUpdated, updateQuery.table()) | ||||
|             } | ||||
|         } finally { | ||||
|             cursor.close() | ||||
|         } | ||||
|  | ||||
|         putResult | ||||
| @@ -48,10 +48,10 @@ class HistoryLastReadPutResolver : HistoryPutResolver() { | ||||
|      * @param obj history object | ||||
|      */ | ||||
|     override fun mapToUpdateQuery(obj: History) = UpdateQuery.builder() | ||||
|             .table(HistoryTable.TABLE) | ||||
|             .where("${HistoryTable.COL_CHAPTER_ID} = ?") | ||||
|             .whereArgs(obj.chapter_id) | ||||
|             .build() | ||||
|         .table(HistoryTable.TABLE) | ||||
|         .where("${HistoryTable.COL_CHAPTER_ID} = ?") | ||||
|         .whereArgs(obj.chapter_id) | ||||
|         .build() | ||||
|  | ||||
|     /** | ||||
|      * Create content query | ||||
| @@ -60,5 +60,4 @@ class HistoryLastReadPutResolver : HistoryPutResolver() { | ||||
|     fun mapToUpdateContentValues(history: History) = ContentValues(1).apply { | ||||
|         put(HistoryTable.COL_LAST_READ, history.last_read) | ||||
|     } | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -21,5 +21,4 @@ class LibraryMangaGetResolver : DefaultGetResolver<LibraryManga>(), BaseMangaGet | ||||
|  | ||||
|         return manga | ||||
|     } | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -24,5 +24,4 @@ class MangaChapterGetResolver : DefaultGetResolver<MangaChapter>() { | ||||
|  | ||||
|         return MangaChapter(manga, chapter) | ||||
|     } | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -20,14 +20,12 @@ class MangaFavoritePutResolver : PutResolver<Manga>() { | ||||
|     } | ||||
|  | ||||
|     fun mapToUpdateQuery(manga: Manga) = UpdateQuery.builder() | ||||
|             .table(MangaTable.TABLE) | ||||
|             .where("${MangaTable.COL_ID} = ?") | ||||
|             .whereArgs(manga.id) | ||||
|             .build() | ||||
|         .table(MangaTable.TABLE) | ||||
|         .where("${MangaTable.COL_ID} = ?") | ||||
|         .whereArgs(manga.id) | ||||
|         .build() | ||||
|  | ||||
|     fun mapToContentValues(manga: Manga) = ContentValues(1).apply { | ||||
|         put(MangaTable.COL_FAVORITE, manga.favorite) | ||||
|     } | ||||
|  | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -20,14 +20,12 @@ class MangaFlagsPutResolver : PutResolver<Manga>() { | ||||
|     } | ||||
|  | ||||
|     fun mapToUpdateQuery(manga: Manga) = UpdateQuery.builder() | ||||
|             .table(MangaTable.TABLE) | ||||
|             .where("${MangaTable.COL_ID} = ?") | ||||
|             .whereArgs(manga.id) | ||||
|             .build() | ||||
|         .table(MangaTable.TABLE) | ||||
|         .where("${MangaTable.COL_ID} = ?") | ||||
|         .whereArgs(manga.id) | ||||
|         .build() | ||||
|  | ||||
|     fun mapToContentValues(manga: Manga) = ContentValues(1).apply { | ||||
|         put(MangaTable.COL_CHAPTER_FLAGS, manga.chapter_flags) | ||||
|     } | ||||
|  | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -20,14 +20,12 @@ class MangaLastUpdatedPutResolver : PutResolver<Manga>() { | ||||
|     } | ||||
|  | ||||
|     fun mapToUpdateQuery(manga: Manga) = UpdateQuery.builder() | ||||
|             .table(MangaTable.TABLE) | ||||
|             .where("${MangaTable.COL_ID} = ?") | ||||
|             .whereArgs(manga.id) | ||||
|             .build() | ||||
|         .table(MangaTable.TABLE) | ||||
|         .where("${MangaTable.COL_ID} = ?") | ||||
|         .whereArgs(manga.id) | ||||
|         .build() | ||||
|  | ||||
|     fun mapToContentValues(manga: Manga) = ContentValues(1).apply { | ||||
|         put(MangaTable.COL_LAST_UPDATE, manga.last_update) | ||||
|     } | ||||
|  | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -20,13 +20,12 @@ class MangaTitlePutResolver : PutResolver<Manga>() { | ||||
|     } | ||||
|  | ||||
|     fun mapToUpdateQuery(manga: Manga) = UpdateQuery.builder() | ||||
|             .table(MangaTable.TABLE) | ||||
|             .where("${MangaTable.COL_ID} = ?") | ||||
|             .whereArgs(manga.id) | ||||
|             .build() | ||||
|         .table(MangaTable.TABLE) | ||||
|         .where("${MangaTable.COL_ID} = ?") | ||||
|         .whereArgs(manga.id) | ||||
|         .build() | ||||
|  | ||||
|     fun mapToContentValues(manga: Manga) = ContentValues(1).apply { | ||||
|         put(MangaTable.COL_TITLE, manga.title) | ||||
|     } | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -20,13 +20,12 @@ class MangaViewerPutResolver : PutResolver<Manga>() { | ||||
|     } | ||||
|  | ||||
|     fun mapToUpdateQuery(manga: Manga) = UpdateQuery.builder() | ||||
|             .table(MangaTable.TABLE) | ||||
|             .where("${MangaTable.COL_ID} = ?") | ||||
|             .whereArgs(manga.id) | ||||
|             .build() | ||||
|         .table(MangaTable.TABLE) | ||||
|         .where("${MangaTable.COL_ID} = ?") | ||||
|         .whereArgs(manga.id) | ||||
|         .build() | ||||
|  | ||||
|     fun mapToContentValues(manga: Manga) = ContentValues(1).apply { | ||||
|         put(MangaTable.COL_VIEWER, manga.viewer) | ||||
|     } | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -13,11 +13,11 @@ object CategoryTable { | ||||
|     const val COL_FLAGS = "flags" | ||||
|  | ||||
|     val createTableQuery: String | ||||
|         get() = """CREATE TABLE $TABLE( | ||||
|         get() = | ||||
|             """CREATE TABLE $TABLE( | ||||
|             $COL_ID INTEGER NOT NULL PRIMARY KEY, | ||||
|             $COL_NAME TEXT NOT NULL, | ||||
|             $COL_ORDER INTEGER NOT NULL, | ||||
|             $COL_FLAGS INTEGER NOT NULL | ||||
|             )""" | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -29,7 +29,8 @@ object ChapterTable { | ||||
|     const val COL_SOURCE_ORDER = "source_order" | ||||
|  | ||||
|     val createTableQuery: String | ||||
|         get() = """CREATE TABLE $TABLE( | ||||
|         get() = | ||||
|             """CREATE TABLE $TABLE( | ||||
|             $COL_ID INTEGER NOT NULL PRIMARY KEY, | ||||
|             $COL_MANGA_ID INTEGER NOT NULL, | ||||
|             $COL_URL TEXT NOT NULL, | ||||
| @@ -51,7 +52,7 @@ object ChapterTable { | ||||
|  | ||||
|     val createUnreadChaptersIndexQuery: String | ||||
|         get() = "CREATE INDEX ${TABLE}_unread_by_manga_index ON $TABLE($COL_MANGA_ID, $COL_READ) " + | ||||
|                 "WHERE $COL_READ = 0" | ||||
|             "WHERE $COL_READ = 0" | ||||
|  | ||||
|     val sourceOrderUpdateQuery: String | ||||
|         get() = "ALTER TABLE $TABLE ADD COLUMN $COL_SOURCE_ORDER INTEGER DEFAULT 0" | ||||
| @@ -61,5 +62,4 @@ object ChapterTable { | ||||
|  | ||||
|     val addScanlator: String | ||||
|         get() = "ALTER TABLE $TABLE ADD COLUMN $COL_SCANLATOR TEXT DEFAULT NULL" | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -31,7 +31,8 @@ object HistoryTable { | ||||
|      * query to create history table | ||||
|      */ | ||||
|     val createTableQuery: String | ||||
|         get() = """CREATE TABLE $TABLE( | ||||
|         get() = | ||||
|             """CREATE TABLE $TABLE( | ||||
|             $COL_ID INTEGER NOT NULL PRIMARY KEY, | ||||
|             $COL_CHAPTER_ID INTEGER NOT NULL UNIQUE, | ||||
|             $COL_LAST_READ LONG, | ||||
|   | ||||
| @@ -11,7 +11,8 @@ object MangaCategoryTable { | ||||
|     const val COL_CATEGORY_ID = "category_id" | ||||
|  | ||||
|     val createTableQuery: String | ||||
|         get() = """CREATE TABLE $TABLE( | ||||
|         get() = | ||||
|             """CREATE TABLE $TABLE( | ||||
|             $COL_ID INTEGER NOT NULL PRIMARY KEY, | ||||
|             $COL_MANGA_ID INTEGER NOT NULL, | ||||
|             $COL_CATEGORY_ID INTEGER NOT NULL, | ||||
| @@ -20,5 +21,4 @@ object MangaCategoryTable { | ||||
|             FOREIGN KEY($COL_MANGA_ID) REFERENCES ${MangaTable.TABLE} (${MangaTable.COL_ID}) | ||||
|             ON DELETE CASCADE | ||||
|             )""" | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -39,7 +39,8 @@ object MangaTable { | ||||
|     const val COL_CATEGORY = "category" | ||||
|  | ||||
|     val createTableQuery: String | ||||
|         get() = """CREATE TABLE $TABLE( | ||||
|         get() = | ||||
|             """CREATE TABLE $TABLE( | ||||
|             $COL_ID INTEGER NOT NULL PRIMARY KEY, | ||||
|             $COL_SOURCE INTEGER NOT NULL, | ||||
|             $COL_URL TEXT NOT NULL, | ||||
| @@ -62,5 +63,5 @@ object MangaTable { | ||||
|  | ||||
|     val createLibraryIndexQuery: String | ||||
|         get() = "CREATE INDEX library_${COL_FAVORITE}_index ON $TABLE($COL_FAVORITE) " + | ||||
|                 "WHERE $COL_FAVORITE = 1" | ||||
|             "WHERE $COL_FAVORITE = 1" | ||||
| } | ||||
|   | ||||
| @@ -26,8 +26,13 @@ object TrackTable { | ||||
|  | ||||
|     const val COL_TRACKING_URL = "remote_url" | ||||
|  | ||||
|     const val COL_START_DATE = "start_date" | ||||
|  | ||||
|     const val COL_FINISH_DATE = "finish_date" | ||||
|  | ||||
|     val createTableQuery: String | ||||
|         get() = """CREATE TABLE $TABLE( | ||||
|         get() = | ||||
|             """CREATE TABLE $TABLE( | ||||
|             $COL_ID INTEGER NOT NULL PRIMARY KEY, | ||||
|             $COL_MANGA_ID INTEGER NOT NULL, | ||||
|             $COL_SYNC_ID INTEGER NOT NULL, | ||||
| @@ -39,6 +44,8 @@ object TrackTable { | ||||
|             $COL_STATUS INTEGER NOT NULL, | ||||
|             $COL_SCORE FLOAT NOT NULL, | ||||
|             $COL_TRACKING_URL TEXT NOT NULL, | ||||
|             $COL_START_DATE LONG NOT NULL, | ||||
|             $COL_FINISH_DATE LONG NOT NULL, | ||||
|             UNIQUE ($COL_MANGA_ID, $COL_SYNC_ID) ON CONFLICT REPLACE, | ||||
|             FOREIGN KEY($COL_MANGA_ID) REFERENCES ${MangaTable.TABLE} (${MangaTable.COL_ID}) | ||||
|             ON DELETE CASCADE | ||||
| @@ -49,4 +56,10 @@ object TrackTable { | ||||
|  | ||||
|     val addLibraryId: String | ||||
|         get() = "ALTER TABLE $TABLE ADD COLUMN $COL_LIBRARY_ID INTEGER NULL" | ||||
|  | ||||
|     val addStartDate: String | ||||
|         get() = "ALTER TABLE $TABLE ADD COLUMN $COL_START_DATE LONG NOT NULL DEFAULT 0" | ||||
|  | ||||
|     val addFinishDate: String | ||||
|         get() = "ALTER TABLE $TABLE ADD COLUMN $COL_FINISH_DATE LONG NOT NULL DEFAULT 0" | ||||
| } | ||||
|   | ||||
| @@ -6,11 +6,11 @@ import com.hippo.unifile.UniFile | ||||
| import eu.kanade.tachiyomi.data.database.models.Chapter | ||||
| import eu.kanade.tachiyomi.data.database.models.Manga | ||||
| import eu.kanade.tachiyomi.data.preference.PreferencesHelper | ||||
| import eu.kanade.tachiyomi.data.preference.getOrDefault | ||||
| import eu.kanade.tachiyomi.source.SourceManager | ||||
| import java.util.concurrent.TimeUnit | ||||
| import kotlinx.coroutines.flow.onEach | ||||
| import uy.kohesive.injekt.Injekt | ||||
| import uy.kohesive.injekt.api.get | ||||
| import java.util.concurrent.TimeUnit | ||||
|  | ||||
| /** | ||||
|  * Cache where we dump the downloads directory from the filesystem. This class is needed because | ||||
| @@ -24,10 +24,10 @@ import java.util.concurrent.TimeUnit | ||||
|  * @param preferences the preferences of the app. | ||||
|  */ | ||||
| class DownloadCache( | ||||
|         private val context: Context, | ||||
|         private val provider: DownloadProvider, | ||||
|         private val sourceManager: SourceManager, | ||||
|         private val preferences: PreferencesHelper = Injekt.get() | ||||
|     private val context: Context, | ||||
|     private val provider: DownloadProvider, | ||||
|     private val sourceManager: SourceManager, | ||||
|     private val preferences: PreferencesHelper = Injekt.get() | ||||
| ) { | ||||
|  | ||||
|     /** | ||||
| @@ -47,19 +47,18 @@ class DownloadCache( | ||||
|     private var rootDir = RootDirectory(getDirectoryFromPreference()) | ||||
|  | ||||
|     init { | ||||
|         preferences.downloadsDirectory().asObservable() | ||||
|                 .skip(1) | ||||
|                 .subscribe { | ||||
|                     lastRenew = 0L // invalidate cache | ||||
|                     rootDir = RootDirectory(getDirectoryFromPreference()) | ||||
|                 } | ||||
|         preferences.downloadsDirectory().asFlow() | ||||
|             .onEach { | ||||
|                 lastRenew = 0L // invalidate cache | ||||
|                 rootDir = RootDirectory(getDirectoryFromPreference()) | ||||
|             } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns the downloads directory from the user's preferences. | ||||
|      */ | ||||
|     private fun getDirectoryFromPreference(): UniFile { | ||||
|         val dir = preferences.downloadsDirectory().getOrDefault() | ||||
|         val dir = preferences.downloadsDirectory().get() | ||||
|         return UniFile.fromUri(context, Uri.parse(dir)) | ||||
|     } | ||||
|  | ||||
| @@ -100,7 +99,9 @@ class DownloadCache( | ||||
|         if (sourceDir != null) { | ||||
|             val mangaDir = sourceDir.files[provider.getMangaDirName(manga)] | ||||
|             if (mangaDir != null) { | ||||
|                 return mangaDir.files.size | ||||
|                 return mangaDir.files | ||||
|                     .filter { !it.endsWith(Downloader.TMP_DIR_SUFFIX) } | ||||
|                     .size | ||||
|             } | ||||
|         } | ||||
|         return 0 | ||||
| @@ -124,26 +125,26 @@ class DownloadCache( | ||||
|         val onlineSources = sourceManager.getOnlineSources() | ||||
|  | ||||
|         val sourceDirs = rootDir.dir.listFiles() | ||||
|                 .orEmpty() | ||||
|                 .associate { it.name to SourceDirectory(it) } | ||||
|                 .mapNotNullKeys { entry -> | ||||
|                     onlineSources.find { provider.getSourceDirName(it) == entry.key }?.id | ||||
|                 } | ||||
|             .orEmpty() | ||||
|             .associate { it.name to SourceDirectory(it) } | ||||
|             .mapNotNullKeys { entry -> | ||||
|                 onlineSources.find { provider.getSourceDirName(it) == entry.key }?.id | ||||
|             } | ||||
|  | ||||
|         rootDir.files = sourceDirs | ||||
|  | ||||
|         sourceDirs.values.forEach { sourceDir -> | ||||
|             val mangaDirs = sourceDir.dir.listFiles() | ||||
|                     .orEmpty() | ||||
|                     .associateNotNullKeys { it.name to MangaDirectory(it) } | ||||
|                 .orEmpty() | ||||
|                 .associateNotNullKeys { it.name to MangaDirectory(it) } | ||||
|  | ||||
|             sourceDir.files = mangaDirs | ||||
|  | ||||
|             mangaDirs.values.forEach { mangaDir -> | ||||
|                 val chapterDirs = mangaDir.dir.listFiles() | ||||
|                         .orEmpty() | ||||
|                         .mapNotNull { it.name } | ||||
|                         .toHashSet() | ||||
|                     .orEmpty() | ||||
|                     .mapNotNull { it.name } | ||||
|                     .toHashSet() | ||||
|  | ||||
|                 mangaDir.files = chapterDirs | ||||
|             } | ||||
| @@ -231,27 +232,33 @@ class DownloadCache( | ||||
|     /** | ||||
|      * Class to store the files under the root downloads directory. | ||||
|      */ | ||||
|     private class RootDirectory(val dir: UniFile, | ||||
|                                 var files: Map<Long, SourceDirectory> = hashMapOf()) | ||||
|     private class RootDirectory( | ||||
|         val dir: UniFile, | ||||
|         var files: Map<Long, SourceDirectory> = hashMapOf() | ||||
|     ) | ||||
|  | ||||
|     /** | ||||
|      * Class to store the files under a source directory. | ||||
|      */ | ||||
|     private class SourceDirectory(val dir: UniFile, | ||||
|                                   var files: Map<String, MangaDirectory> = hashMapOf()) | ||||
|     private class SourceDirectory( | ||||
|         val dir: UniFile, | ||||
|         var files: Map<String, MangaDirectory> = hashMapOf() | ||||
|     ) | ||||
|  | ||||
|     /** | ||||
|      * Class to store the files under a manga directory. | ||||
|      */ | ||||
|     private class MangaDirectory(val dir: UniFile, | ||||
|                                  var files: Set<String> = hashSetOf()) | ||||
|     private class MangaDirectory( | ||||
|         val dir: UniFile, | ||||
|         var files: Set<String> = hashSetOf() | ||||
|     ) | ||||
|  | ||||
|     /** | ||||
|      * Returns a new map containing only the key entries of [transform] that are not null. | ||||
|      */ | ||||
|     private inline fun <K, V, R> Map<out K, V>.mapNotNullKeys(transform: (Map.Entry<K?, V>) -> R?): Map<R, V> { | ||||
|         val destination = LinkedHashMap<R, V>() | ||||
|         forEach { element -> transform(element)?.let { destination.put(it, element.value) } } | ||||
|         forEach { element -> transform(element)?.let { destination[it] = element.value } } | ||||
|         return destination | ||||
|     } | ||||
|  | ||||
| @@ -263,10 +270,9 @@ class DownloadCache( | ||||
|         for (element in this) { | ||||
|             val (key, value) = transform(element) | ||||
|             if (key != null) { | ||||
|                 destination.put(key, value) | ||||
|                 destination[key] = value | ||||
|             } | ||||
|         } | ||||
|         return destination | ||||
|     } | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -20,7 +20,7 @@ import uy.kohesive.injekt.injectLazy | ||||
|  * | ||||
|  * @param context the application context. | ||||
|  */ | ||||
| class DownloadManager(context: Context) { | ||||
| class DownloadManager(private val context: Context) { | ||||
|  | ||||
|     /** | ||||
|      * The sources manager. | ||||
| @@ -99,10 +99,21 @@ class DownloadManager(context: Context) { | ||||
|      * @param downloads value to set the download queue to | ||||
|      */ | ||||
|     fun reorderQueue(downloads: List<Download>) { | ||||
|         val wasRunning = downloader.isRunning | ||||
|  | ||||
|         if (downloads.isEmpty()) { | ||||
|             DownloadService.stop(context) | ||||
|             downloader.queue.clear() | ||||
|             return | ||||
|         } | ||||
|  | ||||
|         downloader.pause() | ||||
|         downloader.queue.clear() | ||||
|         downloader.queue.addAll(downloads) | ||||
|         downloader.start() | ||||
|  | ||||
|         if (wasRunning) { | ||||
|             downloader.start() | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @@ -137,16 +148,16 @@ class DownloadManager(context: Context) { | ||||
|     private fun buildPageList(chapterDir: UniFile?): Observable<List<Page>> { | ||||
|         return Observable.fromCallable { | ||||
|             val files = chapterDir?.listFiles().orEmpty() | ||||
|                     .filter { "image" in it.type.orEmpty() } | ||||
|                 .filter { "image" in it.type.orEmpty() } | ||||
|  | ||||
|             if (files.isEmpty()) { | ||||
|                 throw Exception("Page list is empty") | ||||
|             } | ||||
|  | ||||
|             files.sortedBy { it.name } | ||||
|                     .mapIndexed { i, file -> | ||||
|                         Page(i, uri = file.uri).apply { status = Page.READY } | ||||
|                     } | ||||
|                 .mapIndexed { i, file -> | ||||
|                     Page(i, uri = file.uri).apply { status = Page.READY } | ||||
|                 } | ||||
|         } | ||||
|     } | ||||
|  | ||||
| @@ -170,6 +181,15 @@ class DownloadManager(context: Context) { | ||||
|         return cache.getDownloadCount(manga) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Calls delete chapter, which deletes a temp download. | ||||
|      * | ||||
|      * @param download the download to cancel. | ||||
|      */ | ||||
|     fun deletePendingDownload(download: Download) { | ||||
|         deleteChapters(listOf(download.chapter), download.manga, download.source) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Deletes the directories of a list of downloaded chapters. | ||||
|      * | ||||
| @@ -219,5 +239,4 @@ class DownloadManager(context: Context) { | ||||
|             deleteChapters(chapters, manga, source) | ||||
|         } | ||||
|     } | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -5,13 +5,16 @@ import android.graphics.BitmapFactory | ||||
| import androidx.core.app.NotificationCompat | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.download.model.Download | ||||
| import eu.kanade.tachiyomi.data.download.model.DownloadQueue | ||||
| import eu.kanade.tachiyomi.data.notification.NotificationHandler | ||||
| import eu.kanade.tachiyomi.data.notification.NotificationReceiver | ||||
| import eu.kanade.tachiyomi.data.notification.Notifications | ||||
| import eu.kanade.tachiyomi.util.chop | ||||
| import eu.kanade.tachiyomi.util.notificationManager | ||||
| import eu.kanade.tachiyomi.data.preference.PreferencesHelper | ||||
| import eu.kanade.tachiyomi.util.lang.chop | ||||
| import eu.kanade.tachiyomi.util.system.notificationBuilder | ||||
| import eu.kanade.tachiyomi.util.system.notificationManager | ||||
| import java.util.regex.Pattern | ||||
| import uy.kohesive.injekt.Injekt | ||||
| import uy.kohesive.injekt.api.get | ||||
|  | ||||
| /** | ||||
|  * DownloadNotifier is used to show notifications when downloading one or multiple chapters. | ||||
| @@ -19,40 +22,23 @@ import java.util.regex.Pattern | ||||
|  * @param context context of application | ||||
|  */ | ||||
| internal class DownloadNotifier(private val context: Context) { | ||||
|     /** | ||||
|      * Notification builder. | ||||
|      */ | ||||
|     private val notification by lazy { | ||||
|         NotificationCompat.Builder(context, Notifications.CHANNEL_DOWNLOADER) | ||||
|                 .setLargeIcon(BitmapFactory.decodeResource(context.resources, R.mipmap.ic_launcher)) | ||||
|  | ||||
|     private val notificationBuilder = context.notificationBuilder(Notifications.CHANNEL_DOWNLOADER) { | ||||
|         setLargeIcon(BitmapFactory.decodeResource(context.resources, R.mipmap.ic_launcher)) | ||||
|     } | ||||
|  | ||||
|     private val preferences by lazy { Injekt.get<PreferencesHelper>() } | ||||
|  | ||||
|     /** | ||||
|      * Status of download. Used for correct notification icon. | ||||
|      */ | ||||
|     private var isDownloading = false | ||||
|  | ||||
|     /** | ||||
|      * The size of queue on start download. | ||||
|      */ | ||||
|     var initialQueueSize = 0 | ||||
|         set(value) { | ||||
|             if (value != 0) { | ||||
|                 isSingleChapter = (value == 1) | ||||
|             } | ||||
|             field = value | ||||
|         } | ||||
|  | ||||
|     /** | ||||
|      * Updated when error is thrown | ||||
|      */ | ||||
|     var errorThrown = false | ||||
|  | ||||
|     /** | ||||
|      * Updated when only single page is downloaded | ||||
|      */ | ||||
|     var isSingleChapter = false | ||||
|  | ||||
|     /** | ||||
|      * Updated when paused | ||||
|      */ | ||||
| @@ -70,9 +56,10 @@ internal class DownloadNotifier(private val context: Context) { | ||||
|     /** | ||||
|      * Clear old actions if they exist. | ||||
|      */ | ||||
|     private fun clearActions() = with(notification) { | ||||
|         if (!mActions.isEmpty()) | ||||
|     private fun clearActions() = with(notificationBuilder) { | ||||
|         if (mActions.isNotEmpty()) { | ||||
|             mActions.clear() | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @@ -90,7 +77,7 @@ internal class DownloadNotifier(private val context: Context) { | ||||
|      */ | ||||
|     fun onProgressChange(download: Download) { | ||||
|         // Create notification | ||||
|         with(notification) { | ||||
|         with(notificationBuilder) { | ||||
|             // Check if first call. | ||||
|             if (!isDownloading) { | ||||
|                 setSmallIcon(android.R.drawable.stat_sys_download) | ||||
| @@ -100,84 +87,65 @@ internal class DownloadNotifier(private val context: Context) { | ||||
|                 setContentIntent(NotificationHandler.openDownloadManagerPendingActivity(context)) | ||||
|                 isDownloading = true | ||||
|                 // Pause action | ||||
|                 addAction(R.drawable.ic_av_pause_grey_24dp_img, | ||||
|                         context.getString(R.string.action_pause), | ||||
|                         NotificationReceiver.pauseDownloadsPendingBroadcast(context)) | ||||
|                 addAction( | ||||
|                     R.drawable.ic_pause_24dp, | ||||
|                     context.getString(R.string.action_pause), | ||||
|                     NotificationReceiver.pauseDownloadsPendingBroadcast(context) | ||||
|                 ) | ||||
|             } | ||||
|  | ||||
|             val downloadingProgressText = context.getString(R.string.chapter_downloading_progress) | ||||
|                 .format(download.downloadedImages, download.pages!!.size) | ||||
|  | ||||
|             if (preferences.hideNotificationContent()) { | ||||
|                 setContentTitle(downloadingProgressText) | ||||
|             } else { | ||||
|                 val title = download.manga.title.chop(15) | ||||
|                 val quotedTitle = Pattern.quote(title) | ||||
|                 val chapter = download.chapter.name.replaceFirst("$quotedTitle[\\s]*[-]*[\\s]*".toRegex(RegexOption.IGNORE_CASE), "") | ||||
|                 setContentTitle("$title - $chapter".chop(30)) | ||||
|                 setContentText(downloadingProgressText) | ||||
|             } | ||||
|  | ||||
|             val title = download.manga.title.chop(15) | ||||
|             val quotedTitle = Pattern.quote(title) | ||||
|             val chapter = download.chapter.name.replaceFirst("$quotedTitle[\\s]*[-]*[\\s]*".toRegex(RegexOption.IGNORE_CASE), "") | ||||
|             setContentTitle("$title - $chapter".chop(30)) | ||||
|             setContentText(context.getString(R.string.chapter_downloading_progress) | ||||
|                     .format(download.downloadedImages, download.pages!!.size)) | ||||
|             setProgress(download.pages!!.size, download.downloadedImages, false) | ||||
|         } | ||||
|  | ||||
|         // Displays the progress bar on notification | ||||
|         notification.show() | ||||
|         notificationBuilder.show() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Show notification when download is paused. | ||||
|      */ | ||||
|     fun onDownloadPaused() { | ||||
|         with(notification) { | ||||
|         with(notificationBuilder) { | ||||
|             setContentTitle(context.getString(R.string.chapter_paused)) | ||||
|             setContentText(context.getString(R.string.download_notifier_download_paused)) | ||||
|             setSmallIcon(R.drawable.ic_av_pause_grey_24dp_img) | ||||
|             setSmallIcon(R.drawable.ic_pause_24dp) | ||||
|             setAutoCancel(false) | ||||
|             setProgress(0, 0, false) | ||||
|             clearActions() | ||||
|             // Open download manager when clicked | ||||
|             setContentIntent(NotificationHandler.openDownloadManagerPendingActivity(context)) | ||||
|             // Resume action | ||||
|             addAction(R.drawable.ic_av_play_arrow_grey_img, | ||||
|                     context.getString(R.string.action_resume), | ||||
|                     NotificationReceiver.resumeDownloadsPendingBroadcast(context)) | ||||
|             //Clear action | ||||
|             addAction(R.drawable.ic_clear_grey_24dp_img, | ||||
|                     context.getString(R.string.action_clear), | ||||
|                     NotificationReceiver.clearDownloadsPendingBroadcast(context)) | ||||
|             addAction( | ||||
|                 R.drawable.ic_play_arrow_24dp, | ||||
|                 context.getString(R.string.action_resume), | ||||
|                 NotificationReceiver.resumeDownloadsPendingBroadcast(context) | ||||
|             ) | ||||
|             // Clear action | ||||
|             addAction( | ||||
|                 R.drawable.ic_close_24dp, | ||||
|                 context.getString(R.string.action_cancel_all), | ||||
|                 NotificationReceiver.clearDownloadsPendingBroadcast(context) | ||||
|             ) | ||||
|         } | ||||
|  | ||||
|         // Show notification. | ||||
|         notification.show() | ||||
|         notificationBuilder.show() | ||||
|  | ||||
|         // Reset initial values | ||||
|         isDownloading = false | ||||
|         initialQueueSize = 0 | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Called when chapter is downloaded. | ||||
|      * | ||||
|      * @param download download object containing download information. | ||||
|      */ | ||||
|     fun onDownloadCompleted(download: Download, queue: DownloadQueue) { | ||||
|         // Check if last download | ||||
|         if (!queue.isEmpty()) { | ||||
|             return | ||||
|         } | ||||
|         // Create notification. | ||||
|         with(notification) { | ||||
|             val title = download.manga.title.chop(15) | ||||
|             val quotedTitle = Pattern.quote(title) | ||||
|             val chapter = download.chapter.name.replaceFirst("$quotedTitle[\\s]*[-]*[\\s]*".toRegex(RegexOption.IGNORE_CASE), "") | ||||
|             setContentTitle("$title - $chapter".chop(30)) | ||||
|             setContentText(context.getString(R.string.update_check_notification_download_complete)) | ||||
|             setSmallIcon(android.R.drawable.stat_sys_download_done) | ||||
|             setAutoCancel(true) | ||||
|             clearActions() | ||||
|             setContentIntent(NotificationReceiver.openChapterPendingBroadcast(context, download.manga, download.chapter)) | ||||
|             setProgress(0, 0, false) | ||||
|         } | ||||
|  | ||||
|         // Show notification. | ||||
|         notification.show() | ||||
|  | ||||
|         // Reset initial values | ||||
|         isDownloading = false | ||||
|         initialQueueSize = 0 | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @@ -186,7 +154,7 @@ internal class DownloadNotifier(private val context: Context) { | ||||
|      * @param reason the text to show. | ||||
|      */ | ||||
|     fun onWarning(reason: String) { | ||||
|         with(notification) { | ||||
|         with(notificationBuilder) { | ||||
|             setContentTitle(context.getString(R.string.download_notifier_downloader_title)) | ||||
|             setContentText(reason) | ||||
|             setSmallIcon(android.R.drawable.stat_sys_warning) | ||||
| @@ -195,7 +163,7 @@ internal class DownloadNotifier(private val context: Context) { | ||||
|             setContentIntent(NotificationHandler.openDownloadManagerPendingActivity(context)) | ||||
|             setProgress(0, 0, false) | ||||
|         } | ||||
|         notification.show() | ||||
|         notificationBuilder.show() | ||||
|  | ||||
|         // Reset download information | ||||
|         isDownloading = false | ||||
| @@ -210,16 +178,19 @@ internal class DownloadNotifier(private val context: Context) { | ||||
|      */ | ||||
|     fun onError(error: String? = null, chapter: String? = null) { | ||||
|         // Create notification | ||||
|         with(notification) { | ||||
|             setContentTitle(chapter ?: context.getString(R.string.download_notifier_downloader_title)) | ||||
|             setContentText(error ?: context.getString(R.string.download_notifier_unkown_error)) | ||||
|         with(notificationBuilder) { | ||||
|             setContentTitle( | ||||
|                 chapter | ||||
|                     ?: context.getString(R.string.download_notifier_downloader_title) | ||||
|             ) | ||||
|             setContentText(error ?: context.getString(R.string.download_notifier_unknown_error)) | ||||
|             setSmallIcon(android.R.drawable.stat_sys_warning) | ||||
|             clearActions() | ||||
|             setAutoCancel(false) | ||||
|             setContentIntent(NotificationHandler.openDownloadManagerPendingActivity(context)) | ||||
|             setProgress(0, 0, false) | ||||
|         } | ||||
|         notification.show(Notifications.ID_DOWNLOAD_CHAPTER_ERROR) | ||||
|         notificationBuilder.show(Notifications.ID_DOWNLOAD_CHAPTER_ERROR) | ||||
|  | ||||
|         // Reset download information | ||||
|         errorThrown = true | ||||
|   | ||||
| @@ -120,27 +120,27 @@ class DownloadPendingDeleter(context: Context) { | ||||
|      * Class used to save an entry of chapters with their manga into preferences. | ||||
|      */ | ||||
|     private data class Entry( | ||||
|             val chapters: List<ChapterEntry>, | ||||
|             val manga: MangaEntry | ||||
|         val chapters: List<ChapterEntry>, | ||||
|         val manga: MangaEntry | ||||
|     ) | ||||
|  | ||||
|     /** | ||||
|      * Class used to save an entry for a chapter into preferences. | ||||
|      */ | ||||
|     private data class ChapterEntry( | ||||
|             val id: Long, | ||||
|             val url: String, | ||||
|             val name: String | ||||
|         val id: Long, | ||||
|         val url: String, | ||||
|         val name: String | ||||
|     ) | ||||
|  | ||||
|     /** | ||||
|      * Class used to save an entry for a manga into preferences. | ||||
|      */ | ||||
|     private data class MangaEntry( | ||||
|             val id: Long, | ||||
|             val url: String, | ||||
|             val title: String, | ||||
|             val source: Long | ||||
|         val id: Long, | ||||
|         val url: String, | ||||
|         val title: String, | ||||
|         val source: Long | ||||
|     ) | ||||
|  | ||||
|     /** | ||||
| @@ -176,5 +176,4 @@ class DownloadPendingDeleter(context: Context) { | ||||
|             it.name = name | ||||
|         } | ||||
|     } | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -3,12 +3,17 @@ package eu.kanade.tachiyomi.data.download | ||||
| import android.content.Context | ||||
| import android.net.Uri | ||||
| import com.hippo.unifile.UniFile | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.database.models.Chapter | ||||
| import eu.kanade.tachiyomi.data.database.models.Manga | ||||
| import eu.kanade.tachiyomi.data.preference.PreferencesHelper | ||||
| import eu.kanade.tachiyomi.data.preference.getOrDefault | ||||
| import eu.kanade.tachiyomi.source.Source | ||||
| import eu.kanade.tachiyomi.util.DiskUtil | ||||
| import eu.kanade.tachiyomi.util.storage.DiskUtil | ||||
| import kotlinx.coroutines.CoroutineScope | ||||
| import kotlinx.coroutines.Dispatchers | ||||
| import kotlinx.coroutines.Job | ||||
| import kotlinx.coroutines.flow.launchIn | ||||
| import kotlinx.coroutines.flow.onEach | ||||
| import uy.kohesive.injekt.injectLazy | ||||
|  | ||||
| /** | ||||
| @@ -19,24 +24,23 @@ import uy.kohesive.injekt.injectLazy | ||||
|  */ | ||||
| class DownloadProvider(private val context: Context) { | ||||
|  | ||||
|     /** | ||||
|      * Preferences helper. | ||||
|      */ | ||||
|     private val preferences: PreferencesHelper by injectLazy() | ||||
|  | ||||
|     private val scope = CoroutineScope(Job() + Dispatchers.Main) | ||||
|  | ||||
|     /** | ||||
|      * The root directory for downloads. | ||||
|      */ | ||||
|     private var downloadsDir = preferences.downloadsDirectory().getOrDefault().let { | ||||
|     private var downloadsDir = preferences.downloadsDirectory().get().let { | ||||
|         val dir = UniFile.fromUri(context, Uri.parse(it)) | ||||
|         DiskUtil.createNoMediaFile(dir, context) | ||||
|         dir | ||||
|     } | ||||
|  | ||||
|     init { | ||||
|         preferences.downloadsDirectory().asObservable() | ||||
|                 .skip(1) | ||||
|                 .subscribe { downloadsDir = UniFile.fromUri(context, Uri.parse(it)) } | ||||
|         preferences.downloadsDirectory().asFlow() | ||||
|             .onEach { downloadsDir = UniFile.fromUri(context, Uri.parse(it)) } | ||||
|             .launchIn(scope) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @@ -46,9 +50,13 @@ class DownloadProvider(private val context: Context) { | ||||
|      * @param source the source of the manga. | ||||
|      */ | ||||
|     internal fun getMangaDir(manga: Manga, source: Source): UniFile { | ||||
|         return downloadsDir | ||||
|         try { | ||||
|             return downloadsDir | ||||
|                 .createDirectory(getSourceDirName(source)) | ||||
|                 .createDirectory(getMangaDirName(manga)) | ||||
|         } catch (e: NullPointerException) { | ||||
|             throw Exception(context.getString(R.string.invalid_download_dir)) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @@ -121,5 +129,4 @@ class DownloadProvider(private val context: Context) { | ||||
|     fun getChapterDirName(chapter: Chapter): String { | ||||
|         return DiskUtil.buildValidFilename(chapter.name) | ||||
|     } | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -9,17 +9,17 @@ import android.net.NetworkInfo.State.DISCONNECTED | ||||
| import android.os.Build | ||||
| import android.os.IBinder | ||||
| import android.os.PowerManager | ||||
| import androidx.core.app.NotificationCompat | ||||
| import com.github.pwittchen.reactivenetwork.library.Connectivity | ||||
| import com.github.pwittchen.reactivenetwork.library.ReactiveNetwork | ||||
| import com.jakewharton.rxrelay.BehaviorRelay | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.notification.Notifications | ||||
| import eu.kanade.tachiyomi.data.preference.PreferencesHelper | ||||
| import eu.kanade.tachiyomi.util.connectivityManager | ||||
| import eu.kanade.tachiyomi.util.plusAssign | ||||
| import eu.kanade.tachiyomi.util.powerManager | ||||
| import eu.kanade.tachiyomi.util.toast | ||||
| import eu.kanade.tachiyomi.util.lang.plusAssign | ||||
| import eu.kanade.tachiyomi.util.system.connectivityManager | ||||
| import eu.kanade.tachiyomi.util.system.notification | ||||
| import eu.kanade.tachiyomi.util.system.powerManager | ||||
| import eu.kanade.tachiyomi.util.system.toast | ||||
| import rx.android.schedulers.AndroidSchedulers | ||||
| import rx.schedulers.Schedulers | ||||
| import rx.subscriptions.CompositeSubscription | ||||
| @@ -63,14 +63,8 @@ class DownloadService : Service() { | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Download manager. | ||||
|      */ | ||||
|     private val downloadManager: DownloadManager by injectLazy() | ||||
|  | ||||
|     /** | ||||
|      * Preferences helper. | ||||
|      */ | ||||
|     private val preferences: PreferencesHelper by injectLazy() | ||||
|  | ||||
|     /** | ||||
| @@ -112,7 +106,7 @@ class DownloadService : Service() { | ||||
|      * Not used. | ||||
|      */ | ||||
|     override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { | ||||
|         return Service.START_NOT_STICKY | ||||
|         return START_NOT_STICKY | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @@ -129,13 +123,17 @@ class DownloadService : Service() { | ||||
|      */ | ||||
|     private fun listenNetworkChanges() { | ||||
|         subscriptions += ReactiveNetwork.observeNetworkConnectivity(applicationContext) | ||||
|                 .subscribeOn(Schedulers.io()) | ||||
|                 .observeOn(AndroidSchedulers.mainThread()) | ||||
|                 .subscribe({ state -> onNetworkStateChanged(state) | ||||
|                 }, { | ||||
|             .subscribeOn(Schedulers.io()) | ||||
|             .observeOn(AndroidSchedulers.mainThread()) | ||||
|             .subscribe( | ||||
|                 { state -> | ||||
|                     onNetworkStateChanged(state) | ||||
|                 }, | ||||
|                 { | ||||
|                     toast(R.string.download_queue_error) | ||||
|                     stopSelf() | ||||
|                 }) | ||||
|                 } | ||||
|             ) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @@ -156,7 +154,9 @@ class DownloadService : Service() { | ||||
|             DISCONNECTED -> { | ||||
|                 downloadManager.stopDownloads(getString(R.string.download_notifier_no_network)) | ||||
|             } | ||||
|             else -> { /* Do nothing */ } | ||||
|             else -> { | ||||
|                 /* Do nothing */ | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
| @@ -165,10 +165,11 @@ class DownloadService : Service() { | ||||
|      */ | ||||
|     private fun listenDownloaderState() { | ||||
|         subscriptions += downloadManager.runningRelay.subscribe { running -> | ||||
|             if (running) | ||||
|             if (running) { | ||||
|                 wakeLock.acquireIfNeeded() | ||||
|             else | ||||
|             } else { | ||||
|                 wakeLock.releaseIfNeeded() | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
| @@ -187,9 +188,8 @@ class DownloadService : Service() { | ||||
|     } | ||||
|  | ||||
|     private fun getPlaceholderNotification(): Notification { | ||||
|         return NotificationCompat.Builder(this, Notifications.CHANNEL_DOWNLOADER) | ||||
|             .setContentTitle(getString(R.string.download_notifier_downloader_title)) | ||||
|             .build() | ||||
|         return notification(Notifications.CHANNEL_DOWNLOADER) { | ||||
|             setContentTitle(getString(R.string.download_notifier_downloader_title)) | ||||
|         } | ||||
|     } | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -15,8 +15,8 @@ import uy.kohesive.injekt.injectLazy | ||||
|  * @param context the application context. | ||||
|  */ | ||||
| class DownloadStore( | ||||
|         context: Context, | ||||
|         private val sourceManager: SourceManager | ||||
|     context: Context, | ||||
|     private val sourceManager: SourceManager | ||||
| ) { | ||||
|  | ||||
|     /** | ||||
| @@ -29,9 +29,6 @@ class DownloadStore( | ||||
|      */ | ||||
|     private val gson: Gson by injectLazy() | ||||
|  | ||||
|     /** | ||||
|      * Database helper. | ||||
|      */ | ||||
|     private val db: DatabaseHelper by injectLazy() | ||||
|  | ||||
|     /** | ||||
| @@ -80,9 +77,9 @@ class DownloadStore( | ||||
|      */ | ||||
|     fun restore(): List<Download> { | ||||
|         val objs = preferences.all | ||||
|                 .mapNotNull { it.value as? String } | ||||
|                 .mapNotNull { deserialize(it) } | ||||
|                 .sortedBy { it.order } | ||||
|             .mapNotNull { it.value as? String } | ||||
|             .mapNotNull { deserialize(it) } | ||||
|             .sortedBy { it.order } | ||||
|  | ||||
|         val downloads = mutableListOf<Download>() | ||||
|         if (objs.isNotEmpty()) { | ||||
| @@ -133,5 +130,4 @@ class DownloadStore( | ||||
|      * @param order the order of the download in the queue. | ||||
|      */ | ||||
|     data class DownloadObject(val mangaId: Long, val chapterId: Long, val order: Int) | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -6,6 +6,7 @@ import com.elvishew.xlog.XLog | ||||
| import com.hippo.unifile.UniFile | ||||
| import com.jakewharton.rxrelay.BehaviorRelay | ||||
| import com.jakewharton.rxrelay.PublishRelay | ||||
| import eu.kanade.tachiyomi.data.cache.ChapterCache | ||||
| import eu.kanade.tachiyomi.data.database.models.Chapter | ||||
| import eu.kanade.tachiyomi.data.database.models.Manga | ||||
| import eu.kanade.tachiyomi.data.download.model.Download | ||||
| @@ -14,7 +15,14 @@ import eu.kanade.tachiyomi.source.SourceManager | ||||
| import eu.kanade.tachiyomi.source.model.Page | ||||
| import eu.kanade.tachiyomi.source.online.HttpSource | ||||
| import eu.kanade.tachiyomi.source.online.fetchAllImageUrlsFromPageList | ||||
| import eu.kanade.tachiyomi.util.* | ||||
| import eu.kanade.tachiyomi.util.lang.RetryWithDelay | ||||
| import eu.kanade.tachiyomi.util.lang.launchNow | ||||
| import eu.kanade.tachiyomi.util.lang.launchUI | ||||
| import eu.kanade.tachiyomi.util.lang.plusAssign | ||||
| import eu.kanade.tachiyomi.util.storage.DiskUtil | ||||
| import eu.kanade.tachiyomi.util.storage.saveTo | ||||
| import eu.kanade.tachiyomi.util.system.ImageUtil | ||||
| import java.io.File | ||||
| import kotlinx.coroutines.async | ||||
| import okhttp3.Response | ||||
| import rx.Observable | ||||
| @@ -22,6 +30,7 @@ import rx.android.schedulers.AndroidSchedulers | ||||
| import rx.schedulers.Schedulers | ||||
| import rx.subscriptions.CompositeSubscription | ||||
| import timber.log.Timber | ||||
| import uy.kohesive.injekt.injectLazy | ||||
|  | ||||
| /** | ||||
|  * This class is the one in charge of downloading chapters. | ||||
| @@ -38,12 +47,14 @@ import timber.log.Timber | ||||
|  * @param sourceManager the source manager. | ||||
|  */ | ||||
| class Downloader( | ||||
|         private val context: Context, | ||||
|         private val provider: DownloadProvider, | ||||
|         private val cache: DownloadCache, | ||||
|         private val sourceManager: SourceManager | ||||
|     private val context: Context, | ||||
|     private val provider: DownloadProvider, | ||||
|     private val cache: DownloadCache, | ||||
|     private val sourceManager: SourceManager | ||||
| ) { | ||||
|  | ||||
|     private val chapterCache: ChapterCache by injectLazy() | ||||
|  | ||||
|     /** | ||||
|      * Store for persisting downloads across restarts. | ||||
|      */ | ||||
| @@ -77,7 +88,9 @@ class Downloader( | ||||
|     /** | ||||
|      * Whether the downloader is running. | ||||
|      */ | ||||
|     @Volatile private var isRunning: Boolean = false | ||||
|     @Volatile | ||||
|     var isRunning: Boolean = false | ||||
|         private set | ||||
|  | ||||
|     init { | ||||
|         launchNow { | ||||
| @@ -93,17 +106,19 @@ class Downloader( | ||||
|      * @return true if the downloader is started, false otherwise. | ||||
|      */ | ||||
|     fun start(): Boolean { | ||||
|         if (isRunning || queue.isEmpty()) | ||||
|         if (isRunning || queue.isEmpty()) { | ||||
|             return false | ||||
|         notifier.paused = false | ||||
|         if (!subscriptions.hasSubscriptions()) | ||||
|         } | ||||
|  | ||||
|         if (!subscriptions.hasSubscriptions()) { | ||||
|             initializeSubscriptions() | ||||
|         } | ||||
|  | ||||
|         val pending = queue.filter { it.status != Download.DOWNLOADED } | ||||
|         pending.forEach { if (it.status != Download.QUEUE) it.status = Download.QUEUE } | ||||
|  | ||||
|         downloadsRelay.call(pending) | ||||
|         return !pending.isEmpty() | ||||
|         return pending.isNotEmpty() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @@ -112,8 +127,8 @@ class Downloader( | ||||
|     fun stop(reason: String? = null) { | ||||
|         destroySubscriptions() | ||||
|         queue | ||||
|                 .filter { it.status == Download.DOWNLOADING } | ||||
|                 .forEach { it.status = Download.ERROR } | ||||
|             .filter { it.status == Download.DOWNLOADING } | ||||
|             .forEach { it.status = Download.ERROR } | ||||
|  | ||||
|         if (reason != null) { | ||||
|             notifier.onWarning(reason) | ||||
| @@ -121,8 +136,6 @@ class Downloader( | ||||
|             if (notifier.paused) { | ||||
|                 notifier.paused = false | ||||
|                 notifier.onDownloadPaused() | ||||
|             } else if (notifier.isSingleChapter && !notifier.errorThrown) { | ||||
|                 notifier.isSingleChapter = false | ||||
|             } else { | ||||
|                 notifier.dismiss() | ||||
|             } | ||||
| @@ -135,8 +148,8 @@ class Downloader( | ||||
|     fun pause() { | ||||
|         destroySubscriptions() | ||||
|         queue | ||||
|                 .filter { it.status == Download.DOWNLOADING } | ||||
|                 .forEach { it.status = Download.QUEUE } | ||||
|             .filter { it.status == Download.DOWNLOADING } | ||||
|             .forEach { it.status = Download.QUEUE } | ||||
|         notifier.paused = true | ||||
|     } | ||||
|  | ||||
| @@ -148,11 +161,11 @@ class Downloader( | ||||
|     fun clearQueue(isNotification: Boolean = false) { | ||||
|         destroySubscriptions() | ||||
|  | ||||
|         //Needed to update the chapter view | ||||
|         // Needed to update the chapter view | ||||
|         if (isNotification) { | ||||
|             queue | ||||
|                     .filter { it.status == Download.QUEUE } | ||||
|                     .forEach { it.status = Download.NOT_DOWNLOADED } | ||||
|                 .filter { it.status == Download.QUEUE } | ||||
|                 .forEach { it.status = Download.NOT_DOWNLOADED } | ||||
|         } | ||||
|         queue.clear() | ||||
|         notifier.dismiss() | ||||
| @@ -169,15 +182,19 @@ class Downloader( | ||||
|         subscriptions.clear() | ||||
|  | ||||
|         subscriptions += downloadsRelay.concatMapIterable { it } | ||||
|                 .concatMap { downloadChapter(it).subscribeOn(Schedulers.io()) } | ||||
|                 .onBackpressureBuffer() | ||||
|                 .observeOn(AndroidSchedulers.mainThread()) | ||||
|                 .subscribe({ completeDownload(it) | ||||
|                 }, { error -> | ||||
|             .concatMap { downloadChapter(it).subscribeOn(Schedulers.io()) } | ||||
|             .onBackpressureBuffer() | ||||
|             .observeOn(AndroidSchedulers.mainThread()) | ||||
|             .subscribe( | ||||
|                 { | ||||
|                     completeDownload(it) | ||||
|                 }, | ||||
|                 { error -> | ||||
|                     DownloadService.stop(context) | ||||
|                     Timber.e(error) | ||||
|                     notifier.onError(error.message) | ||||
|                 }) | ||||
|                 } | ||||
|             ) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @@ -200,40 +217,37 @@ class Downloader( | ||||
|      */ | ||||
|     fun queueChapters(manga: Manga, chapters: List<Chapter>, autoStart: Boolean) = launchUI { | ||||
|         val source = sourceManager.get(manga.source) as? HttpSource ?: return@launchUI | ||||
|  | ||||
|         val wasEmpty = queue.isEmpty() | ||||
|         // Called in background thread, the operation can be slow with SAF. | ||||
|         val chaptersWithoutDir = async { | ||||
|             val mangaDir = provider.findMangaDir(manga, source) | ||||
|  | ||||
|             chapters | ||||
|                     // Avoid downloading chapters with the same name. | ||||
|                     .distinctBy { it.name } | ||||
|                     // Filter out those already downloaded. | ||||
|                     .filter { mangaDir?.findFile(provider.getChapterDirName(it)) == null } | ||||
|                     // Add chapters to queue from the start. | ||||
|                     .sortedByDescending { it.source_order } | ||||
|                 // Avoid downloading chapters with the same name. | ||||
|                 .distinctBy { it.name } | ||||
|                 // Filter out those already downloaded. | ||||
|                 .filter { mangaDir?.findFile(provider.getChapterDirName(it)) == null } | ||||
|                 // Add chapters to queue from the start. | ||||
|                 .sortedByDescending { it.source_order } | ||||
|         } | ||||
|  | ||||
|         // Runs in main thread (synchronization needed). | ||||
|         val chaptersToQueue = chaptersWithoutDir.await() | ||||
|                 // Filter out those already enqueued. | ||||
|                 .filter { chapter -> queue.none { it.chapter.id == chapter.id } } | ||||
|                 // Create a download for each one. | ||||
|                 .map { Download(source, manga, it) } | ||||
|             // Filter out those already enqueued. | ||||
|             .filter { chapter -> queue.none { it.chapter.id == chapter.id } } | ||||
|             // Create a download for each one. | ||||
|             .map { Download(source, manga, it) } | ||||
|  | ||||
|         if (chaptersToQueue.isNotEmpty()) { | ||||
|             queue.addAll(chaptersToQueue) | ||||
|  | ||||
|             // Initialize queue size. | ||||
|             notifier.initialQueueSize = queue.size | ||||
|  | ||||
|             if (isRunning) { | ||||
|                 // Send the list of downloads to the downloader. | ||||
|                 downloadsRelay.call(chaptersToQueue) | ||||
|             } | ||||
|  | ||||
|             // Start downloader if needed | ||||
|             if (autoStart) { | ||||
|             if (autoStart && wasEmpty) { | ||||
|                 DownloadService.start(this@Downloader.context) | ||||
|             } | ||||
|         } | ||||
| @@ -247,59 +261,48 @@ class Downloader( | ||||
|     private fun downloadChapter(download: Download): Observable<Download> = Observable.defer { | ||||
|         val chapterDirname = provider.getChapterDirName(download.chapter) | ||||
|         val mangaDir = provider.getMangaDir(download.manga, download.source) | ||||
|         val tmpDir = mangaDir.createDirectory("${chapterDirname}_tmp") | ||||
|         val tmpDir = mangaDir.createDirectory(chapterDirname + TMP_DIR_SUFFIX) | ||||
|  | ||||
|         val pageListObservable = if (download.pages == null) { | ||||
|             // Pull page list from network and add them to download object | ||||
|             download.source.fetchPageList(download.chapter) | ||||
|                     .doOnNext { pages -> | ||||
|                         if (pages.isEmpty()) { | ||||
|                             throw Exception("Page list is empty") | ||||
|                         } | ||||
|                         download.pages = pages | ||||
|                 .doOnNext { pages -> | ||||
|                     if (pages.isEmpty()) { | ||||
|                         throw Exception("Page list is empty") | ||||
|                     } | ||||
|                     download.pages = pages | ||||
|                 } | ||||
|         } else { | ||||
|             // Or if the page list already exists, start from the file | ||||
|             Observable.just(download.pages!!) | ||||
|         } | ||||
|  | ||||
|         pageListObservable | ||||
|                 .doOnNext { _ -> | ||||
|                     // Delete all temporary (unfinished) files | ||||
|                     tmpDir.listFiles() | ||||
|                             ?.filter { it.name!!.endsWith(".tmp") } | ||||
|                             ?.forEach { it.delete() } | ||||
|  | ||||
|                     download.downloadedImages = 0 | ||||
|                     download.status = Download.DOWNLOADING | ||||
|                 } | ||||
|                 // Get all the URLs to the source images, fetch pages if necessary | ||||
|                 .flatMap { download.source.fetchAllImageUrlsFromPageList(it) } | ||||
|                 // Start downloading images, consider we can have downloaded images already | ||||
|                 .concatMap { page -> getOrDownloadImage(page, download, tmpDir) } | ||||
|                 // Do when page is downloaded. | ||||
|                 .doOnNext { notifier.onProgressChange(download) } | ||||
|                 .toList() | ||||
|                 .map { _ -> download } | ||||
|                 // Do after download completes | ||||
|                 .doOnNext { ensureSuccessfulDownload(download, mangaDir, tmpDir, chapterDirname) } | ||||
|                 // If the page list threw, it will resume here | ||||
|                 .onErrorReturn { error -> | ||||
|                     // [EXH] | ||||
|                     XLog.w("> Download error!", error) | ||||
|                     XLog.w("> (source.id: %s, source.name: %s, manga.id: %s, manga.url: %s, chapter.id: %s, chapter.url: %s)", | ||||
|                             download.source.id, | ||||
|                             download.source.name, | ||||
|                             download.manga.id, | ||||
|                             download.manga.url, | ||||
|                             download.chapter.id, | ||||
|                             download.chapter.url) | ||||
|  | ||||
|                     download.status = Download.ERROR | ||||
|                     notifier.onError(error.message, download.chapter.name) | ||||
|                     download | ||||
|                 } | ||||
|             .doOnNext { _ -> | ||||
|                 // Delete all temporary (unfinished) files | ||||
|                 tmpDir.listFiles() | ||||
|                     ?.filter { it.name!!.endsWith(".tmp") } | ||||
|                     ?.forEach { it.delete() } | ||||
|  | ||||
|                 download.downloadedImages = 0 | ||||
|                 download.status = Download.DOWNLOADING | ||||
|             } | ||||
|             // Get all the URLs to the source images, fetch pages if necessary | ||||
|             .flatMap { download.source.fetchAllImageUrlsFromPageList(it) } | ||||
|             // Start downloading images, consider we can have downloaded images already | ||||
|             .concatMap { page -> getOrDownloadImage(page, download, tmpDir) } | ||||
|             // Do when page is downloaded. | ||||
|             .doOnNext { notifier.onProgressChange(download) } | ||||
|             .toList() | ||||
|             .map { download } | ||||
|             // Do after download completes | ||||
|             .doOnNext { ensureSuccessfulDownload(download, mangaDir, tmpDir, chapterDirname) } | ||||
|             // If the page list threw, it will resume here | ||||
|             .onErrorReturn { error -> | ||||
|                 download.status = Download.ERROR | ||||
|                 notifier.onError(error.message, download.chapter.name) | ||||
|                 download | ||||
|             } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @@ -312,8 +315,9 @@ class Downloader( | ||||
|      */ | ||||
|     private fun getOrDownloadImage(page: Page, download: Download, tmpDir: UniFile): Observable<Page> { | ||||
|         // If the image URL is empty, do nothing | ||||
|         if (page.imageUrl == null) | ||||
|         if (page.imageUrl == null) { | ||||
|             return Observable.just(page) | ||||
|         } | ||||
|  | ||||
|         val filename = String.format("%03d", page.number) | ||||
|         val tmpFile = tmpDir.findFile("$filename.tmp") | ||||
| @@ -325,26 +329,27 @@ class Downloader( | ||||
|         val imageFile = tmpDir.listFiles()!!.find { it.name!!.startsWith("$filename.") } | ||||
|  | ||||
|         // If the image is already downloaded, do nothing. Otherwise download from network | ||||
|         val pageObservable = if (imageFile != null) | ||||
|             Observable.just(imageFile) | ||||
|         else | ||||
|             downloadImage(page, download.source, tmpDir, filename) | ||||
|         val pageObservable = when { | ||||
|             imageFile != null -> Observable.just(imageFile) | ||||
|             chapterCache.isImageInCache(page.imageUrl!!) -> copyImageFromCache(chapterCache.getImageFile(page.imageUrl!!), tmpDir, filename) | ||||
|             else -> downloadImage(page, download.source, tmpDir, filename) | ||||
|         } | ||||
|  | ||||
|         return pageObservable | ||||
|                 // When the image is ready, set image path, progress (just in case) and status | ||||
|                 .doOnNext { file -> | ||||
|                     page.uri = file.uri | ||||
|                     page.progress = 100 | ||||
|                     download.downloadedImages++ | ||||
|                     page.status = Page.READY | ||||
|                 } | ||||
|                 .map { page } | ||||
|                 // Mark this page as error and allow to download the remaining | ||||
|                 .onErrorReturn { | ||||
|                     page.progress = 0 | ||||
|                     page.status = Page.ERROR | ||||
|                     page | ||||
|                 } | ||||
|             // When the image is ready, set image path, progress (just in case) and status | ||||
|             .doOnNext { file -> | ||||
|                 page.uri = file.uri | ||||
|                 page.progress = 100 | ||||
|                 download.downloadedImages++ | ||||
|                 page.status = Page.READY | ||||
|             } | ||||
|             .map { page } | ||||
|             // Mark this page as error and allow to download the remaining | ||||
|             .onErrorReturn { | ||||
|                 page.progress = 0 | ||||
|                 page.status = Page.ERROR | ||||
|                 page | ||||
|             } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @@ -359,30 +364,43 @@ class Downloader( | ||||
|         page.status = Page.DOWNLOAD_IMAGE | ||||
|         page.progress = 0 | ||||
|         return source.fetchImage(page) | ||||
|                 .map { response -> | ||||
|                     val file = tmpDir.createFile("$filename.tmp") | ||||
|                     try { | ||||
|                         response.body!!.source().saveTo(file.openOutputStream()) | ||||
|                         val extension = getImageExtension(response, file) | ||||
|                         file.renameTo("$filename.$extension") | ||||
|                     } catch (e: Exception) { | ||||
|                         // [EXH] | ||||
|                         XLog.w("> Failed to fetch image!", e) | ||||
|                         XLog.w("> (source.id: %s, source.name: %s, page.index: %s, page.url: %s, page.imageUrl: %s)", | ||||
|                                 source.id, | ||||
|                                 source.name, | ||||
|                                 page.index, | ||||
|                                 page.url, | ||||
|                                 page.imageUrl) | ||||
|  | ||||
|                         response.close() | ||||
|                         file.delete() | ||||
|                         throw e | ||||
|                     } | ||||
|                     file | ||||
|             .map { response -> | ||||
|                 val file = tmpDir.createFile("$filename.tmp") | ||||
|                 try { | ||||
|                     response.body!!.source().saveTo(file.openOutputStream()) | ||||
|                     val extension = getImageExtension(response, file) | ||||
|                     file.renameTo("$filename.$extension") | ||||
|                 } catch (e: Exception) { | ||||
|                     response.close() | ||||
|                     file.delete() | ||||
|                     throw e | ||||
|                 } | ||||
|                 // Retry 3 times, waiting 2, 4 and 8 seconds between attempts. | ||||
|                 .retryWhen(RetryWithDelay(3, { (2 shl it - 1) * 1000 }, Schedulers.trampoline())) | ||||
|                 file | ||||
|             } | ||||
|             // Retry 3 times, waiting 2, 4 and 8 seconds between attempts. | ||||
|             .retryWhen(RetryWithDelay(3, { (2 shl it - 1) * 1000 }, Schedulers.trampoline())) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Return the observable which copies the image from cache. | ||||
|      * | ||||
|      * @param cacheFile the file from cache. | ||||
|      * @param tmpDir the temporary directory of the download. | ||||
|      * @param filename the filename of the image. | ||||
|      */ | ||||
|     private fun copyImageFromCache(cacheFile: File, tmpDir: UniFile, filename: String): Observable<UniFile> { | ||||
|         return Observable.just(cacheFile).map { | ||||
|             val tmpFile = tmpDir.createFile("$filename.tmp") | ||||
|             cacheFile.inputStream().use { input -> | ||||
|                 tmpFile.openOutputStream().use { output -> | ||||
|                     input.copyTo(output) | ||||
|                 } | ||||
|             } | ||||
|             val extension = ImageUtil.findImageType(cacheFile.inputStream()) ?: return@map tmpFile | ||||
|             tmpFile.renameTo("$filename.${extension.extension}") | ||||
|             cacheFile.delete() | ||||
|             tmpFile | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @@ -411,9 +429,12 @@ class Downloader( | ||||
|      * @param tmpDir the directory where the download is currently stored. | ||||
|      * @param dirname the real (non temporary) directory name of the download. | ||||
|      */ | ||||
|     private fun ensureSuccessfulDownload(download: Download, mangaDir: UniFile, | ||||
|                                          tmpDir: UniFile, dirname: String) { | ||||
|  | ||||
|     private fun ensureSuccessfulDownload( | ||||
|         download: Download, | ||||
|         mangaDir: UniFile, | ||||
|         tmpDir: UniFile, | ||||
|         dirname: String | ||||
|     ) { | ||||
|         // Ensure that the chapter folder has all the images. | ||||
|         val downloadedImages = tmpDir.listFiles().orEmpty().filterNot { it.name!!.endsWith(".tmp") } | ||||
|  | ||||
| @@ -442,9 +463,6 @@ class Downloader( | ||||
|             queue.remove(download) | ||||
|         } | ||||
|         if (areAllDownloadsFinished()) { | ||||
|             if (notifier.isSingleChapter && !notifier.errorThrown) { | ||||
|                 notifier.onDownloadCompleted(download, queue) | ||||
|             } | ||||
|             DownloadService.stop(context) | ||||
|         } | ||||
|     } | ||||
| @@ -456,4 +474,7 @@ class Downloader( | ||||
|         return queue.none { it.status <= Download.DOWNLOADING } | ||||
|     } | ||||
|  | ||||
|     companion object { | ||||
|         const val TMP_DIR_SUFFIX = "_tmp" | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -10,28 +10,48 @@ class Download(val source: HttpSource, val manga: Manga, val chapter: Chapter) { | ||||
|  | ||||
|     var pages: List<Page>? = null | ||||
|  | ||||
|     @Volatile @Transient var totalProgress: Int = 0 | ||||
|     @Volatile | ||||
|     @Transient | ||||
|     var totalProgress: Int = 0 | ||||
|  | ||||
|     @Volatile @Transient var downloadedImages: Int = 0 | ||||
|     @Volatile | ||||
|     @Transient | ||||
|     var downloadedImages: Int = 0 | ||||
|  | ||||
|     @Volatile @Transient var status: Int = 0 | ||||
|     @Volatile | ||||
|     @Transient | ||||
|     var status: Int = 0 | ||||
|         set(status) { | ||||
|             field = status | ||||
|             statusSubject?.onNext(this) | ||||
|             statusCallback?.invoke(this) | ||||
|         } | ||||
|  | ||||
|     @Transient private var statusSubject: PublishSubject<Download>? = null | ||||
|     @Transient | ||||
|     private var statusSubject: PublishSubject<Download>? = null | ||||
|  | ||||
|     @Transient | ||||
|     private var statusCallback: ((Download) -> Unit)? = null | ||||
|  | ||||
|     val progress: Int | ||||
|         get() { | ||||
|             val pages = pages ?: return 0 | ||||
|             return pages.map(Page::progress).average().toInt() | ||||
|         } | ||||
|  | ||||
|     fun setStatusSubject(subject: PublishSubject<Download>?) { | ||||
|         statusSubject = subject | ||||
|     } | ||||
|  | ||||
|     companion object { | ||||
|     fun setStatusCallback(f: ((Download) -> Unit)?) { | ||||
|         statusCallback = f | ||||
|     } | ||||
|  | ||||
|     companion object { | ||||
|         const val NOT_DOWNLOADED = 0 | ||||
|         const val QUEUE = 1 | ||||
|         const val DOWNLOADING = 2 | ||||
|         const val DOWNLOADED = 3 | ||||
|         const val ERROR = 4 | ||||
|     } | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -5,14 +5,14 @@ import eu.kanade.tachiyomi.data.database.models.Chapter | ||||
| import eu.kanade.tachiyomi.data.database.models.Manga | ||||
| import eu.kanade.tachiyomi.data.download.DownloadStore | ||||
| import eu.kanade.tachiyomi.source.model.Page | ||||
| import java.util.concurrent.CopyOnWriteArrayList | ||||
| import rx.Observable | ||||
| import rx.subjects.PublishSubject | ||||
| import java.util.concurrent.CopyOnWriteArrayList | ||||
|  | ||||
| class DownloadQueue( | ||||
|         private val store: DownloadStore, | ||||
|         private val queue: MutableList<Download> = CopyOnWriteArrayList<Download>()) | ||||
| : List<Download> by queue { | ||||
|     private val store: DownloadStore, | ||||
|     private val queue: MutableList<Download> = CopyOnWriteArrayList() | ||||
| ) : List<Download> by queue { | ||||
|  | ||||
|     private val statusSubject = PublishSubject.create<Download>() | ||||
|  | ||||
| @@ -21,6 +21,7 @@ class DownloadQueue( | ||||
|     fun addAll(downloads: List<Download>) { | ||||
|         downloads.forEach { download -> | ||||
|             download.setStatusSubject(statusSubject) | ||||
|             download.setStatusCallback(::setPagesFor) | ||||
|             download.status = Download.QUEUE | ||||
|         } | ||||
|         queue.addAll(downloads) | ||||
| @@ -32,6 +33,10 @@ class DownloadQueue( | ||||
|         val removed = queue.remove(download) | ||||
|         store.remove(download) | ||||
|         download.setStatusSubject(null) | ||||
|         download.setStatusCallback(null) | ||||
|         if (download.status == Download.DOWNLOADING || download.status == Download.QUEUE) { | ||||
|             download.status = Download.NOT_DOWNLOADED | ||||
|         } | ||||
|         if (removed) { | ||||
|             updatedRelay.call(Unit) | ||||
|         } | ||||
| @@ -42,7 +47,9 @@ class DownloadQueue( | ||||
|     } | ||||
|  | ||||
|     fun remove(chapters: List<Chapter>) { | ||||
|         for (chapter in chapters) { remove(chapter) } | ||||
|         for (chapter in chapters) { | ||||
|             remove(chapter) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fun remove(manga: Manga) { | ||||
| @@ -52,6 +59,10 @@ class DownloadQueue( | ||||
|     fun clear() { | ||||
|         queue.forEach { download -> | ||||
|             download.setStatusSubject(null) | ||||
|             download.setStatusCallback(null) | ||||
|             if (download.status == Download.DOWNLOADING || download.status == Download.QUEUE) { | ||||
|                 download.status = Download.NOT_DOWNLOADED | ||||
|             } | ||||
|         } | ||||
|         queue.clear() | ||||
|         store.clear() | ||||
| @@ -64,35 +75,35 @@ class DownloadQueue( | ||||
|     fun getStatusObservable(): Observable<Download> = statusSubject.onBackpressureBuffer() | ||||
|  | ||||
|     fun getUpdatedObservable(): Observable<List<Download>> = updatedRelay.onBackpressureBuffer() | ||||
|             .startWith(Unit) | ||||
|             .map { this } | ||||
|         .startWith(Unit) | ||||
|         .map { this } | ||||
|  | ||||
|     fun getProgressObservable(): Observable<Download> { | ||||
|         return statusSubject.onBackpressureBuffer() | ||||
|                 .startWith(getActiveDownloads()) | ||||
|                 .flatMap { download -> | ||||
|                     if (download.status == Download.DOWNLOADING) { | ||||
|                         val pageStatusSubject = PublishSubject.create<Int>() | ||||
|                         setPagesSubject(download.pages, pageStatusSubject) | ||||
|                         return@flatMap pageStatusSubject | ||||
|                                 .onBackpressureBuffer() | ||||
|                                 .filter { it == Page.READY } | ||||
|                                 .map { download } | ||||
|  | ||||
|                     } else if (download.status == Download.DOWNLOADED || download.status == Download.ERROR) { | ||||
|                         setPagesSubject(download.pages, null) | ||||
|                     } | ||||
|                     Observable.just(download) | ||||
|                 } | ||||
|                 .filter { it.status == Download.DOWNLOADING } | ||||
|     } | ||||
|  | ||||
|     private fun setPagesSubject(pages: List<Page>?, subject: PublishSubject<Int>?) { | ||||
|         if (pages != null) { | ||||
|             for (page in pages) { | ||||
|                 page.setStatusSubject(subject) | ||||
|             } | ||||
|     private fun setPagesFor(download: Download) { | ||||
|         if (download.status == Download.DOWNLOADED || download.status == Download.ERROR) { | ||||
|             setPagesSubject(download.pages, null) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fun getProgressObservable(): Observable<Download> { | ||||
|         return statusSubject.onBackpressureBuffer() | ||||
|             .startWith(getActiveDownloads()) | ||||
|             .flatMap { download -> | ||||
|                 if (download.status == Download.DOWNLOADING) { | ||||
|                     val pageStatusSubject = PublishSubject.create<Int>() | ||||
|                     setPagesSubject(download.pages, pageStatusSubject) | ||||
|                     return@flatMap pageStatusSubject | ||||
|                         .onBackpressureBuffer() | ||||
|                         .filter { it == Page.READY } | ||||
|                         .map { download } | ||||
|                 } else if (download.status == Download.DOWNLOADED || download.status == Download.ERROR) { | ||||
|                     setPagesSubject(download.pages, null) | ||||
|                 } | ||||
|                 Observable.just(download) | ||||
|             } | ||||
|             .filter { it.status == Download.DOWNLOADING } | ||||
|     } | ||||
|  | ||||
|     private fun setPagesSubject(pages: List<Page>?, subject: PublishSubject<Int>?) { | ||||
|         pages?.forEach { it.setStatusSubject(subject) } | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -5,7 +5,12 @@ import android.util.Log | ||||
| import com.bumptech.glide.Priority | ||||
| import com.bumptech.glide.load.DataSource | ||||
| import com.bumptech.glide.load.data.DataFetcher | ||||
| import java.io.* | ||||
| import java.io.File | ||||
| import java.io.FileInputStream | ||||
| import java.io.FileNotFoundException | ||||
| import java.io.IOException | ||||
| import java.io.InputStream | ||||
| import timber.log.Timber | ||||
|  | ||||
| open class FileFetcher(private val file: File) : DataFetcher<InputStream> { | ||||
|  | ||||
| @@ -20,7 +25,7 @@ open class FileFetcher(private val file: File) : DataFetcher<InputStream> { | ||||
|             data = FileInputStream(file) | ||||
|         } catch (e: FileNotFoundException) { | ||||
|             if (Log.isLoggable(TAG, Log.DEBUG)) { | ||||
|                 Log.d(TAG, "Failed to open file", e) | ||||
|                 Timber.d(e, "Failed to open file") | ||||
|             } | ||||
|             callback.onLoadFailed(e) | ||||
|             return | ||||
| @@ -48,4 +53,4 @@ open class FileFetcher(private val file: File) : DataFetcher<InputStream> { | ||||
|     override fun getDataSource(): DataSource { | ||||
|         return DataSource.LOCAL | ||||
|     } | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -16,44 +16,48 @@ import java.io.InputStream | ||||
|  * @param manga the manga of the cover to load. | ||||
|  * @param file the file where this cover should be. It may exists or not. | ||||
|  */ | ||||
| class LibraryMangaUrlFetcher(private val networkFetcher: DataFetcher<InputStream>, | ||||
|                              private val manga: Manga, | ||||
|                              private val file: File) | ||||
| : FileFetcher(file) { | ||||
| class LibraryMangaUrlFetcher( | ||||
|     private val networkFetcher: DataFetcher<InputStream>, | ||||
|     private val manga: Manga, | ||||
|     private val file: File | ||||
| ) : | ||||
|     FileFetcher(file) { | ||||
|  | ||||
|     override fun loadData(priority: Priority, callback: DataFetcher.DataCallback<in InputStream>) { | ||||
|         if (!file.exists()) { | ||||
|             networkFetcher.loadData(priority, object : DataFetcher.DataCallback<InputStream> { | ||||
|                 override fun onDataReady(data: InputStream?) { | ||||
|                     if (data != null) { | ||||
|                         val tmpFile = File(file.path + ".tmp") | ||||
|                         try { | ||||
|                             // Retrieve destination stream, create parent folders if needed. | ||||
|                             val output = try { | ||||
|                                 tmpFile.outputStream() | ||||
|                             } catch (e: FileNotFoundException) { | ||||
|                                 tmpFile.parentFile.mkdirs() | ||||
|                                 tmpFile.outputStream() | ||||
|                             } | ||||
|             networkFetcher.loadData( | ||||
|                 priority, | ||||
|                 object : DataFetcher.DataCallback<InputStream> { | ||||
|                     override fun onDataReady(data: InputStream?) { | ||||
|                         if (data != null) { | ||||
|                             val tmpFile = File(file.path + ".tmp") | ||||
|                             try { | ||||
|                                 // Retrieve destination stream, create parent folders if needed. | ||||
|                                 val output = try { | ||||
|                                     tmpFile.outputStream() | ||||
|                                 } catch (e: FileNotFoundException) { | ||||
|                                     tmpFile.parentFile.mkdirs() | ||||
|                                     tmpFile.outputStream() | ||||
|                                 } | ||||
|  | ||||
|                             // Copy the file and rename to the original. | ||||
|                             data.use { output.use { data.copyTo(output) } } | ||||
|                             tmpFile.renameTo(file) | ||||
|                             loadFromFile(callback) | ||||
|                         } catch (e: Exception) { | ||||
|                             tmpFile.delete() | ||||
|                             callback.onLoadFailed(e) | ||||
|                                 // Copy the file and rename to the original. | ||||
|                                 data.use { output.use { data.copyTo(output) } } | ||||
|                                 tmpFile.renameTo(file) | ||||
|                                 loadFromFile(callback) | ||||
|                             } catch (e: Exception) { | ||||
|                                 tmpFile.delete() | ||||
|                                 callback.onLoadFailed(e) | ||||
|                             } | ||||
|                         } else { | ||||
|                             callback.onLoadFailed(Exception("Null data")) | ||||
|                         } | ||||
|                     } else { | ||||
|                         callback.onLoadFailed(Exception("Null data")) | ||||
|                     } | ||||
|  | ||||
|                     override fun onLoadFailed(e: Exception) { | ||||
|                         callback.onLoadFailed(e) | ||||
|                     } | ||||
|                 } | ||||
|  | ||||
|                 override fun onLoadFailed(e: Exception) { | ||||
|                     callback.onLoadFailed(e) | ||||
|                 } | ||||
|  | ||||
|             }) | ||||
|             ) | ||||
|         } else { | ||||
|             loadFromFile(callback) | ||||
|         } | ||||
| @@ -68,5 +72,4 @@ class LibraryMangaUrlFetcher(private val networkFetcher: DataFetcher<InputStream | ||||
|         super.cancel() | ||||
|         networkFetcher.cancel() | ||||
|     } | ||||
|  | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -24,4 +24,4 @@ class MangaSignature(manga: Manga, file: File) : Key { | ||||
|     override fun updateDiskCacheKey(md: MessageDigest) { | ||||
|         md.update(key.toByteArray(Key.CHARSET)) | ||||
|     } | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -0,0 +1,7 @@ | ||||
| package eu.kanade.tachiyomi.data.glide | ||||
|  | ||||
| import eu.kanade.tachiyomi.data.database.models.Manga | ||||
|  | ||||
| data class MangaThumbnail(val manga: Manga, val url: String?) | ||||
|  | ||||
| fun Manga.toMangaThumbnail() = MangaThumbnail(this, this.thumbnail_url) | ||||
| @@ -3,18 +3,22 @@ package eu.kanade.tachiyomi.data.glide | ||||
| import android.util.LruCache | ||||
| import com.bumptech.glide.integration.okhttp3.OkHttpStreamFetcher | ||||
| import com.bumptech.glide.load.Options | ||||
| import com.bumptech.glide.load.model.* | ||||
| import com.bumptech.glide.load.model.GlideUrl | ||||
| import com.bumptech.glide.load.model.Headers | ||||
| import com.bumptech.glide.load.model.LazyHeaders | ||||
| import com.bumptech.glide.load.model.ModelLoader | ||||
| import com.bumptech.glide.load.model.ModelLoaderFactory | ||||
| import com.bumptech.glide.load.model.MultiModelLoaderFactory | ||||
| import eu.kanade.tachiyomi.data.cache.CoverCache | ||||
| import eu.kanade.tachiyomi.data.database.models.Manga | ||||
| import eu.kanade.tachiyomi.network.NetworkHelper | ||||
| import eu.kanade.tachiyomi.source.SourceManager | ||||
| import eu.kanade.tachiyomi.source.online.HttpSource | ||||
| import java.io.File | ||||
| import java.io.InputStream | ||||
| import uy.kohesive.injekt.Injekt | ||||
| import uy.kohesive.injekt.api.get | ||||
| import uy.kohesive.injekt.injectLazy | ||||
| import java.io.File | ||||
| import java.io.InputStream | ||||
| 
 | ||||
| 
 | ||||
| /** | ||||
|  * A class for loading a cover associated with a [Manga] that can be present in our own cache. | ||||
| @@ -27,7 +31,7 @@ import java.io.InputStream | ||||
|  * | ||||
|  * @param context the application context. | ||||
|  */ | ||||
| class MangaModelLoader : ModelLoader<Manga, InputStream> { | ||||
| class MangaThumbnailModelLoader : ModelLoader<MangaThumbnail, InputStream> { | ||||
| 
 | ||||
|     /** | ||||
|      * Cover cache where persistent covers are stored. | ||||
| @@ -56,18 +60,18 @@ class MangaModelLoader : ModelLoader<Manga, InputStream> { | ||||
|     private val cachedHeaders = hashMapOf<Long, LazyHeaders>() | ||||
| 
 | ||||
|     /** | ||||
|      * Factory class for creating [MangaModelLoader] instances. | ||||
|      * Factory class for creating [MangaThumbnailModelLoader] instances. | ||||
|      */ | ||||
|     class Factory : ModelLoaderFactory<Manga, InputStream> { | ||||
|     class Factory : ModelLoaderFactory<MangaThumbnail, InputStream> { | ||||
| 
 | ||||
|         override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<Manga, InputStream> { | ||||
|             return MangaModelLoader() | ||||
|         override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<MangaThumbnail, InputStream> { | ||||
|             return MangaThumbnailModelLoader() | ||||
|         } | ||||
| 
 | ||||
|         override fun teardown() {} | ||||
|     } | ||||
| 
 | ||||
|     override fun handles(model: Manga): Boolean { | ||||
|     override fun handles(model: MangaThumbnail): Boolean { | ||||
|         return true | ||||
|     } | ||||
| 
 | ||||
| @@ -78,15 +82,21 @@ class MangaModelLoader : ModelLoader<Manga, InputStream> { | ||||
|      * @param width the width of the view where the resource will be loaded. | ||||
|      * @param height the height of the view where the resource will be loaded. | ||||
|      */ | ||||
|     override fun buildLoadData(manga: Manga, width: Int, height: Int, | ||||
|                                options: Options): ModelLoader.LoadData<InputStream>? { | ||||
|     override fun buildLoadData( | ||||
|         mangaThumbnail: MangaThumbnail, | ||||
|         width: Int, | ||||
|         height: Int, | ||||
|         options: Options | ||||
|     ): ModelLoader.LoadData<InputStream>? { | ||||
|         // Check thumbnail is not null or empty | ||||
|         val url = manga.thumbnail_url | ||||
|         val url = mangaThumbnail.url | ||||
|         if (url == null || url.isEmpty()) { | ||||
|             return null | ||||
|         } | ||||
| 
 | ||||
|         if (url.startsWith("http")) { | ||||
|         val manga = mangaThumbnail.manga | ||||
| 
 | ||||
|         if (url.startsWith("http", true)) { | ||||
|             val source = sourceManager.get(manga.source) as? HttpSource | ||||
|             val glideUrl = GlideUrl(url, getHeaders(manga, source)) | ||||
| 
 | ||||
| @@ -118,7 +128,7 @@ class MangaModelLoader : ModelLoader<Manga, InputStream> { | ||||
|      * | ||||
|      * @param manga the model. | ||||
|      */ | ||||
|     fun getHeaders(manga: Manga, source: HttpSource?): Headers { | ||||
|     private fun getHeaders(manga: Manga, source: HttpSource?): Headers { | ||||
|         if (source == null) return LazyHeaders.DEFAULT | ||||
| 
 | ||||
|         return cachedHeaders.getOrPut(manga.source) { | ||||
| @@ -142,5 +152,4 @@ class MangaModelLoader : ModelLoader<Manga, InputStream> { | ||||
|             value | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
| } | ||||
| @@ -14,10 +14,10 @@ import java.io.InputStream | ||||
| class PassthroughModelLoader : ModelLoader<InputStream, InputStream> { | ||||
|  | ||||
|     override fun buildLoadData( | ||||
|             model: InputStream, | ||||
|             width: Int, | ||||
|             height: Int, | ||||
|             options: Options | ||||
|         model: InputStream, | ||||
|         width: Int, | ||||
|         height: Int, | ||||
|         options: Options | ||||
|     ): ModelLoader.LoadData<InputStream>? { | ||||
|         return ModelLoader.LoadData(ObjectKey(model), Fetcher(model)) | ||||
|     } | ||||
| @@ -49,12 +49,11 @@ class PassthroughModelLoader : ModelLoader<InputStream, InputStream> { | ||||
|         } | ||||
|  | ||||
|         override fun loadData( | ||||
|                 priority: Priority, | ||||
|                 callback: DataFetcher.DataCallback<in InputStream> | ||||
|             priority: Priority, | ||||
|             callback: DataFetcher.DataCallback<in InputStream> | ||||
|         ) { | ||||
|             callback.onDataReady(stream) | ||||
|         } | ||||
|  | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @@ -63,12 +62,11 @@ class PassthroughModelLoader : ModelLoader<InputStream, InputStream> { | ||||
|     class Factory : ModelLoaderFactory<InputStream, InputStream> { | ||||
|  | ||||
|         override fun build( | ||||
|                 multiFactory: MultiModelLoaderFactory | ||||
|             multiFactory: MultiModelLoaderFactory | ||||
|         ): ModelLoader<InputStream, InputStream> { | ||||
|             return PassthroughModelLoader() | ||||
|         } | ||||
|  | ||||
|         override fun teardown() {} | ||||
|     } | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -13,11 +13,10 @@ import com.bumptech.glide.load.model.GlideUrl | ||||
| import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions | ||||
| import com.bumptech.glide.module.AppGlideModule | ||||
| import com.bumptech.glide.request.RequestOptions | ||||
| import eu.kanade.tachiyomi.data.database.models.Manga | ||||
| import eu.kanade.tachiyomi.network.NetworkHelper | ||||
| import java.io.InputStream | ||||
| import uy.kohesive.injekt.Injekt | ||||
| import uy.kohesive.injekt.api.get | ||||
| import java.io.InputStream | ||||
|  | ||||
| /** | ||||
|  * Class used to update Glide module settings | ||||
| @@ -28,16 +27,21 @@ class TachiGlideModule : AppGlideModule() { | ||||
|     override fun applyOptions(context: Context, builder: GlideBuilder) { | ||||
|         builder.setDiskCache(InternalCacheDiskCacheFactory(context, 50 * 1024 * 1024)) | ||||
|         builder.setDefaultRequestOptions(RequestOptions().format(DecodeFormat.PREFER_RGB_565)) | ||||
|         builder.setDefaultTransitionOptions(Drawable::class.java, | ||||
|                 DrawableTransitionOptions.withCrossFade()) | ||||
|         builder.setDefaultTransitionOptions( | ||||
|             Drawable::class.java, | ||||
|             DrawableTransitionOptions.withCrossFade() | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     override fun registerComponents(context: Context, glide: Glide, registry: Registry) { | ||||
|         val networkFactory = OkHttpUrlLoader.Factory(Injekt.get<NetworkHelper>().client) | ||||
|  | ||||
|         registry.replace(GlideUrl::class.java, InputStream::class.java, networkFactory) | ||||
|         registry.append(Manga::class.java, InputStream::class.java, MangaModelLoader.Factory()) | ||||
|         registry.append(InputStream::class.java, InputStream::class.java, PassthroughModelLoader | ||||
|             .Factory()) | ||||
|         registry.append(MangaThumbnail::class.java, InputStream::class.java, MangaThumbnailModelLoader.Factory()) | ||||
|         registry.append( | ||||
|             InputStream::class.java, InputStream::class.java, | ||||
|             PassthroughModelLoader | ||||
|                 .Factory() | ||||
|         ) | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -1,47 +1,58 @@ | ||||
| package eu.kanade.tachiyomi.data.library | ||||
|  | ||||
| import com.evernote.android.job.Job | ||||
| import com.evernote.android.job.JobManager | ||||
| import com.evernote.android.job.JobRequest | ||||
| import android.content.Context | ||||
| import androidx.work.Constraints | ||||
| import androidx.work.ExistingPeriodicWorkPolicy | ||||
| import androidx.work.NetworkType | ||||
| import androidx.work.PeriodicWorkRequestBuilder | ||||
| import androidx.work.WorkManager | ||||
| import androidx.work.Worker | ||||
| import androidx.work.WorkerParameters | ||||
| import eu.kanade.tachiyomi.data.preference.PreferencesHelper | ||||
| import eu.kanade.tachiyomi.data.preference.getOrDefault | ||||
| import java.util.concurrent.TimeUnit | ||||
| import uy.kohesive.injekt.Injekt | ||||
| import uy.kohesive.injekt.api.get | ||||
|  | ||||
| class LibraryUpdateJob : Job() { | ||||
| class LibraryUpdateJob(private val context: Context, workerParams: WorkerParameters) : | ||||
|     Worker(context, workerParams) { | ||||
|  | ||||
|     override fun onRunJob(params: Params): Result { | ||||
|     override fun doWork(): Result { | ||||
|         LibraryUpdateService.start(context) | ||||
|         return Job.Result.SUCCESS | ||||
|         return Result.success() | ||||
|     } | ||||
|  | ||||
|     companion object { | ||||
|         const val TAG = "LibraryUpdate" | ||||
|         private const val TAG = "LibraryUpdate" | ||||
|  | ||||
|         fun setupTask(prefInterval: Int? = null) { | ||||
|         fun setupTask(context: Context, prefInterval: Int? = null) { | ||||
|             val preferences = Injekt.get<PreferencesHelper>() | ||||
|             val interval = prefInterval ?: preferences.libraryUpdateInterval().getOrDefault() | ||||
|             val interval = prefInterval ?: preferences.libraryUpdateInterval().get() | ||||
|             if (interval > 0) { | ||||
|                 val restrictions = preferences.libraryUpdateRestriction() | ||||
|                 val restrictions = preferences.libraryUpdateRestriction()!! | ||||
|                 val acRestriction = "ac" in restrictions | ||||
|                 val wifiRestriction = if ("wifi" in restrictions) | ||||
|                     JobRequest.NetworkType.UNMETERED | ||||
|                 else | ||||
|                     JobRequest.NetworkType.CONNECTED | ||||
|                 val wifiRestriction = if ("wifi" in restrictions) { | ||||
|                     NetworkType.UNMETERED | ||||
|                 } else { | ||||
|                     NetworkType.CONNECTED | ||||
|                 } | ||||
|  | ||||
|                 JobRequest.Builder(TAG) | ||||
|                         .setPeriodic(interval * 60 * 60 * 1000L, 10 * 60 * 1000) | ||||
|                         .setRequiredNetworkType(wifiRestriction) | ||||
|                         .setRequiresCharging(acRestriction) | ||||
|                         .setRequirementsEnforced(true) | ||||
|                         .setUpdateCurrent(true) | ||||
|                         .build() | ||||
|                         .schedule() | ||||
|                 val constraints = Constraints.Builder() | ||||
|                     .setRequiredNetworkType(wifiRestriction) | ||||
|                     .setRequiresCharging(acRestriction) | ||||
|                     .build() | ||||
|  | ||||
|                 val request = PeriodicWorkRequestBuilder<LibraryUpdateJob>( | ||||
|                     interval.toLong(), TimeUnit.HOURS, | ||||
|                     10, TimeUnit.MINUTES | ||||
|                 ) | ||||
|                     .addTag(TAG) | ||||
|                     .setConstraints(constraints) | ||||
|                     .build() | ||||
|  | ||||
|                 WorkManager.getInstance(context).enqueueUniquePeriodicWork(TAG, ExistingPeriodicWorkPolicy.REPLACE, request) | ||||
|             } else { | ||||
|                 WorkManager.getInstance(context).cancelAllWorkByTag(TAG) | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         fun cancelTask() { | ||||
|             JobManager.instance().cancelAllForTag(TAG) | ||||
|         } | ||||
|     } | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -8,8 +8,9 @@ import eu.kanade.tachiyomi.data.database.models.Manga | ||||
| object LibraryUpdateRanker { | ||||
|  | ||||
|     val rankingScheme = listOf( | ||||
|             (this::lexicographicRanking)(), | ||||
|             (this::latestFirstRanking)()) | ||||
|         (this::lexicographicRanking)(), | ||||
|         (this::latestFirstRanking)() | ||||
|     ) | ||||
|  | ||||
|     /** | ||||
|      * Provides a total ordering over all the Mangas. | ||||
| @@ -22,7 +23,7 @@ object LibraryUpdateRanker { | ||||
|      */ | ||||
|     fun latestFirstRanking(): Comparator<Manga> { | ||||
|         return Comparator { mangaFirst: Manga, | ||||
|                             mangaSecond: Manga -> | ||||
|             mangaSecond: Manga -> | ||||
|             compareValues(mangaSecond.last_update, mangaFirst.last_update) | ||||
|         } | ||||
|     } | ||||
| @@ -35,9 +36,8 @@ object LibraryUpdateRanker { | ||||
|      */ | ||||
|     fun lexicographicRanking(): Comparator<Manga> { | ||||
|         return Comparator { mangaFirst: Manga, | ||||
|                                    mangaSecond: Manga -> | ||||
|             mangaSecond: Manga -> | ||||
|             compareValues(mangaFirst.title, mangaSecond.title) | ||||
|         } | ||||
|     } | ||||
|  | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -1,12 +1,19 @@ | ||||
| package eu.kanade.tachiyomi.data.library | ||||
|  | ||||
| import android.app.Notification | ||||
| import android.app.PendingIntent | ||||
| import android.app.Service | ||||
| import android.content.Context | ||||
| import android.content.Intent | ||||
| import android.graphics.Bitmap | ||||
| import android.graphics.BitmapFactory | ||||
| import android.os.Build | ||||
| import android.os.IBinder | ||||
| import android.os.PowerManager | ||||
| import androidx.core.app.NotificationCompat | ||||
| import androidx.core.app.NotificationCompat.GROUP_ALERT_SUMMARY | ||||
| import androidx.core.app.NotificationManagerCompat | ||||
| import com.bumptech.glide.Glide | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.database.DatabaseHelper | ||||
| import eu.kanade.tachiyomi.data.database.models.Category | ||||
| @@ -15,28 +22,34 @@ import eu.kanade.tachiyomi.data.database.models.LibraryManga | ||||
| import eu.kanade.tachiyomi.data.database.models.Manga | ||||
| import eu.kanade.tachiyomi.data.download.DownloadManager | ||||
| import eu.kanade.tachiyomi.data.download.DownloadService | ||||
| import eu.kanade.tachiyomi.data.glide.toMangaThumbnail | ||||
| import eu.kanade.tachiyomi.data.library.LibraryUpdateRanker.rankingScheme | ||||
| import eu.kanade.tachiyomi.data.library.LibraryUpdateService.Companion.start | ||||
| import eu.kanade.tachiyomi.data.notification.NotificationReceiver | ||||
| import eu.kanade.tachiyomi.data.notification.Notifications | ||||
| import eu.kanade.tachiyomi.data.preference.PreferencesHelper | ||||
| import eu.kanade.tachiyomi.data.preference.getOrDefault | ||||
| import eu.kanade.tachiyomi.data.track.TrackManager | ||||
| import eu.kanade.tachiyomi.source.SourceManager | ||||
| import eu.kanade.tachiyomi.source.model.SManga | ||||
| import eu.kanade.tachiyomi.source.online.HttpSource | ||||
| import eu.kanade.tachiyomi.util.isServiceRunning | ||||
| import eu.kanade.tachiyomi.util.notificationManager | ||||
| import eu.kanade.tachiyomi.util.syncChaptersWithSource | ||||
| import eu.kanade.tachiyomi.ui.main.MainActivity | ||||
| import eu.kanade.tachiyomi.util.chapter.syncChaptersWithSource | ||||
| import eu.kanade.tachiyomi.util.lang.chop | ||||
| import eu.kanade.tachiyomi.util.system.isServiceRunning | ||||
| import eu.kanade.tachiyomi.util.system.notification | ||||
| import eu.kanade.tachiyomi.util.system.notificationBuilder | ||||
| import eu.kanade.tachiyomi.util.system.notificationManager | ||||
| import exh.LIBRARY_UPDATE_EXCLUDED_SOURCES | ||||
| import java.text.DecimalFormat | ||||
| import java.text.DecimalFormatSymbols | ||||
| import java.util.ArrayList | ||||
| import java.util.concurrent.atomic.AtomicInteger | ||||
| import rx.Observable | ||||
| import rx.Subscription | ||||
| import rx.schedulers.Schedulers | ||||
| import timber.log.Timber | ||||
| import uy.kohesive.injekt.Injekt | ||||
| import uy.kohesive.injekt.api.get | ||||
| import java.util.* | ||||
| import java.util.concurrent.atomic.AtomicInteger | ||||
|  | ||||
| /** | ||||
|  * This class will take care of updating the chapters of the manga from the library. It can be | ||||
| @@ -47,11 +60,11 @@ import java.util.concurrent.atomic.AtomicInteger | ||||
|  * destroyed. | ||||
|  */ | ||||
| class LibraryUpdateService( | ||||
|         val db: DatabaseHelper = Injekt.get(), | ||||
|         val sourceManager: SourceManager = Injekt.get(), | ||||
|         val preferences: PreferencesHelper = Injekt.get(), | ||||
|         val downloadManager: DownloadManager = Injekt.get(), | ||||
|         val trackManager: TrackManager = Injekt.get() | ||||
|     val db: DatabaseHelper = Injekt.get(), | ||||
|     val sourceManager: SourceManager = Injekt.get(), | ||||
|     val preferences: PreferencesHelper = Injekt.get(), | ||||
|     val downloadManager: DownloadManager = Injekt.get(), | ||||
|     val trackManager: TrackManager = Injekt.get() | ||||
| ) : Service() { | ||||
|  | ||||
|     /** | ||||
| @@ -76,13 +89,15 @@ class LibraryUpdateService( | ||||
|     /** | ||||
|      * Cached progress notification to avoid creating a lot. | ||||
|      */ | ||||
|     private val progressNotification by lazy { NotificationCompat.Builder(this, Notifications.CHANNEL_LIBRARY) | ||||
|             .setContentTitle(getString(R.string.app_name)) | ||||
|             .setSmallIcon(R.drawable.ic_refresh_white_24dp_img) | ||||
|             .setLargeIcon(updateNotifier.notificationBitmap) | ||||
|             .setOngoing(true) | ||||
|             .setOnlyAlertOnce(true) | ||||
|             .addAction(R.drawable.ic_clear_grey_24dp_img, getString(android.R.string.cancel), cancelIntent) | ||||
|     private val progressNotificationBuilder by lazy { | ||||
|         notificationBuilder(Notifications.CHANNEL_LIBRARY) { | ||||
|             setContentTitle(getString(R.string.app_name)) | ||||
|             setSmallIcon(R.drawable.ic_refresh_24dp) | ||||
|             setLargeIcon(notificationBitmap) | ||||
|             setOngoing(true) | ||||
|             setOnlyAlertOnce(true) | ||||
|             addAction(R.drawable.ic_close_24dp, getString(android.R.string.cancel), cancelIntent) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @@ -90,8 +105,8 @@ class LibraryUpdateService( | ||||
|      */ | ||||
|     enum class Target { | ||||
|         CHAPTERS, // Manga chapters | ||||
|         DETAILS,  // Manga metadata | ||||
|         TRACKING  // Tracking metadata | ||||
|         DETAILS, // Manga metadata | ||||
|         TRACKING // Tracking metadata | ||||
|     } | ||||
|  | ||||
|     companion object { | ||||
| @@ -106,6 +121,10 @@ class LibraryUpdateService( | ||||
|          */ | ||||
|         const val KEY_TARGET = "target" | ||||
|  | ||||
|         private const val NOTIF_MAX_CHAPTERS = 5 | ||||
|         private const val NOTIF_TITLE_MAX_LEN = 45 | ||||
|         private const val NOTIF_ICON_SIZE = 192 | ||||
|  | ||||
|         /** | ||||
|          * Returns the status of the service. | ||||
|          * | ||||
| @@ -123,8 +142,9 @@ class LibraryUpdateService( | ||||
|          * @param context the application context. | ||||
|          * @param category a specific category to update, or null for global update. | ||||
|          * @param target defines what should be updated. | ||||
|          * @return true if service newly started, false otherwise | ||||
|          */ | ||||
|         fun start(context: Context, category: Category? = null, target: Target = Target.CHAPTERS) { | ||||
|         fun start(context: Context, category: Category? = null, target: Target = Target.CHAPTERS): Boolean { | ||||
|             if (!isRunning(context)) { | ||||
|                 val intent = Intent(context, LibraryUpdateService::class.java).apply { | ||||
|                     putExtra(KEY_TARGET, target) | ||||
| @@ -135,7 +155,11 @@ class LibraryUpdateService( | ||||
|                 } else { | ||||
|                     context.startForegroundService(intent) | ||||
|                 } | ||||
|  | ||||
|                 return true | ||||
|             } | ||||
|  | ||||
|             return false | ||||
|         } | ||||
|  | ||||
|         /** | ||||
| @@ -146,7 +170,6 @@ class LibraryUpdateService( | ||||
|         fun stop(context: Context) { | ||||
|             context.stopService(Intent(context, LibraryUpdateService::class.java)) | ||||
|         } | ||||
|  | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @@ -155,9 +178,10 @@ class LibraryUpdateService( | ||||
|      */ | ||||
|     override fun onCreate() { | ||||
|         super.onCreate() | ||||
|         startForeground(Notifications.ID_LIBRARY_PROGRESS, progressNotification.build()) | ||||
|         startForeground(Notifications.ID_LIBRARY_PROGRESS, progressNotificationBuilder.build()) | ||||
|         wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).newWakeLock( | ||||
|                 PowerManager.PARTIAL_WAKE_LOCK, "LibraryUpdateService:WakeLock") | ||||
|             PowerManager.PARTIAL_WAKE_LOCK, "LibraryUpdateService:WakeLock" | ||||
|         ) | ||||
|         wakeLock.acquire() | ||||
|     } | ||||
|  | ||||
| @@ -189,37 +213,41 @@ class LibraryUpdateService( | ||||
|      * @return the start value of the command. | ||||
|      */ | ||||
|     override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { | ||||
|         if (intent == null) return Service.START_NOT_STICKY | ||||
|         if (intent == null) return START_NOT_STICKY | ||||
|         val target = intent.getSerializableExtra(KEY_TARGET) as? Target | ||||
|                 ?: return Service.START_NOT_STICKY | ||||
|             ?: return START_NOT_STICKY | ||||
|  | ||||
|         // Unsubscribe from any previous subscription if needed. | ||||
|         subscription?.unsubscribe() | ||||
|  | ||||
|         // Update favorite manga. Destroy service when completed or in case of an error. | ||||
|         subscription = Observable | ||||
|                 .defer { | ||||
|                     val selectedScheme = preferences.libraryUpdatePrioritization().getOrDefault() | ||||
|                     val mangaList = getMangaToUpdate(intent, target) | ||||
|                             .sortedWith(rankingScheme[selectedScheme]) | ||||
|             .defer { | ||||
|                 val selectedScheme = preferences.libraryUpdatePrioritization().get() | ||||
|                 val mangaList = getMangaToUpdate(intent, target) | ||||
|                     .sortedWith(rankingScheme[selectedScheme]) | ||||
|  | ||||
|                     // Update either chapter list or manga details. | ||||
|                     when (target) { | ||||
|                         Target.CHAPTERS -> updateChapterList(mangaList) | ||||
|                         Target.DETAILS -> updateDetails(mangaList) | ||||
|                         Target.TRACKING -> updateTrackings(mangaList) | ||||
|                     } | ||||
|                 // Update either chapter list or manga details. | ||||
|                 when (target) { | ||||
|                     Target.CHAPTERS -> updateChapterList(mangaList) | ||||
|                     Target.DETAILS -> updateDetails(mangaList) | ||||
|                     Target.TRACKING -> updateTrackings(mangaList) | ||||
|                 } | ||||
|                 .subscribeOn(Schedulers.io()) | ||||
|                 .subscribe({ | ||||
|                 }, { | ||||
|             } | ||||
|             .subscribeOn(Schedulers.io()) | ||||
|             .subscribe( | ||||
|                 { | ||||
|                 }, | ||||
|                 { | ||||
|                     Timber.e(it) | ||||
|                     stopSelf(startId) | ||||
|                 }, { | ||||
|                 }, | ||||
|                 { | ||||
|                     stopSelf(startId) | ||||
|                 }) | ||||
|                 } | ||||
|             ) | ||||
|  | ||||
|         return Service.START_REDELIVER_INTENT | ||||
|         return START_REDELIVER_INTENT | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @@ -232,18 +260,18 @@ class LibraryUpdateService( | ||||
|     fun getMangaToUpdate(intent: Intent, target: Target): List<LibraryManga> { | ||||
|         val categoryId = intent.getIntExtra(KEY_CATEGORY, -1) | ||||
|  | ||||
|         var listToUpdate = if (categoryId != -1) | ||||
|         var listToUpdate = if (categoryId != -1) { | ||||
|             db.getLibraryMangas().executeAsBlocking().filter { it.category == categoryId } | ||||
|         else { | ||||
|             val categoriesToUpdate = preferences.libraryUpdateCategories().getOrDefault().map(String::toInt) | ||||
|             if (categoriesToUpdate.isNotEmpty()) | ||||
|         } else { | ||||
|             val categoriesToUpdate = preferences.libraryUpdateCategories().get().map(String::toInt) | ||||
|             if (categoriesToUpdate.isNotEmpty()) { | ||||
|                 db.getLibraryMangas().executeAsBlocking() | ||||
|                         .filter { it.category in categoriesToUpdate } | ||||
|                         .distinctBy { it.id } | ||||
|             else | ||||
|                     .filter { it.category in categoriesToUpdate } | ||||
|                     .distinctBy { it.id } | ||||
|             } else { | ||||
|                 db.getLibraryMangas().executeAsBlocking().distinctBy { it.id } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         if (target == Target.CHAPTERS && preferences.updateOnlyNonCompleted()) { | ||||
|             listToUpdate = listToUpdate.filter { it.status != SManga.COMPLETED } | ||||
|         } | ||||
| @@ -264,66 +292,73 @@ class LibraryUpdateService( | ||||
|         // Initialize the variables holding the progress of the updates. | ||||
|         val count = AtomicInteger(0) | ||||
|         // List containing new updates | ||||
|         val newUpdates = ArrayList<Manga>() | ||||
|         // list containing failed updates | ||||
|         val newUpdates = ArrayList<Pair<LibraryManga, Array<Chapter>>>() | ||||
|         // List containing failed updates | ||||
|         val failedUpdates = ArrayList<Manga>() | ||||
|         // List containing categories that get included in downloads. | ||||
|         val categoriesToDownload = preferences.downloadNewCategories().getOrDefault().map(String::toInt) | ||||
|         val categoriesToDownload = preferences.downloadNewCategories().get().map(String::toInt) | ||||
|         // Boolean to determine if user wants to automatically download new chapters. | ||||
|         val downloadNew = preferences.downloadNew().getOrDefault() | ||||
|         val downloadNew = preferences.downloadNew().get() | ||||
|         // Boolean to determine if DownloadManager has downloads | ||||
|         var hasDownloads = false | ||||
|  | ||||
|         // Emit each manga and update it sequentially. | ||||
|         return Observable.from(mangaToUpdate) | ||||
|                 // Notify manga that will update. | ||||
|                 .doOnNext { showProgressNotification(it, count.andIncrement, mangaToUpdate.size) } | ||||
|                 // Update the chapters of the manga. | ||||
|                 .concatMap { manga -> | ||||
|                     if(manga.source in LIBRARY_UPDATE_EXCLUDED_SOURCES) { | ||||
|             // Notify manga that will update. | ||||
|             .doOnNext { showProgressNotification(it, count.andIncrement, mangaToUpdate.size) } | ||||
|             // Update the chapters of the manga. | ||||
|             .concatMap { manga -> | ||||
|             if(manga.source in LIBRARY_UPDATE_EXCLUDED_SOURCES) { | ||||
|                         // Ignore EXH manga, updating chapters for every manga will get you banned | ||||
|                         Observable.empty() | ||||
|                     } else { | ||||
|                         updateManga(manga) | ||||
|                                 // If there's any error, return empty update and continue. | ||||
|                                 .onErrorReturn { | ||||
|                                     failedUpdates.add(manga) | ||||
|                                     Pair(emptyList(), emptyList()) | ||||
|                             // If there's any error, return empty update and continue. | ||||
|                             .onErrorReturn { | ||||
|                                 failedUpdates.add(manga) | ||||
|                                 Pair(emptyList(), emptyList()) | ||||
|                             } | ||||
|                             // Filter out mangas without new chapters (or failed). | ||||
|                             .filter { pair -> pair.first.isNotEmpty() } | ||||
|                             .doOnNext { | ||||
|                                 if (downloadNew && ( | ||||
|                                     categoriesToDownload.isEmpty() || | ||||
|                                         manga.category in categoriesToDownload | ||||
|                                     ) | ||||
|                                 ) { | ||||
|                                     downloadChapters(manga, it.first) | ||||
|                                     hasDownloads = true | ||||
|                                 } | ||||
|                                 // Filter out mangas without new chapters (or failed). | ||||
|                                 .filter { pair -> pair.first.isNotEmpty() } | ||||
|                                 .doOnNext { | ||||
|                                     if (downloadNew && (categoriesToDownload.isEmpty() || | ||||
|                                                     manga.category in categoriesToDownload)) { | ||||
|  | ||||
|                                         downloadChapters(manga, it.first) | ||||
|                                         hasDownloads = true | ||||
|                                     } | ||||
|                                 } | ||||
|                                 // Convert to the manga that contains new chapters. | ||||
|                                 .map { manga } | ||||
|                             } | ||||
|                             // Convert to the manga that contains new chapters. | ||||
|                             .map { | ||||
|                                 Pair( | ||||
|                                     manga, | ||||
|                                     (it.first.sortedByDescending { ch -> ch.source_order }.toTypedArray()) | ||||
|                                 ) | ||||
|                     } | ||||
|             } | ||||
|             // Add manga with new chapters to the list. | ||||
|             .doOnNext { manga -> | ||||
|                 // Add to the list | ||||
|                 newUpdates.add(manga) | ||||
|             } | ||||
|             // Notify result of the overall update. | ||||
|             .doOnCompleted { | ||||
|                 if (newUpdates.isNotEmpty()) { | ||||
|                     showUpdateNotifications(newUpdates) | ||||
|                     if (downloadNew && hasDownloads) { | ||||
|                         DownloadService.start(this) | ||||
|                     } | ||||
|                 } | ||||
|                 // Add manga with new chapters to the list. | ||||
|                 .doOnNext { manga -> | ||||
|                     // Add to the list | ||||
|                     newUpdates.add(manga) | ||||
|                 } | ||||
|                 // Notify result of the overall update. | ||||
|                 .doOnCompleted { | ||||
|                     if (newUpdates.isNotEmpty()) { | ||||
|                         updateNotifier.showResultNotification(newUpdates) | ||||
|                         if (downloadNew && hasDownloads) { | ||||
|                             DownloadService.start(this) | ||||
|                         } | ||||
|                     } | ||||
|  | ||||
|                     if (failedUpdates.isNotEmpty()) { | ||||
|                         Timber.e("Failed updating: ${failedUpdates.map { it.title }}") | ||||
|                     } | ||||
|  | ||||
|                     cancelProgressNotification() | ||||
|                 if (failedUpdates.isNotEmpty()) { | ||||
|                     Timber.e("Failed updating: ${failedUpdates.map { it.title }}") | ||||
|                 } | ||||
|  | ||||
|                 cancelProgressNotification() | ||||
|             } | ||||
|             .map { manga -> manga.first } | ||||
|     } | ||||
|  | ||||
|     fun downloadChapters(manga: Manga, chapters: List<Chapter>) { | ||||
| @@ -346,7 +381,7 @@ class LibraryUpdateService( | ||||
|     fun updateManga(manga: Manga): Observable<Pair<List<Chapter>, List<Chapter>>> { | ||||
|         val source = sourceManager.get(manga.source) as? HttpSource ?: return Observable.empty() | ||||
|         return source.fetchChapterList(manga) | ||||
|                 .map { syncChaptersWithSource(db, it, manga, source) } | ||||
|             .map { syncChaptersWithSource(db, it, manga, source) } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @@ -362,24 +397,24 @@ class LibraryUpdateService( | ||||
|  | ||||
|         // Emit each manga and update it sequentially. | ||||
|         return Observable.from(mangaToUpdate) | ||||
|                 // Notify manga that will update. | ||||
|                 .doOnNext { showProgressNotification(it, count.andIncrement, mangaToUpdate.size) } | ||||
|                 // Update the details of the manga. | ||||
|                 .concatMap { manga -> | ||||
|                     val source = sourceManager.get(manga.source) as? HttpSource | ||||
|                             ?: return@concatMap Observable.empty<LibraryManga>() | ||||
|             // Notify manga that will update. | ||||
|             .doOnNext { showProgressNotification(it, count.andIncrement, mangaToUpdate.size) } | ||||
|             // Update the details of the manga. | ||||
|             .concatMap { manga -> | ||||
|                 val source = sourceManager.get(manga.source) as? HttpSource | ||||
|                     ?: return@concatMap Observable.empty<LibraryManga>() | ||||
|  | ||||
|                     source.fetchMangaDetails(manga) | ||||
|                             .map { networkManga -> | ||||
|                                 manga.copyFrom(networkManga) | ||||
|                                 db.insertManga(manga).executeAsBlocking() | ||||
|                                 manga | ||||
|                             } | ||||
|                             .onErrorReturn { manga } | ||||
|                 } | ||||
|                 .doOnCompleted { | ||||
|                     cancelProgressNotification() | ||||
|                 } | ||||
|                 source.fetchMangaDetails(manga) | ||||
|                     .map { networkManga -> | ||||
|                         manga.copyFrom(networkManga) | ||||
|                         db.insertManga(manga).executeAsBlocking() | ||||
|                         manga | ||||
|                     } | ||||
|                     .onErrorReturn { manga } | ||||
|             } | ||||
|             .doOnCompleted { | ||||
|                 cancelProgressNotification() | ||||
|             } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @@ -394,28 +429,28 @@ class LibraryUpdateService( | ||||
|  | ||||
|         // Emit each manga and update it sequentially. | ||||
|         return Observable.from(mangaToUpdate) | ||||
|                 // Notify manga that will update. | ||||
|                 .doOnNext { showProgressNotification(it, count++, mangaToUpdate.size) } | ||||
|                 // Update the tracking details. | ||||
|                 .concatMap { manga -> | ||||
|                     val tracks = db.getTracks(manga).executeAsBlocking() | ||||
|             // Notify manga that will update. | ||||
|             .doOnNext { showProgressNotification(it, count++, mangaToUpdate.size) } | ||||
|             // Update the tracking details. | ||||
|             .concatMap { manga -> | ||||
|                 val tracks = db.getTracks(manga).executeAsBlocking() | ||||
|  | ||||
|                     Observable.from(tracks) | ||||
|                             .concatMap { track -> | ||||
|                                 val service = trackManager.getService(track.sync_id) | ||||
|                                 if (service != null && service in loggedServices) { | ||||
|                                     service.refresh(track) | ||||
|                                             .doOnNext { db.insertTrack(it).executeAsBlocking() } | ||||
|                                             .onErrorReturn { track } | ||||
|                                 } else { | ||||
|                                     Observable.empty() | ||||
|                                 } | ||||
|                             } | ||||
|                             .map { manga } | ||||
|                 } | ||||
|                 .doOnCompleted { | ||||
|                     cancelProgressNotification() | ||||
|                 } | ||||
|                 Observable.from(tracks) | ||||
|                     .concatMap { track -> | ||||
|                         val service = trackManager.getService(track.sync_id) | ||||
|                         if (service != null && service in loggedServices) { | ||||
|                             service.refresh(track) | ||||
|                                 .doOnNext { db.insertTrack(it).executeAsBlocking() } | ||||
|                                 .onErrorReturn { track } | ||||
|                         } else { | ||||
|                             Observable.empty() | ||||
|                         } | ||||
|                     } | ||||
|                     .map { manga } | ||||
|             } | ||||
|             .doOnCompleted { | ||||
|                 cancelProgressNotification() | ||||
|             } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @@ -426,10 +461,116 @@ class LibraryUpdateService( | ||||
|      * @param total the total progress. | ||||
|      */ | ||||
|     private fun showProgressNotification(manga: Manga, current: Int, total: Int) { | ||||
|         notificationManager.notify(Notifications.ID_LIBRARY_PROGRESS, progressNotification | ||||
|                 .setContentTitle(manga.title) | ||||
|         val title = if (preferences.hideNotificationContent()) { | ||||
|             getString(R.string.notification_check_updates) | ||||
|         } else { | ||||
|             manga.title | ||||
|         } | ||||
|  | ||||
|         notificationManager.notify( | ||||
|             Notifications.ID_LIBRARY_PROGRESS, | ||||
|             progressNotificationBuilder | ||||
|                 .setContentTitle(title) | ||||
|                 .setProgress(total, current, false) | ||||
|                 .build()) | ||||
|                 .build() | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Shows the notification containing the result of the update done by the service. | ||||
|      * | ||||
|      * @param updates a list of manga with new updates. | ||||
|      */ | ||||
|     private fun showUpdateNotifications(updates: List<Pair<Manga, Array<Chapter>>>) { | ||||
|         if (updates.isEmpty()) { | ||||
|             return | ||||
|         } | ||||
|  | ||||
|         NotificationManagerCompat.from(this).apply { | ||||
|             // Parent group notification | ||||
|             notify( | ||||
|                 Notifications.ID_NEW_CHAPTERS, | ||||
|                 notification(Notifications.CHANNEL_NEW_CHAPTERS) { | ||||
|                     setContentTitle(getString(R.string.notification_new_chapters)) | ||||
|                     if (updates.size == 1 && !preferences.hideNotificationContent()) { | ||||
|                         setContentText(updates.first().first.title.chop(NOTIF_TITLE_MAX_LEN)) | ||||
|                     } else { | ||||
|                         setContentText(resources.getQuantityString(R.plurals.notification_new_chapters_summary, updates.size, updates.size)) | ||||
|  | ||||
|                         if (!preferences.hideNotificationContent()) { | ||||
|                             setStyle( | ||||
|                                 NotificationCompat.BigTextStyle().bigText( | ||||
|                                     updates.joinToString("\n") { | ||||
|                                         it.first.title.chop(NOTIF_TITLE_MAX_LEN) | ||||
|                                     } | ||||
|                                 ) | ||||
|                             ) | ||||
|                         } | ||||
|                     } | ||||
|  | ||||
|                     setSmallIcon(R.drawable.ic_tachi) | ||||
|                     setLargeIcon(notificationBitmap) | ||||
|  | ||||
|                     setGroup(Notifications.GROUP_NEW_CHAPTERS) | ||||
|                     setGroupAlertBehavior(GROUP_ALERT_SUMMARY) | ||||
|                     setGroupSummary(true) | ||||
|                     priority = NotificationCompat.PRIORITY_HIGH | ||||
|  | ||||
|                     setContentIntent(getNotificationIntent()) | ||||
|                     setAutoCancel(true) | ||||
|                 } | ||||
|             ) | ||||
|  | ||||
|             // Per-manga notification | ||||
|             if (!preferences.hideNotificationContent()) { | ||||
|                 updates.forEach { | ||||
|                     val (manga, chapters) = it | ||||
|                     notify(manga.id.hashCode(), createNewChaptersNotification(manga, chapters)) | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private fun createNewChaptersNotification(manga: Manga, chapters: Array<Chapter>): Notification { | ||||
|         return notification(Notifications.CHANNEL_NEW_CHAPTERS) { | ||||
|             setContentTitle(manga.title) | ||||
|  | ||||
|             val description = getNewChaptersDescription(chapters) | ||||
|             setContentText(description) | ||||
|             setStyle(NotificationCompat.BigTextStyle().bigText(description)) | ||||
|  | ||||
|             setSmallIcon(R.drawable.ic_tachi) | ||||
|  | ||||
|             val icon = getMangaIcon(manga) | ||||
|             if (icon != null) { | ||||
|                 setLargeIcon(icon) | ||||
|             } | ||||
|  | ||||
|             setGroup(Notifications.GROUP_NEW_CHAPTERS) | ||||
|             setGroupAlertBehavior(GROUP_ALERT_SUMMARY) | ||||
|             priority = NotificationCompat.PRIORITY_HIGH | ||||
|  | ||||
|             // Open first chapter on tap | ||||
|             setContentIntent(NotificationReceiver.openChapterPendingActivity(this@LibraryUpdateService, manga, chapters.first())) | ||||
|             setAutoCancel(true) | ||||
|  | ||||
|             // Mark chapters as read action | ||||
|             addAction( | ||||
|                 R.drawable.ic_glasses_black_24dp, getString(R.string.action_mark_as_read), | ||||
|                 NotificationReceiver.markAsReadPendingBroadcast( | ||||
|                     this@LibraryUpdateService, | ||||
|                     manga, chapters, Notifications.ID_NEW_CHAPTERS | ||||
|                 ) | ||||
|             ) | ||||
|             // View chapters action | ||||
|             addAction( | ||||
|                 R.drawable.ic_book_24dp, getString(R.string.action_view_chapters), | ||||
|                 NotificationReceiver.openChapterPendingActivity( | ||||
|                     this@LibraryUpdateService, | ||||
|                     manga, Notifications.ID_NEW_CHAPTERS | ||||
|                 ) | ||||
|             ) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @@ -438,4 +579,77 @@ class LibraryUpdateService( | ||||
|     private fun cancelProgressNotification() { | ||||
|         notificationManager.cancel(Notifications.ID_LIBRARY_PROGRESS) | ||||
|     } | ||||
|  | ||||
|     private fun getMangaIcon(manga: Manga): Bitmap? { | ||||
|         return try { | ||||
|             Glide.with(this) | ||||
|                 .asBitmap() | ||||
|                 .load(manga.toMangaThumbnail()) | ||||
|                 .dontTransform() | ||||
|                 .centerCrop() | ||||
|                 .circleCrop() | ||||
|                 .override(NOTIF_ICON_SIZE, NOTIF_ICON_SIZE) | ||||
|                 .submit() | ||||
|                 .get() | ||||
|         } catch (e: Exception) { | ||||
|             null | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private fun getNewChaptersDescription(chapters: Array<Chapter>): String { | ||||
|         val formatter = DecimalFormat( | ||||
|             "#.###", | ||||
|             DecimalFormatSymbols() | ||||
|                 .apply { decimalSeparator = '.' } | ||||
|         ) | ||||
|  | ||||
|         val displayableChapterNumbers = chapters | ||||
|             .filter { it.isRecognizedNumber } | ||||
|             .sortedBy { it.chapter_number } | ||||
|             .map { formatter.format(it.chapter_number) } | ||||
|             .toSet() | ||||
|  | ||||
|         return when (displayableChapterNumbers.size) { | ||||
|             // No sensible chapter numbers to show (i.e. no chapters have parsed chapter number) | ||||
|             0 -> { | ||||
|                 // "1 new chapter" or "5 new chapters" | ||||
|                 resources.getQuantityString(R.plurals.notification_chapters_generic, chapters.size, chapters.size) | ||||
|             } | ||||
|             // Only 1 chapter has a parsed chapter number | ||||
|             1 -> { | ||||
|                 val remaining = chapters.size - displayableChapterNumbers.size | ||||
|                 if (remaining == 0) { | ||||
|                     // "Chapter 2.5" | ||||
|                     resources.getString(R.string.notification_chapters_single, displayableChapterNumbers.first()) | ||||
|                 } else { | ||||
|                     // "Chapter 2.5 and 10 more" | ||||
|                     resources.getString(R.string.notification_chapters_single_and_more, displayableChapterNumbers.first(), remaining) | ||||
|                 } | ||||
|             } | ||||
|             // Everything else (i.e. multiple parsed chapter numbers) | ||||
|             else -> { | ||||
|                 val shouldTruncate = displayableChapterNumbers.size > NOTIF_MAX_CHAPTERS | ||||
|                 if (shouldTruncate) { | ||||
|                     // "Chapters 1, 2.5, 3, 4, 5 and 10 more" | ||||
|                     val remaining = displayableChapterNumbers.size - NOTIF_MAX_CHAPTERS | ||||
|                     val joinedChapterNumbers = displayableChapterNumbers.take(NOTIF_MAX_CHAPTERS).joinToString(", ") | ||||
|                     resources.getQuantityString(R.plurals.notification_chapters_multiple_and_more, remaining, joinedChapterNumbers, remaining) | ||||
|                 } else { | ||||
|                     // "Chapters 1, 2.5, 3" | ||||
|                     resources.getString(R.string.notification_chapters_multiple, displayableChapterNumbers.joinToString(", ")) | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns an intent to open the main activity. | ||||
|      */ | ||||
|     private fun getNotificationIntent(): PendingIntent { | ||||
|         val intent = Intent(this, MainActivity::class.java).apply { | ||||
|             flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP | ||||
|             action = MainActivity.SHORTCUT_RECENTLY_UPDATED | ||||
|         } | ||||
|         return PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -4,8 +4,9 @@ import android.app.PendingIntent | ||||
| import android.content.Context | ||||
| import android.content.Intent | ||||
| import android.net.Uri | ||||
| import eu.kanade.tachiyomi.extension.util.ExtensionInstaller | ||||
| import eu.kanade.tachiyomi.ui.main.MainActivity | ||||
| import eu.kanade.tachiyomi.util.getUriCompat | ||||
| import eu.kanade.tachiyomi.util.storage.getUriCompat | ||||
| import java.io.File | ||||
|  | ||||
| /** | ||||
| @@ -48,7 +49,7 @@ object NotificationHandler { | ||||
|      */ | ||||
|     fun installApkPendingActivity(context: Context, uri: Uri): PendingIntent { | ||||
|         val intent = Intent(Intent.ACTION_VIEW).apply { | ||||
|             setDataAndType(uri, "application/vnd.android.package-archive") | ||||
|             setDataAndType(uri, ExtensionInstaller.APK_MIME) | ||||
|             flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION | ||||
|         } | ||||
|         return PendingIntent.getActivity(context, 0, intent, 0) | ||||
|   | ||||
| @@ -4,22 +4,32 @@ import android.app.PendingIntent | ||||
| import android.content.BroadcastReceiver | ||||
| import android.content.Context | ||||
| import android.content.Intent | ||||
| import android.net.Uri | ||||
| import android.os.Build | ||||
| import android.os.Handler | ||||
| import eu.kanade.tachiyomi.BuildConfig.APPLICATION_ID as ID | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.backup.BackupRestoreService | ||||
| import eu.kanade.tachiyomi.data.database.DatabaseHelper | ||||
| import eu.kanade.tachiyomi.data.database.models.Chapter | ||||
| import eu.kanade.tachiyomi.data.database.models.Manga | ||||
| import eu.kanade.tachiyomi.data.download.DownloadManager | ||||
| import eu.kanade.tachiyomi.data.download.DownloadService | ||||
| import eu.kanade.tachiyomi.data.library.LibraryUpdateService | ||||
| import eu.kanade.tachiyomi.data.preference.PreferencesHelper | ||||
| import eu.kanade.tachiyomi.source.SourceManager | ||||
| import eu.kanade.tachiyomi.ui.main.MainActivity | ||||
| import eu.kanade.tachiyomi.ui.manga.MangaController | ||||
| import eu.kanade.tachiyomi.ui.reader.ReaderActivity | ||||
| import eu.kanade.tachiyomi.util.DiskUtil | ||||
| import eu.kanade.tachiyomi.util.getUriCompat | ||||
| import eu.kanade.tachiyomi.util.notificationManager | ||||
| import eu.kanade.tachiyomi.util.toast | ||||
| import uy.kohesive.injekt.injectLazy | ||||
| import eu.kanade.tachiyomi.util.lang.launchIO | ||||
| import eu.kanade.tachiyomi.util.storage.DiskUtil | ||||
| import eu.kanade.tachiyomi.util.storage.getUriCompat | ||||
| import eu.kanade.tachiyomi.util.system.notificationManager | ||||
| import eu.kanade.tachiyomi.util.system.toast | ||||
| import java.io.File | ||||
| import eu.kanade.tachiyomi.BuildConfig.APPLICATION_ID as ID | ||||
| import uy.kohesive.injekt.Injekt | ||||
| import uy.kohesive.injekt.api.get | ||||
| import uy.kohesive.injekt.injectLazy | ||||
|  | ||||
| /** | ||||
|  * Global [BroadcastReceiver] that runs on UI thread | ||||
| @@ -27,9 +37,7 @@ import eu.kanade.tachiyomi.BuildConfig.APPLICATION_ID as ID | ||||
|  * NOTE: Use local broadcasts if possible. | ||||
|  */ | ||||
| class NotificationReceiver : BroadcastReceiver() { | ||||
|     /** | ||||
|      * Download manager. | ||||
|      */ | ||||
|  | ||||
|     private val downloadManager: DownloadManager by injectLazy() | ||||
|  | ||||
|     override fun onReceive(context: Context, intent: Intent) { | ||||
| @@ -45,20 +53,48 @@ class NotificationReceiver : BroadcastReceiver() { | ||||
|             } | ||||
|             // Clear the download queue | ||||
|             ACTION_CLEAR_DOWNLOADS -> downloadManager.clearQueue(true) | ||||
|             // Show message notification created | ||||
|             ACTION_SHORTCUT_CREATED -> context.toast(R.string.shortcut_created) | ||||
|             // Launch share activity and dismiss notification | ||||
|             ACTION_SHARE_IMAGE -> shareImage(context, intent.getStringExtra(EXTRA_FILE_LOCATION), | ||||
|                     intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1)) | ||||
|             ACTION_SHARE_IMAGE -> | ||||
|                 shareImage( | ||||
|                     context, intent.getStringExtra(EXTRA_FILE_LOCATION), | ||||
|                     intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1) | ||||
|                 ) | ||||
|             // Delete image from path and dismiss notification | ||||
|             ACTION_DELETE_IMAGE -> deleteImage(context, intent.getStringExtra(EXTRA_FILE_LOCATION), | ||||
|                     intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1)) | ||||
|             ACTION_DELETE_IMAGE -> | ||||
|                 deleteImage( | ||||
|                     context, intent.getStringExtra(EXTRA_FILE_LOCATION), | ||||
|                     intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1) | ||||
|                 ) | ||||
|             // Share backup file | ||||
|             ACTION_SHARE_BACKUP -> | ||||
|                 shareBackup( | ||||
|                     context, intent.getParcelableExtra(EXTRA_URI), | ||||
|                     intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1) | ||||
|                 ) | ||||
|             ACTION_CANCEL_RESTORE -> cancelRestore( | ||||
|                 context, | ||||
|                 intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1) | ||||
|             ) | ||||
|             // Cancel library update and dismiss notification | ||||
|             ACTION_CANCEL_LIBRARY_UPDATE -> cancelLibraryUpdate(context, Notifications.ID_LIBRARY_PROGRESS) | ||||
|             // Open reader activity | ||||
|             ACTION_OPEN_CHAPTER -> { | ||||
|                 openChapter(context, intent.getLongExtra(EXTRA_MANGA_ID, -1), | ||||
|                         intent.getLongExtra(EXTRA_CHAPTER_ID, -1)) | ||||
|                 openChapter( | ||||
|                     context, intent.getLongExtra(EXTRA_MANGA_ID, -1), | ||||
|                     intent.getLongExtra(EXTRA_CHAPTER_ID, -1) | ||||
|                 ) | ||||
|             } | ||||
|             // Mark updated manga chapters as read | ||||
|             ACTION_MARK_AS_READ -> { | ||||
|                 val notificationId = intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1) | ||||
|                 if (notificationId > -1) { | ||||
|                     dismissNotification(context, notificationId, intent.getIntExtra(EXTRA_GROUP_ID, 0)) | ||||
|                 } | ||||
|                 val urls = intent.getStringArrayExtra(EXTRA_CHAPTER_URL) ?: return | ||||
|                 val mangaId = intent.getLongExtra(EXTRA_MANGA_ID, -1) | ||||
|                 if (mangaId > -1) { | ||||
|                     markAsRead(urls, mangaId) | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| @@ -84,8 +120,8 @@ class NotificationReceiver : BroadcastReceiver() { | ||||
|         val intent = Intent(Intent.ACTION_SEND).apply { | ||||
|             val uri = File(path).getUriCompat(context) | ||||
|             putExtra(Intent.EXTRA_STREAM, uri) | ||||
|             flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION | ||||
|             type = "image/*" | ||||
|             flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION | ||||
|         } | ||||
|         // Dismiss notification | ||||
|         dismissNotification(context, notificationId) | ||||
| @@ -93,6 +129,25 @@ class NotificationReceiver : BroadcastReceiver() { | ||||
|         context.startActivity(intent) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Called to start share intent to share backup file | ||||
|      * | ||||
|      * @param context context of application | ||||
|      * @param path path of file | ||||
|      * @param notificationId id of notification | ||||
|      */ | ||||
|     private fun shareBackup(context: Context, uri: Uri, notificationId: Int) { | ||||
|         val sendIntent = Intent(Intent.ACTION_SEND).apply { | ||||
|             putExtra(Intent.EXTRA_STREAM, uri) | ||||
|             type = "application/json" | ||||
|             flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION | ||||
|         } | ||||
|         // Dismiss notification | ||||
|         dismissNotification(context, notificationId) | ||||
|         // Launch share activity | ||||
|         context.startActivity(sendIntent) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Starts reader activity | ||||
|      * | ||||
| @@ -104,7 +159,6 @@ class NotificationReceiver : BroadcastReceiver() { | ||||
|         val db = DatabaseHelper(context) | ||||
|         val manga = db.getManga(mangaId).executeAsBlocking() | ||||
|         val chapter = db.getChapter(chapterId).executeAsBlocking() | ||||
|  | ||||
|         if (manga != null && chapter != null) { | ||||
|             val intent = ReaderActivity.newIntent(context, manga, chapter).apply { | ||||
|                 flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP | ||||
| @@ -132,6 +186,17 @@ class NotificationReceiver : BroadcastReceiver() { | ||||
|         DiskUtil.scanMedia(context, file) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Method called when user wants to stop a backup restore job. | ||||
|      * | ||||
|      * @param context context of application | ||||
|      * @param notificationId id of notification | ||||
|      */ | ||||
|     private fun cancelRestore(context: Context, notificationId: Int) { | ||||
|         BackupRestoreService.stop(context) | ||||
|         Handler().post { dismissNotification(context, notificationId) } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Method called when user wants to stop a library update | ||||
|      * | ||||
| @@ -143,6 +208,35 @@ class NotificationReceiver : BroadcastReceiver() { | ||||
|         Handler().post { dismissNotification(context, notificationId) } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Method called when user wants to mark manga chapters as read | ||||
|      * | ||||
|      * @param chapterUrls URLs of chapter to mark as read | ||||
|      * @param mangaId id of manga | ||||
|      */ | ||||
|     private fun markAsRead(chapterUrls: Array<String>, mangaId: Long) { | ||||
|         val db: DatabaseHelper = Injekt.get() | ||||
|         val preferences: PreferencesHelper = Injekt.get() | ||||
|         val sourceManager: SourceManager = Injekt.get() | ||||
|  | ||||
|         launchIO { | ||||
|             chapterUrls.mapNotNull { db.getChapter(it, mangaId).executeAsBlocking() } | ||||
|                 .forEach { | ||||
|                     it.read = true | ||||
|                     db.updateChapterProgress(it).executeAsBlocking() | ||||
|                     if (preferences.removeAfterMarkedAsRead()) { | ||||
|                         val manga = db.getManga(mangaId).executeAsBlocking() | ||||
|                         if (manga != null) { | ||||
|                             val source = sourceManager.get(manga.source) | ||||
|                             if (source != null) { | ||||
|                                 downloadManager.deleteChapters(listOf(it), manga, source) | ||||
|                             } | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     companion object { | ||||
|         private const val NAME = "NotificationReceiver" | ||||
|  | ||||
| @@ -152,10 +246,19 @@ class NotificationReceiver : BroadcastReceiver() { | ||||
|         // Called to delete image. | ||||
|         private const val ACTION_DELETE_IMAGE = "$ID.$NAME.DELETE_IMAGE" | ||||
|  | ||||
|         // Called to launch send intent. | ||||
|         private const val ACTION_SHARE_BACKUP = "$ID.$NAME.SEND_BACKUP" | ||||
|  | ||||
|         // Called to cancel backup restore job. | ||||
|         private const val ACTION_CANCEL_RESTORE = "$ID.$NAME.CANCEL_RESTORE" | ||||
|  | ||||
|         // Called to cancel library update. | ||||
|         private const val ACTION_CANCEL_LIBRARY_UPDATE = "$ID.$NAME.CANCEL_LIBRARY_UPDATE" | ||||
|  | ||||
|         // Called to open chapter | ||||
|         // Called to mark manga chapters as read. | ||||
|         private const val ACTION_MARK_AS_READ = "$ID.$NAME.MARK_AS_READ" | ||||
|  | ||||
|         // Called to open chapter. | ||||
|         private const val ACTION_OPEN_CHAPTER = "$ID.$NAME.ACTION_OPEN_CHAPTER" | ||||
|  | ||||
|         // Value containing file location. | ||||
| @@ -170,21 +273,27 @@ class NotificationReceiver : BroadcastReceiver() { | ||||
|         // Called to clear downloads. | ||||
|         private const val ACTION_CLEAR_DOWNLOADS = "$ID.$NAME.ACTION_CLEAR_DOWNLOADS" | ||||
|  | ||||
|         // Called to notify user shortcut is created. | ||||
|         private const val ACTION_SHORTCUT_CREATED = "$ID.$NAME.ACTION_SHORTCUT_CREATED" | ||||
|  | ||||
|         // Called to dismiss notification. | ||||
|         private const val ACTION_DISMISS_NOTIFICATION = "$ID.$NAME.ACTION_DISMISS_NOTIFICATION" | ||||
|  | ||||
|         // Value containing uri. | ||||
|         private const val EXTRA_URI = "$ID.$NAME.URI" | ||||
|  | ||||
|         // Value containing notification id. | ||||
|         private const val EXTRA_NOTIFICATION_ID = "$ID.$NAME.NOTIFICATION_ID" | ||||
|  | ||||
|         // Value containing group id. | ||||
|         private const val EXTRA_GROUP_ID = "$ID.$NAME.EXTRA_GROUP_ID" | ||||
|  | ||||
|         // Value containing manga id. | ||||
|         private const val EXTRA_MANGA_ID = "$ID.$NAME.EXTRA_MANGA_ID" | ||||
|  | ||||
|         // Value containing chapter id. | ||||
|         private const val EXTRA_CHAPTER_ID = "$ID.$NAME.EXTRA_CHAPTER_ID" | ||||
|  | ||||
|         // Value containing chapter url. | ||||
|         private const val EXTRA_CHAPTER_URL = "$ID.$NAME.EXTRA_CHAPTER_URL" | ||||
|  | ||||
|         /** | ||||
|          * Returns a [PendingIntent] that resumes the download of a chapter | ||||
|          * | ||||
| @@ -224,13 +333,6 @@ class NotificationReceiver : BroadcastReceiver() { | ||||
|             return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) | ||||
|         } | ||||
|  | ||||
|         internal fun shortcutCreatedBroadcast(context: Context): PendingIntent { | ||||
|             val intent = Intent(context, NotificationReceiver::class.java).apply { | ||||
|                 action = ACTION_SHORTCUT_CREATED | ||||
|             } | ||||
|             return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) | ||||
|         } | ||||
|  | ||||
|         /** | ||||
|          * Returns [PendingIntent] that starts a service which dismissed the notification | ||||
|          * | ||||
| @@ -246,6 +348,44 @@ class NotificationReceiver : BroadcastReceiver() { | ||||
|             return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) | ||||
|         } | ||||
|  | ||||
|         /** | ||||
|          * Returns [PendingIntent] that starts a service which dismissed the notification | ||||
|          * | ||||
|          * @param context context of application | ||||
|          * @param notificationId id of notification | ||||
|          * @return [PendingIntent] | ||||
|          */ | ||||
|         internal fun dismissNotification(context: Context, notificationId: Int, groupId: Int? = null) { | ||||
|             /* | ||||
|             Group notifications always have at least 2 notifications: | ||||
|             - Group summary notification | ||||
|             - Single manga notification | ||||
|  | ||||
|             If the single notification is dismissed by the system, ie by a user swipe or tapping on the notification, | ||||
|             it will auto dismiss the group notification if there's no other single updates. | ||||
|  | ||||
|             When programmatically dismissing this notification, the group notification is not automatically dismissed. | ||||
|              */ | ||||
|             if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { | ||||
|                 val groupKey = context.notificationManager.activeNotifications.find { | ||||
|                     it.id == notificationId | ||||
|                 }?.groupKey | ||||
|  | ||||
|                 if (groupId != null && groupId != 0 && groupKey != null && groupKey.isNotEmpty()) { | ||||
|                     val notifications = context.notificationManager.activeNotifications.filter { | ||||
|                         it.groupKey == groupKey | ||||
|                     } | ||||
|  | ||||
|                     if (notifications.size == 2) { | ||||
|                         context.notificationManager.cancel(groupId) | ||||
|                         return | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             context.notificationManager.cancel(notificationId) | ||||
|         } | ||||
|  | ||||
|         /** | ||||
|          * Returns [PendingIntent] that starts a service which cancels the notification and starts a share activity | ||||
|          * | ||||
| @@ -281,19 +421,53 @@ class NotificationReceiver : BroadcastReceiver() { | ||||
|         } | ||||
|  | ||||
|         /** | ||||
|          * Returns [PendingIntent] that start a reader activity containing chapter. | ||||
|          * Returns [PendingIntent] that starts a reader activity containing chapter. | ||||
|          * | ||||
|          * @param context context of application | ||||
|          * @param manga manga of chapter | ||||
|          * @param chapter chapter that needs to be opened | ||||
|          */ | ||||
|         internal fun openChapterPendingBroadcast(context: Context, manga: Manga, chapter: Chapter): PendingIntent { | ||||
|             val intent = Intent(context, NotificationReceiver::class.java).apply { | ||||
|                 action = ACTION_OPEN_CHAPTER | ||||
|         internal fun openChapterPendingActivity(context: Context, manga: Manga, chapter: Chapter): PendingIntent { | ||||
|             val newIntent = ReaderActivity.newIntent(context, manga, chapter) | ||||
|             return PendingIntent.getActivity(context, manga.id.hashCode(), newIntent, PendingIntent.FLAG_UPDATE_CURRENT) | ||||
|         } | ||||
|  | ||||
|         /** | ||||
|          * Returns [PendingIntent] that opens the manga info controller. | ||||
|          * | ||||
|          * @param context context of application | ||||
|          * @param manga manga of chapter | ||||
|          */ | ||||
|         internal fun openChapterPendingActivity(context: Context, manga: Manga, groupId: Int): PendingIntent { | ||||
|             val newIntent = | ||||
|                 Intent(context, MainActivity::class.java).setAction(MainActivity.SHORTCUT_MANGA) | ||||
|                     .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) | ||||
|                     .putExtra(MangaController.MANGA_EXTRA, manga.id) | ||||
|                     .putExtra("notificationId", manga.id.hashCode()) | ||||
|                     .putExtra("groupId", groupId) | ||||
|             return PendingIntent.getActivity(context, manga.id.hashCode(), newIntent, PendingIntent.FLAG_UPDATE_CURRENT) | ||||
|         } | ||||
|  | ||||
|         /** | ||||
|          * Returns [PendingIntent] that marks a chapter as read and deletes it if preferred | ||||
|          * | ||||
|          * @param context context of application | ||||
|          * @param manga manga of chapter | ||||
|          */ | ||||
|         internal fun markAsReadPendingBroadcast( | ||||
|             context: Context, | ||||
|             manga: Manga, | ||||
|             chapters: Array<Chapter>, | ||||
|             groupId: Int | ||||
|         ): PendingIntent { | ||||
|             val newIntent = Intent(context, NotificationReceiver::class.java).apply { | ||||
|                 action = ACTION_MARK_AS_READ | ||||
|                 putExtra(EXTRA_CHAPTER_URL, chapters.map { it.url }.toTypedArray()) | ||||
|                 putExtra(EXTRA_MANGA_ID, manga.id) | ||||
|                 putExtra(EXTRA_CHAPTER_ID, chapter.id) | ||||
|                 putExtra(EXTRA_NOTIFICATION_ID, manga.id.hashCode()) | ||||
|                 putExtra(EXTRA_GROUP_ID, groupId) | ||||
|             } | ||||
|             return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) | ||||
|             return PendingIntent.getBroadcast(context, manga.id.hashCode(), newIntent, PendingIntent.FLAG_UPDATE_CURRENT) | ||||
|         } | ||||
|  | ||||
|         /** | ||||
| @@ -308,5 +482,67 @@ class NotificationReceiver : BroadcastReceiver() { | ||||
|             } | ||||
|             return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) | ||||
|         } | ||||
|  | ||||
|         /** | ||||
|          * Returns [PendingIntent] that opens the extensions controller. | ||||
|          * | ||||
|          * @param context context of application | ||||
|          * @return [PendingIntent] | ||||
|          */ | ||||
|         internal fun openExtensionsPendingActivity(context: Context): PendingIntent { | ||||
|             val intent = Intent(context, MainActivity::class.java).apply { | ||||
|                 action = MainActivity.SHORTCUT_EXTENSIONS | ||||
|                 addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) | ||||
|             } | ||||
|             return PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) | ||||
|         } | ||||
|  | ||||
|         /** | ||||
|          * Returns [PendingIntent] that starts a share activity for a backup file. | ||||
|          * | ||||
|          * @param context context of application | ||||
|          * @param uri uri of backup file | ||||
|          * @param notificationId id of notification | ||||
|          * @return [PendingIntent] | ||||
|          */ | ||||
|         internal fun shareBackupPendingBroadcast(context: Context, uri: Uri, notificationId: Int): PendingIntent { | ||||
|             val intent = Intent(context, NotificationReceiver::class.java).apply { | ||||
|                 action = ACTION_SHARE_BACKUP | ||||
|                 putExtra(EXTRA_URI, uri) | ||||
|                 putExtra(EXTRA_NOTIFICATION_ID, notificationId) | ||||
|             } | ||||
|             return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) | ||||
|         } | ||||
|  | ||||
|         /** | ||||
|          * Returns [PendingIntent] that opens the error log file in an external viewer | ||||
|          * | ||||
|          * @param context context of application | ||||
|          * @param uri uri of error log file | ||||
|          * @return [PendingIntent] | ||||
|          */ | ||||
|         internal fun openErrorLogPendingActivity(context: Context, uri: Uri): PendingIntent { | ||||
|             val intent = Intent().apply { | ||||
|                 action = Intent.ACTION_VIEW | ||||
|                 setDataAndType(uri, "text/plain") | ||||
|                 flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION | ||||
|             } | ||||
|             return PendingIntent.getActivity(context, 0, intent, 0) | ||||
|         } | ||||
|  | ||||
|         /** | ||||
|          * Returns [PendingIntent] that cancels a backup restore job. | ||||
|          * | ||||
|          * @param context context of application | ||||
|          * @param notificationId id of notification | ||||
|          * @return [PendingIntent] | ||||
|          */ | ||||
|         internal fun cancelRestorePendingBroadcast(context: Context, notificationId: Int): PendingIntent { | ||||
|             val intent = Intent(context, NotificationReceiver::class.java).apply { | ||||
|                 action = ACTION_CANCEL_RESTORE | ||||
|                 putExtra(EXTRA_NOTIFICATION_ID, notificationId) | ||||
|             } | ||||
|             return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) | ||||
|         } | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -1,11 +1,12 @@ | ||||
| package eu.kanade.tachiyomi.data.notification | ||||
|  | ||||
| import android.app.NotificationChannel | ||||
| import android.app.NotificationChannelGroup | ||||
| import android.app.NotificationManager | ||||
| import android.content.Context | ||||
| import android.os.Build | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.util.notificationManager | ||||
| import eu.kanade.tachiyomi.util.system.notificationManager | ||||
|  | ||||
| /** | ||||
|  * Class to manage the basic information of all the notifications used in the app. | ||||
| @@ -23,15 +24,42 @@ object Notifications { | ||||
|      * Notification channel and ids used by the library updater. | ||||
|      */ | ||||
|     const val CHANNEL_LIBRARY = "library_channel" | ||||
|     const val ID_LIBRARY_PROGRESS = 101 | ||||
|     const val ID_LIBRARY_RESULT = 102 | ||||
|     const val ID_LIBRARY_PROGRESS = -101 | ||||
|  | ||||
|     /** | ||||
|      * Notification channel and ids used by the downloader. | ||||
|      */ | ||||
|     const val CHANNEL_DOWNLOADER = "downloader_channel" | ||||
|     const val ID_DOWNLOAD_CHAPTER = 201 | ||||
|     const val ID_DOWNLOAD_CHAPTER_ERROR = 202 | ||||
|     const val ID_DOWNLOAD_CHAPTER = -201 | ||||
|     const val ID_DOWNLOAD_CHAPTER_ERROR = -202 | ||||
|  | ||||
|     /** | ||||
|      * Notification channel and ids used by the library updater. | ||||
|      */ | ||||
|     const val CHANNEL_NEW_CHAPTERS = "new_chapters_channel" | ||||
|     const val ID_NEW_CHAPTERS = -301 | ||||
|     const val GROUP_NEW_CHAPTERS = "eu.kanade.tachiyomi.NEW_CHAPTERS" | ||||
|  | ||||
|     /** | ||||
|      * Notification channel and ids used by the library updater. | ||||
|      */ | ||||
|     const val CHANNEL_UPDATES_TO_EXTS = "updates_ext_channel" | ||||
|     const val ID_UPDATES_TO_EXTS = -401 | ||||
|  | ||||
|     /** | ||||
|      * Notification channel and ids used by the backup/restore system. | ||||
|      */ | ||||
|     private const val GROUP_BACK_RESTORE = "group_backup_restore" | ||||
|     const val CHANNEL_BACKUP_RESTORE_PROGRESS = "backup_restore_progress_channel" | ||||
|     const val ID_BACKUP_PROGRESS = -501 | ||||
|     const val ID_RESTORE_PROGRESS = -503 | ||||
|     const val CHANNEL_BACKUP_RESTORE_COMPLETE = "backup_restore_complete_channel_v2" | ||||
|     const val ID_BACKUP_COMPLETE = -502 | ||||
|     const val ID_RESTORE_COMPLETE = -504 | ||||
|  | ||||
|     private val deprecatedChannels = listOf( | ||||
|         "backup_restore_complete_channel" | ||||
|     ) | ||||
|  | ||||
|     /** | ||||
|      * Creates the notification channels introduced in Android Oreo. | ||||
| @@ -41,14 +69,55 @@ object Notifications { | ||||
|     fun createChannels(context: Context) { | ||||
|         if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return | ||||
|  | ||||
|         val backupRestoreGroup = NotificationChannelGroup(GROUP_BACK_RESTORE, context.getString(R.string.channel_backup_restore)) | ||||
|         context.notificationManager.createNotificationChannelGroup(backupRestoreGroup) | ||||
|  | ||||
|         val channels = listOf( | ||||
|                 NotificationChannel(CHANNEL_COMMON, context.getString(R.string.channel_common), | ||||
|                         NotificationManager.IMPORTANCE_LOW), | ||||
|                 NotificationChannel(CHANNEL_LIBRARY, context.getString(R.string.channel_library), | ||||
|                         NotificationManager.IMPORTANCE_LOW), | ||||
|                 NotificationChannel(CHANNEL_DOWNLOADER, context.getString(R.string.channel_downloader), | ||||
|                         NotificationManager.IMPORTANCE_LOW) | ||||
|             NotificationChannel( | ||||
|                 CHANNEL_COMMON, context.getString(R.string.channel_common), | ||||
|                 NotificationManager.IMPORTANCE_LOW | ||||
|             ), | ||||
|             NotificationChannel( | ||||
|                 CHANNEL_LIBRARY, context.getString(R.string.channel_library), | ||||
|                 NotificationManager.IMPORTANCE_LOW | ||||
|             ).apply { | ||||
|                 setShowBadge(false) | ||||
|             }, | ||||
|             NotificationChannel( | ||||
|                 CHANNEL_DOWNLOADER, context.getString(R.string.channel_downloader), | ||||
|                 NotificationManager.IMPORTANCE_LOW | ||||
|             ).apply { | ||||
|                 setShowBadge(false) | ||||
|             }, | ||||
|             NotificationChannel( | ||||
|                 CHANNEL_NEW_CHAPTERS, context.getString(R.string.channel_new_chapters), | ||||
|                 NotificationManager.IMPORTANCE_DEFAULT | ||||
|             ), | ||||
|             NotificationChannel( | ||||
|                 CHANNEL_UPDATES_TO_EXTS, context.getString(R.string.channel_ext_updates), | ||||
|                 NotificationManager.IMPORTANCE_DEFAULT | ||||
|             ), | ||||
|             NotificationChannel( | ||||
|                 CHANNEL_BACKUP_RESTORE_PROGRESS, context.getString(R.string.channel_backup_restore_progress), | ||||
|                 NotificationManager.IMPORTANCE_LOW | ||||
|             ).apply { | ||||
|                 group = GROUP_BACK_RESTORE | ||||
|                 setShowBadge(false) | ||||
|             }, | ||||
|             NotificationChannel( | ||||
|                 CHANNEL_BACKUP_RESTORE_COMPLETE, context.getString(R.string.channel_backup_restore_complete), | ||||
|                 NotificationManager.IMPORTANCE_HIGH | ||||
|             ).apply { | ||||
|                 group = GROUP_BACK_RESTORE | ||||
|                 setShowBadge(false) | ||||
|                 setSound(null, null) | ||||
|             } | ||||
|         ) | ||||
|         context.notificationManager.createNotificationChannels(channels) | ||||
|  | ||||
|         // Delete old notification channels | ||||
|         deprecatedChannels.forEach { | ||||
|             context.notificationManager.deleteNotificationChannel(it) | ||||
|         } | ||||
|     } | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -1,201 +1,227 @@ | ||||
| package eu.kanade.tachiyomi.data.preference | ||||
|  | ||||
| /** | ||||
|  * This class stores the keys for the preferences in the application. | ||||
|  */ | ||||
| object PreferenceKeys { | ||||
|  | ||||
|     const val theme = "pref_theme_key" | ||||
|  | ||||
|     const val rotation = "pref_rotation_type_key" | ||||
|  | ||||
|     const val enableTransitions = "pref_enable_transitions_key" | ||||
|  | ||||
|     const val doubleTapAnimationSpeed = "pref_double_tap_anim_speed" | ||||
|  | ||||
|     const val showPageNumber = "pref_show_page_number_key" | ||||
|  | ||||
|     const val trueColor = "pref_true_color_key" | ||||
|  | ||||
|     const val fullscreen = "fullscreen" | ||||
|  | ||||
|     const val keepScreenOn = "pref_keep_screen_on_key" | ||||
|  | ||||
|     const val customBrightness = "pref_custom_brightness_key" | ||||
|  | ||||
|     const val customBrightnessValue = "custom_brightness_value" | ||||
|  | ||||
|     const val colorFilter = "pref_color_filter_key" | ||||
|  | ||||
|     const val colorFilterValue = "color_filter_value" | ||||
|  | ||||
|     const val colorFilterMode = "color_filter_mode" | ||||
|  | ||||
|     const val defaultViewer = "pref_default_viewer_key" | ||||
|  | ||||
|     const val imageScaleType = "pref_image_scale_type_key" | ||||
|  | ||||
|     const val zoomStart = "pref_zoom_start_key" | ||||
|  | ||||
|     const val readerTheme = "pref_reader_theme_key" | ||||
|  | ||||
|     const val cropBorders = "crop_borders" | ||||
|  | ||||
|     const val cropBordersWebtoon = "crop_borders_webtoon" | ||||
|  | ||||
|     const val readWithTapping = "reader_tap" | ||||
|  | ||||
|     const val readWithLongTap = "reader_long_tap" | ||||
|  | ||||
|     const val readWithVolumeKeys = "reader_volume_keys" | ||||
|  | ||||
|     const val readWithVolumeKeysInverted = "reader_volume_keys_inverted" | ||||
|  | ||||
|     const val portraitColumns = "pref_library_columns_portrait_key" | ||||
|  | ||||
|     const val landscapeColumns = "pref_library_columns_landscape_key" | ||||
|  | ||||
|     const val updateOnlyNonCompleted = "pref_update_only_non_completed_key" | ||||
|  | ||||
|     const val autoUpdateTrack = "pref_auto_update_manga_sync_key" | ||||
|  | ||||
|     const val lastUsedCatalogueSource = "last_catalogue_source" | ||||
|  | ||||
|     const val lastUsedCategory = "last_used_category" | ||||
|  | ||||
|     const val catalogueAsList = "pref_display_catalogue_as_list" | ||||
|  | ||||
|     const val enabledLanguages = "source_languages" | ||||
|  | ||||
|     const val backupDirectory = "backup_directory" | ||||
|  | ||||
|     const val downloadsDirectory = "download_directory" | ||||
|  | ||||
|     const val downloadOnlyOverWifi = "pref_download_only_over_wifi_key" | ||||
|  | ||||
|     const val numberOfBackups = "backup_slots" | ||||
|  | ||||
|     const val backupInterval = "backup_interval" | ||||
|  | ||||
|     const val removeAfterReadSlots = "remove_after_read_slots" | ||||
|  | ||||
|     const val removeAfterMarkedAsRead = "pref_remove_after_marked_as_read_key" | ||||
|  | ||||
|     const val libraryUpdateInterval = "pref_library_update_interval_key" | ||||
|  | ||||
|     const val libraryUpdateRestriction = "library_update_restriction" | ||||
|  | ||||
|     const val libraryUpdateCategories = "library_update_categories" | ||||
|  | ||||
|     const val libraryUpdatePrioritization = "library_update_prioritization" | ||||
|  | ||||
|     const val filterDownloaded = "pref_filter_downloaded_key" | ||||
|  | ||||
|     const val filterUnread = "pref_filter_unread_key" | ||||
|  | ||||
|     const val filterCompleted = "pref_filter_completed_key" | ||||
|  | ||||
|     const val librarySortingMode = "library_sorting_mode" | ||||
|  | ||||
|     const val automaticUpdates = "automatic_updates" | ||||
|  | ||||
|     const val startScreen = "start_screen" | ||||
|  | ||||
|     const val downloadNew = "download_new" | ||||
|  | ||||
|     const val downloadNewCategories = "download_new_categories" | ||||
|  | ||||
|     const val libraryAsList = "pref_display_library_as_list" | ||||
|  | ||||
|     const val lang = "app_language" | ||||
|  | ||||
|     const val defaultCategory = "default_category" | ||||
|  | ||||
|     const val skipRead = "skip_read" | ||||
|  | ||||
|     const val downloadBadge = "display_download_badge" | ||||
|  | ||||
|     @Deprecated("Use the preferences of the source") | ||||
|     fun sourceUsername(sourceId: Long) = "pref_source_username_$sourceId" | ||||
|  | ||||
|     @Deprecated("Use the preferences of the source") | ||||
|     fun sourcePassword(sourceId: Long) = "pref_source_password_$sourceId" | ||||
|  | ||||
|     fun sourceSharedPref(sourceId: Long) = "source_$sourceId" | ||||
|  | ||||
|     fun trackUsername(syncId: Int) = "pref_mangasync_username_$syncId" | ||||
|  | ||||
|     fun trackPassword(syncId: Int) = "pref_mangasync_password_$syncId" | ||||
|  | ||||
|     fun trackToken(syncId: Int) = "track_token_$syncId" | ||||
|  | ||||
|     const val eh_lock_hash = "lock_hash" | ||||
|  | ||||
|     const val eh_lock_salt = "lock_salt" | ||||
|  | ||||
|     const val eh_lock_length = "lock_length" | ||||
|  | ||||
|     const val eh_lock_finger = "lock_finger" | ||||
|  | ||||
|     const val eh_lock_manually = "eh_lock_manually" | ||||
|  | ||||
|     const val eh_nh_useHighQualityThumbs = "eh_nh_hq_thumbs" | ||||
|  | ||||
|     const val eh_showSyncIntro = "eh_show_sync_intro" | ||||
|  | ||||
|     const val eh_readOnlySync = "eh_sync_read_only" | ||||
|  | ||||
|     const val eh_lenientSync = "eh_lenient_sync" | ||||
|  | ||||
|     const val eh_useOrigImages = "eh_useOrigImages" | ||||
|  | ||||
|     const val eh_ehSettingsProfile = "eh_ehSettingsProfile" | ||||
|  | ||||
|     const val eh_exhSettingsProfile = "eh_exhSettingsProfile" | ||||
|  | ||||
|     const val eh_settingsKey = "eh_settingsKey" | ||||
|  | ||||
|     const val eh_sessionCookie = "eh_sessionCookie" | ||||
|  | ||||
|     const val eh_hathPerksCookie = "eh_hathPerksCookie" | ||||
|  | ||||
|     const val eh_enableExHentai = "enable_exhentai" | ||||
|  | ||||
|     const val eh_ts_aspNetCookie = "eh_ts_aspNetCookie" | ||||
|  | ||||
|     const val eh_showSettingsUploadWarning = "eh_showSettingsUploadWarning2" | ||||
|  | ||||
|     const val eh_expandFilters = "eh_expand_filters" | ||||
|  | ||||
|     const val eh_readerThreads = "eh_reader_threads" | ||||
|  | ||||
|     const val eh_readerInstantRetry = "eh_reader_instant_retry" | ||||
|  | ||||
|     const val eh_utilAutoscrollInterval = "eh_util_autoscroll_interval" | ||||
|  | ||||
|     const val eh_cacheSize = "eh_cache_size" | ||||
|  | ||||
|     const val eh_preserveReadingPosition = "eh_preserve_reading_position" | ||||
|  | ||||
|     const val eh_incogWebview = "eh_incognito_webview" | ||||
|  | ||||
|     const val eh_autoSolveCaptchas = "eh_autosolve_captchas" | ||||
|  | ||||
|     const val eh_delegateSources = "eh_delegate_sources" | ||||
|  | ||||
|     const val eh_showTransitionPages = "eh_show_transition_pages" | ||||
|  | ||||
|     const val eh_logLevel = "eh_log_level" | ||||
|  | ||||
|     const val eh_enableSourceBlacklist = "eh_enable_source_blacklist" | ||||
|  | ||||
|     const val eh_autoUpdateFrequency = "eh_auto_update_frequency" | ||||
|  | ||||
|     const val eh_autoUpdateRestrictions = "eh_auto_update_restrictions" | ||||
|  | ||||
|     const val eh_autoUpdateStats = "eh_auto_update_stats" | ||||
|  | ||||
|     const val eh_aggressivePageLoading = "eh_aggressive_page_loading" | ||||
|  | ||||
|     const val eh_hl_useHighQualityThumbs = "eh_hl_hq_thumbs" | ||||
| } | ||||
| package eu.kanade.tachiyomi.data.preference | ||||
|  | ||||
| /** | ||||
|  * This class stores the keys for the preferences in the application. | ||||
|  */ | ||||
| object PreferenceKeys { | ||||
|  | ||||
|     const val themeMode = "pref_theme_mode_key" | ||||
|  | ||||
|     const val themeLight = "pref_theme_light_key" | ||||
|  | ||||
|     const val themeDark = "pref_theme_dark_key" | ||||
|  | ||||
|     const val confirmExit = "pref_confirm_exit" | ||||
|  | ||||
|     const val rotation = "pref_rotation_type_key" | ||||
|  | ||||
|     const val enableTransitions = "pref_enable_transitions_key" | ||||
|  | ||||
|     const val doubleTapAnimationSpeed = "pref_double_tap_anim_speed" | ||||
|  | ||||
|     const val showPageNumber = "pref_show_page_number_key" | ||||
|  | ||||
|     const val trueColor = "pref_true_color_key" | ||||
|  | ||||
|     const val fullscreen = "fullscreen" | ||||
|  | ||||
|     const val cutoutShort = "cutout_short" | ||||
|  | ||||
|     const val keepScreenOn = "pref_keep_screen_on_key" | ||||
|  | ||||
|     const val customBrightness = "pref_custom_brightness_key" | ||||
|  | ||||
|     const val customBrightnessValue = "custom_brightness_value" | ||||
|  | ||||
|     const val colorFilter = "pref_color_filter_key" | ||||
|  | ||||
|     const val colorFilterValue = "color_filter_value" | ||||
|  | ||||
|     const val colorFilterMode = "color_filter_mode" | ||||
|  | ||||
|     const val defaultViewer = "pref_default_viewer_key" | ||||
|  | ||||
|     const val imageScaleType = "pref_image_scale_type_key" | ||||
|  | ||||
|     const val zoomStart = "pref_zoom_start_key" | ||||
|  | ||||
|     const val readerTheme = "pref_reader_theme_key" | ||||
|  | ||||
|     const val cropBorders = "crop_borders" | ||||
|  | ||||
|     const val cropBordersWebtoon = "crop_borders_webtoon" | ||||
|  | ||||
|     const val readWithTapping = "reader_tap" | ||||
|  | ||||
|     const val readWithLongTap = "reader_long_tap" | ||||
|  | ||||
|     const val readWithVolumeKeys = "reader_volume_keys" | ||||
|  | ||||
|     const val readWithVolumeKeysInverted = "reader_volume_keys_inverted" | ||||
|  | ||||
|     const val webtoonSidePadding = "webtoon_side_padding" | ||||
|  | ||||
|     const val portraitColumns = "pref_library_columns_portrait_key" | ||||
|  | ||||
|     const val landscapeColumns = "pref_library_columns_landscape_key" | ||||
|  | ||||
|     const val updateOnlyNonCompleted = "pref_update_only_non_completed_key" | ||||
|  | ||||
|     const val autoUpdateTrack = "pref_auto_update_manga_sync_key" | ||||
|  | ||||
|     const val lastUsedCatalogueSource = "last_catalogue_source" | ||||
|  | ||||
|     const val lastUsedCategory = "last_used_category" | ||||
|  | ||||
|     const val catalogueAsList = "pref_display_catalogue_as_list" | ||||
|  | ||||
|     const val enabledLanguages = "source_languages" | ||||
|  | ||||
|     const val sourcesSort = "sources_sort" | ||||
|  | ||||
|     const val backupDirectory = "backup_directory" | ||||
|  | ||||
|     const val downloadsDirectory = "download_directory" | ||||
|  | ||||
|     const val downloadOnlyOverWifi = "pref_download_only_over_wifi_key" | ||||
|  | ||||
|     const val numberOfBackups = "backup_slots" | ||||
|  | ||||
|     const val backupInterval = "backup_interval" | ||||
|  | ||||
|     const val removeAfterReadSlots = "remove_after_read_slots" | ||||
|  | ||||
|     const val removeAfterMarkedAsRead = "pref_remove_after_marked_as_read_key" | ||||
|  | ||||
|     const val libraryUpdateInterval = "pref_library_update_interval_key" | ||||
|  | ||||
|     const val libraryUpdateRestriction = "library_update_restriction" | ||||
|  | ||||
|     const val libraryUpdateCategories = "library_update_categories" | ||||
|  | ||||
|     const val libraryUpdatePrioritization = "library_update_prioritization" | ||||
|  | ||||
|     const val downloadedOnly = "pref_downloaded_only" | ||||
|  | ||||
|     const val filterDownloaded = "pref_filter_downloaded_key" | ||||
|  | ||||
|     const val filterUnread = "pref_filter_unread_key" | ||||
|  | ||||
|     const val filterCompleted = "pref_filter_completed_key" | ||||
|  | ||||
|     const val librarySortingMode = "library_sorting_mode" | ||||
|  | ||||
|     const val automaticExtUpdates = "automatic_ext_updates" | ||||
|  | ||||
|     const val startScreen = "start_screen" | ||||
|  | ||||
|     const val useBiometricLock = "use_biometric_lock" | ||||
|  | ||||
|     const val lockAppAfter = "lock_app_after" | ||||
|  | ||||
|     const val lastAppUnlock = "last_app_unlock" | ||||
|  | ||||
|     const val secureScreen = "secure_screen" | ||||
|  | ||||
|     const val hideNotificationContent = "hide_notification_content" | ||||
|  | ||||
|     const val downloadNew = "download_new" | ||||
|  | ||||
|     const val downloadNewCategories = "download_new_categories" | ||||
|  | ||||
|     const val libraryAsList = "pref_display_library_as_list" | ||||
|  | ||||
|     const val lang = "app_language" | ||||
|  | ||||
|     const val dateFormat = "app_date_format" | ||||
|  | ||||
|     const val defaultCategory = "default_category" | ||||
|  | ||||
|     const val skipRead = "skip_read" | ||||
|  | ||||
|     const val skipFiltered = "skip_filtered" | ||||
|  | ||||
|     const val downloadBadge = "display_download_badge" | ||||
|  | ||||
|     const val skipPreMigration = "skip_pre_migration" | ||||
|  | ||||
|     const val alwaysShowChapterTransition = "always_show_chapter_transition" | ||||
|  | ||||
|     const val searchPinnedSourcesOnly = "search_pinned_sources_only" | ||||
|  | ||||
|     fun trackUsername(syncId: Int) = "pref_mangasync_username_$syncId" | ||||
|  | ||||
|     fun trackPassword(syncId: Int) = "pref_mangasync_password_$syncId" | ||||
|  | ||||
|     fun trackToken(syncId: Int) = "track_token_$syncId" | ||||
|  | ||||
|     const val eh_lock_hash = "lock_hash" | ||||
|  | ||||
|     const val eh_lock_salt = "lock_salt" | ||||
|  | ||||
|     const val eh_lock_length = "lock_length" | ||||
|  | ||||
|     const val eh_lock_finger = "lock_finger" | ||||
|  | ||||
|     const val eh_lock_manually = "eh_lock_manually" | ||||
|  | ||||
|     const val eh_nh_useHighQualityThumbs = "eh_nh_hq_thumbs" | ||||
|  | ||||
|     const val eh_showSyncIntro = "eh_show_sync_intro" | ||||
|  | ||||
|     const val eh_readOnlySync = "eh_sync_read_only" | ||||
|  | ||||
|     const val eh_lenientSync = "eh_lenient_sync" | ||||
|  | ||||
|     const val eh_useOrigImages = "eh_useOrigImages" | ||||
|  | ||||
|     const val eh_ehSettingsProfile = "eh_ehSettingsProfile" | ||||
|  | ||||
|     const val eh_exhSettingsProfile = "eh_exhSettingsProfile" | ||||
|  | ||||
|     const val eh_settingsKey = "eh_settingsKey" | ||||
|  | ||||
|     const val eh_sessionCookie = "eh_sessionCookie" | ||||
|  | ||||
|     const val eh_hathPerksCookie = "eh_hathPerksCookie" | ||||
|  | ||||
|     const val eh_enableExHentai = "enable_exhentai" | ||||
|  | ||||
|     const val eh_ts_aspNetCookie = "eh_ts_aspNetCookie" | ||||
|  | ||||
|     const val eh_showSettingsUploadWarning = "eh_showSettingsUploadWarning2" | ||||
|  | ||||
|     const val eh_expandFilters = "eh_expand_filters" | ||||
|  | ||||
|     const val eh_readerThreads = "eh_reader_threads" | ||||
|  | ||||
|     const val eh_readerInstantRetry = "eh_reader_instant_retry" | ||||
|  | ||||
|     const val eh_utilAutoscrollInterval = "eh_util_autoscroll_interval" | ||||
|  | ||||
|     const val eh_cacheSize = "eh_cache_size" | ||||
|  | ||||
|     const val eh_preserveReadingPosition = "eh_preserve_reading_position" | ||||
|  | ||||
|     const val eh_autoSolveCaptchas = "eh_autosolve_captchas" | ||||
|  | ||||
|     const val eh_delegateSources = "eh_delegate_sources" | ||||
|  | ||||
|     const val eh_logLevel = "eh_log_level" | ||||
|  | ||||
|     const val eh_enableSourceBlacklist = "eh_enable_source_blacklist" | ||||
|  | ||||
|     const val eh_autoUpdateFrequency = "eh_auto_update_frequency" | ||||
|  | ||||
|     const val eh_autoUpdateRestrictions = "eh_auto_update_restrictions" | ||||
|  | ||||
|     const val eh_autoUpdateStats = "eh_auto_update_stats" | ||||
|  | ||||
|     const val eh_aggressivePageLoading = "eh_aggressive_page_loading" | ||||
|  | ||||
|     const val eh_hl_useHighQualityThumbs = "eh_hl_hq_thumbs" | ||||
|  | ||||
|     const val eh_library_rounded_corners = "eh_library_corners" | ||||
|  | ||||
|     const val eh_preload_size = "eh_preload_size" | ||||
| } | ||||
|   | ||||
| @@ -0,0 +1,18 @@ | ||||
| package eu.kanade.tachiyomi.data.preference | ||||
|  | ||||
| /** | ||||
|  * This class stores the values for the preferences in the application. | ||||
|  */ | ||||
| object PreferenceValues { | ||||
|  | ||||
|     const val THEME_MODE_LIGHT = "light" | ||||
|     const val THEME_MODE_DARK = "dark" | ||||
|     const val THEME_MODE_SYSTEM = "system" | ||||
|  | ||||
|     const val THEME_LIGHT_DEFAULT = "default" | ||||
|     const val THEME_LIGHT_BLUE = "blue" | ||||
|  | ||||
|     const val THEME_DARK_DEFAULT = "default" | ||||
|     const val THEME_DARK_BLUE = "blue" | ||||
|     const val THEME_DARK_AMOLED = "amoled" | ||||
| } | ||||
| @@ -1,84 +1,146 @@ | ||||
| package eu.kanade.tachiyomi.data.preference | ||||
|  | ||||
| import android.content.Context | ||||
| import android.content.SharedPreferences | ||||
| import android.net.Uri | ||||
| import android.os.Environment | ||||
| import android.preference.PreferenceManager | ||||
| import com.f2prateek.rx.preferences.Preference | ||||
| import androidx.preference.PreferenceManager | ||||
| import com.f2prateek.rx.preferences.Preference as RxPreference | ||||
| import com.f2prateek.rx.preferences.RxSharedPreferences | ||||
| import com.tfcporciuncula.flow.FlowSharedPreferences | ||||
| import com.tfcporciuncula.flow.Preference | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.track.TrackService | ||||
| import eu.kanade.tachiyomi.source.Source | ||||
| import exh.ui.migration.MigrationStatus | ||||
| import java.io.File | ||||
| import eu.kanade.tachiyomi.data.preference.PreferenceKeys as Keys | ||||
| import eu.kanade.tachiyomi.data.preference.PreferenceValues as Values | ||||
| import eu.kanade.tachiyomi.data.track.TrackService | ||||
| import eu.kanade.tachiyomi.data.track.anilist.Anilist | ||||
| import java.io.File | ||||
| import java.text.DateFormat | ||||
| import java.text.SimpleDateFormat | ||||
| import java.util.Locale | ||||
| import kotlinx.coroutines.ExperimentalCoroutinesApi | ||||
| import kotlinx.coroutines.flow.Flow | ||||
| import kotlinx.coroutines.flow.onEach | ||||
|  | ||||
| fun <T> Preference<T>.getOrDefault(): T = get() ?: defaultValue()!! | ||||
| fun <T> RxPreference<T>.getOrDefault(): T = get() ?: defaultValue()!! | ||||
|  | ||||
| fun Preference<Boolean>.invert(): Boolean = getOrDefault().let { set(!it); !it } | ||||
| @OptIn(ExperimentalCoroutinesApi::class) | ||||
| fun <T> Preference<T>.asImmediateFlow(block: (value: T) -> Unit): Flow<T> { | ||||
|     block(get()) | ||||
|     return asFlow() | ||||
|         .onEach { block(it) } | ||||
| } | ||||
|  | ||||
| private class DateFormatConverter : RxPreference.Adapter<DateFormat> { | ||||
|     override fun get(key: String, preferences: SharedPreferences): DateFormat { | ||||
|         val dateFormat = preferences.getString(Keys.dateFormat, "")!! | ||||
|  | ||||
|         if (dateFormat != "") { | ||||
|             return SimpleDateFormat(dateFormat, Locale.getDefault()) | ||||
|         } | ||||
|  | ||||
|         return DateFormat.getDateInstance(DateFormat.SHORT) | ||||
|     } | ||||
|  | ||||
|     override fun set(key: String, value: DateFormat, editor: SharedPreferences.Editor) { | ||||
|         // No-op | ||||
|     } | ||||
| } | ||||
|  | ||||
| @OptIn(ExperimentalCoroutinesApi::class) | ||||
| class PreferencesHelper(val context: Context) { | ||||
|  | ||||
|     val prefs = PreferenceManager.getDefaultSharedPreferences(context) | ||||
|     val rxPrefs = RxSharedPreferences.create(prefs) | ||||
|     val flowPrefs = FlowSharedPreferences(prefs) | ||||
|  | ||||
|     private val defaultDownloadsDir = Uri.fromFile( | ||||
|             File(Environment.getExternalStorageDirectory().absolutePath + File.separator + | ||||
|                     context.getString(R.string.app_name), "downloads")) | ||||
|         File( | ||||
|             Environment.getExternalStorageDirectory().absolutePath + File.separator + | ||||
|                 context.getString(R.string.app_name), | ||||
|             "downloads" | ||||
|         ) | ||||
|     ) | ||||
|  | ||||
|     private val defaultBackupDir = Uri.fromFile( | ||||
|             File(Environment.getExternalStorageDirectory().absolutePath + File.separator + | ||||
|                     context.getString(R.string.app_name), "backup")) | ||||
|         File( | ||||
|             Environment.getExternalStorageDirectory().absolutePath + File.separator + | ||||
|                 context.getString(R.string.app_name), | ||||
|             "backup" | ||||
|         ) | ||||
|     ) | ||||
|  | ||||
|     fun startScreen() = prefs.getInt(Keys.startScreen, 1) | ||||
|  | ||||
|     fun confirmExit() = prefs.getBoolean(Keys.confirmExit, false) | ||||
|  | ||||
|     fun useBiometricLock() = flowPrefs.getBoolean(Keys.useBiometricLock, false) | ||||
|  | ||||
|     fun lockAppAfter() = flowPrefs.getInt(Keys.lockAppAfter, 0) | ||||
|  | ||||
|     fun lastAppUnlock() = flowPrefs.getLong(Keys.lastAppUnlock, 0) | ||||
|  | ||||
|     fun secureScreen() = flowPrefs.getBoolean(Keys.secureScreen, false) | ||||
|  | ||||
|     fun hideNotificationContent() = prefs.getBoolean(Keys.hideNotificationContent, false) | ||||
|  | ||||
|     fun clear() = prefs.edit().clear().apply() | ||||
|  | ||||
|     fun theme() = prefs.getInt(Keys.theme, 1) | ||||
|     fun themeMode() = flowPrefs.getString(Keys.themeMode, Values.THEME_MODE_SYSTEM) | ||||
|  | ||||
|     fun themeLight() = flowPrefs.getString(Keys.themeLight, Values.THEME_LIGHT_DEFAULT) | ||||
|  | ||||
|     fun themeDark() = flowPrefs.getString(Keys.themeDark, Values.THEME_DARK_DEFAULT) | ||||
|  | ||||
|     fun rotation() = rxPrefs.getInteger(Keys.rotation, 1) | ||||
|  | ||||
|     fun pageTransitions() = rxPrefs.getBoolean(Keys.enableTransitions, true) | ||||
|     fun pageTransitions() = flowPrefs.getBoolean(Keys.enableTransitions, true) | ||||
|  | ||||
|     fun doubleTapAnimSpeed() = rxPrefs.getInteger(Keys.doubleTapAnimationSpeed, 500) | ||||
|     fun doubleTapAnimSpeed() = flowPrefs.getInt(Keys.doubleTapAnimationSpeed, 500) | ||||
|  | ||||
|     fun showPageNumber() = rxPrefs.getBoolean(Keys.showPageNumber, true) | ||||
|     fun showPageNumber() = flowPrefs.getBoolean(Keys.showPageNumber, true) | ||||
|  | ||||
|     fun trueColor() = rxPrefs.getBoolean(Keys.trueColor, false) | ||||
|     fun trueColor() = flowPrefs.getBoolean(Keys.trueColor, false) | ||||
|  | ||||
|     fun fullscreen() = rxPrefs.getBoolean(Keys.fullscreen, true) | ||||
|     fun fullscreen() = flowPrefs.getBoolean(Keys.fullscreen, true) | ||||
|  | ||||
|     fun keepScreenOn() = rxPrefs.getBoolean(Keys.keepScreenOn, true) | ||||
|     fun cutoutShort() = flowPrefs.getBoolean(Keys.cutoutShort, true) | ||||
|  | ||||
|     fun customBrightness() = rxPrefs.getBoolean(Keys.customBrightness, false) | ||||
|     fun keepScreenOn() = flowPrefs.getBoolean(Keys.keepScreenOn, true) | ||||
|  | ||||
|     fun customBrightnessValue() = rxPrefs.getInteger(Keys.customBrightnessValue, 0) | ||||
|     fun customBrightness() = flowPrefs.getBoolean(Keys.customBrightness, false) | ||||
|  | ||||
|     fun colorFilter() = rxPrefs.getBoolean(Keys.colorFilter, false) | ||||
|     fun customBrightnessValue() = flowPrefs.getInt(Keys.customBrightnessValue, 0) | ||||
|  | ||||
|     fun colorFilterValue() = rxPrefs.getInteger(Keys.colorFilterValue, 0) | ||||
|     fun colorFilter() = flowPrefs.getBoolean(Keys.colorFilter, false) | ||||
|  | ||||
|     fun colorFilterMode() = rxPrefs.getInteger(Keys.colorFilterMode, 0) | ||||
|     fun colorFilterValue() = flowPrefs.getInt(Keys.colorFilterValue, 0) | ||||
|  | ||||
|     fun colorFilterMode() = flowPrefs.getInt(Keys.colorFilterMode, 0) | ||||
|  | ||||
|     fun defaultViewer() = prefs.getInt(Keys.defaultViewer, 1) | ||||
|  | ||||
|     fun imageScaleType() = rxPrefs.getInteger(Keys.imageScaleType, 1) | ||||
|     fun imageScaleType() = flowPrefs.getInt(Keys.imageScaleType, 1) | ||||
|  | ||||
|     fun zoomStart() = rxPrefs.getInteger(Keys.zoomStart, 1) | ||||
|     fun zoomStart() = flowPrefs.getInt(Keys.zoomStart, 1) | ||||
|  | ||||
|     fun readerTheme() = rxPrefs.getInteger(Keys.readerTheme, 0) | ||||
|     fun readerTheme() = flowPrefs.getInt(Keys.readerTheme, 1) | ||||
|  | ||||
|     fun cropBorders() = rxPrefs.getBoolean(Keys.cropBorders, false) | ||||
|     fun alwaysShowChapterTransition() = flowPrefs.getBoolean(Keys.alwaysShowChapterTransition, true) | ||||
|  | ||||
|     fun cropBordersWebtoon() = rxPrefs.getBoolean(Keys.cropBordersWebtoon, false) | ||||
|     fun cropBorders() = flowPrefs.getBoolean(Keys.cropBorders, false) | ||||
|  | ||||
|     fun readWithTapping() = rxPrefs.getBoolean(Keys.readWithTapping, true) | ||||
|     fun cropBordersWebtoon() = flowPrefs.getBoolean(Keys.cropBordersWebtoon, false) | ||||
|  | ||||
|     fun readWithLongTap() = rxPrefs.getBoolean(Keys.readWithLongTap, true) | ||||
|     fun webtoonSidePadding() = flowPrefs.getInt(Keys.webtoonSidePadding, 0) | ||||
|  | ||||
|     fun readWithVolumeKeys() = rxPrefs.getBoolean(Keys.readWithVolumeKeys, false) | ||||
|     fun readWithTapping() = flowPrefs.getBoolean(Keys.readWithTapping, true) | ||||
|  | ||||
|     fun readWithVolumeKeysInverted() = rxPrefs.getBoolean(Keys.readWithVolumeKeysInverted, false) | ||||
|     fun readWithLongTap() = flowPrefs.getBoolean(Keys.readWithLongTap, true) | ||||
|  | ||||
|     fun readWithVolumeKeys() = flowPrefs.getBoolean(Keys.readWithVolumeKeys, false) | ||||
|  | ||||
|     fun readWithVolumeKeysInverted() = flowPrefs.getBoolean(Keys.readWithVolumeKeysInverted, false) | ||||
|  | ||||
|     fun portraitColumns() = rxPrefs.getInteger(Keys.portraitColumns, 0) | ||||
|  | ||||
| @@ -90,24 +152,15 @@ class PreferencesHelper(val context: Context) { | ||||
|  | ||||
|     fun lastUsedCatalogueSource() = rxPrefs.getLong(Keys.lastUsedCatalogueSource, -1) | ||||
|  | ||||
|     fun lastUsedCategory() = rxPrefs.getInteger(Keys.lastUsedCategory, 0) | ||||
|     fun lastUsedCategory() = flowPrefs.getInt(Keys.lastUsedCategory, 0) | ||||
|  | ||||
|     fun lastVersionCode() = rxPrefs.getInteger("last_version_code", 0) | ||||
|     fun lastVersionCode() = flowPrefs.getInt("last_version_code", 0) | ||||
|  | ||||
|     fun catalogueAsList() = rxPrefs.getBoolean(Keys.catalogueAsList, false) | ||||
|  | ||||
|     fun enabledLanguages() = rxPrefs.getStringSet(Keys.enabledLanguages, setOf("all")) | ||||
|     fun enabledLanguages() = flowPrefs.getStringSet(Keys.enabledLanguages, setOf("all", "en", Locale.getDefault().language)) | ||||
|  | ||||
|     fun sourceUsername(source: Source) = prefs.getString(Keys.sourceUsername(source.id), "") | ||||
|  | ||||
|     fun sourcePassword(source: Source) = prefs.getString(Keys.sourcePassword(source.id), "") | ||||
|  | ||||
|     fun setSourceCredentials(source: Source, username: String, password: String) { | ||||
|         prefs.edit() | ||||
|                 .putString(Keys.sourceUsername(source.id), username) | ||||
|                 .putString(Keys.sourcePassword(source.id), password) | ||||
|                 .apply() | ||||
|     } | ||||
|     fun sourceSorting() = flowPrefs.getInt(Keys.sourcesSort, 0) | ||||
|  | ||||
|     fun trackUsername(sync: TrackService) = prefs.getString(Keys.trackUsername(sync.id), "") | ||||
|  | ||||
| @@ -115,59 +168,71 @@ class PreferencesHelper(val context: Context) { | ||||
|  | ||||
|     fun setTrackCredentials(sync: TrackService, username: String, password: String) { | ||||
|         prefs.edit() | ||||
|                 .putString(Keys.trackUsername(sync.id), username) | ||||
|                 .putString(Keys.trackPassword(sync.id), password) | ||||
|                 .apply() | ||||
|             .putString(Keys.trackUsername(sync.id), username) | ||||
|             .putString(Keys.trackPassword(sync.id), password) | ||||
|             .apply() | ||||
|     } | ||||
|  | ||||
|     fun trackToken(sync: TrackService) = rxPrefs.getString(Keys.trackToken(sync.id), "") | ||||
|     fun trackToken(sync: TrackService) = flowPrefs.getString(Keys.trackToken(sync.id), "") | ||||
|  | ||||
|     fun anilistScoreType() = rxPrefs.getString("anilist_score_type", "POINT_10") | ||||
|     fun anilistScoreType() = flowPrefs.getString("anilist_score_type", Anilist.POINT_10) | ||||
|  | ||||
|     fun backupsDirectory() = rxPrefs.getString(Keys.backupDirectory, defaultBackupDir.toString()) | ||||
|     fun backupsDirectory() = flowPrefs.getString(Keys.backupDirectory, defaultBackupDir.toString()) | ||||
|  | ||||
|     fun downloadsDirectory() = rxPrefs.getString(Keys.downloadsDirectory, defaultDownloadsDir.toString()) | ||||
|     fun dateFormat() = rxPrefs.getObject(Keys.dateFormat, DateFormat.getDateInstance(DateFormat.SHORT), DateFormatConverter()) | ||||
|  | ||||
|     fun downloadsDirectory() = flowPrefs.getString(Keys.downloadsDirectory, defaultDownloadsDir.toString()) | ||||
|  | ||||
|     fun downloadOnlyOverWifi() = prefs.getBoolean(Keys.downloadOnlyOverWifi, true) | ||||
|  | ||||
|     fun numberOfBackups() = rxPrefs.getInteger(Keys.numberOfBackups, 1) | ||||
|     fun numberOfBackups() = flowPrefs.getInt(Keys.numberOfBackups, 1) | ||||
|  | ||||
|     fun backupInterval() = rxPrefs.getInteger(Keys.backupInterval, 0) | ||||
|     fun backupInterval() = flowPrefs.getInt(Keys.backupInterval, 0) | ||||
|  | ||||
|     fun removeAfterReadSlots() = prefs.getInt(Keys.removeAfterReadSlots, -1) | ||||
|  | ||||
|     fun removeAfterMarkedAsRead() = prefs.getBoolean(Keys.removeAfterMarkedAsRead, false) | ||||
|  | ||||
|     fun libraryUpdateInterval() = rxPrefs.getInteger(Keys.libraryUpdateInterval, 0) | ||||
|     fun libraryUpdateInterval() = flowPrefs.getInt(Keys.libraryUpdateInterval, 24) | ||||
|  | ||||
|     fun libraryUpdateRestriction() = prefs.getStringSet(Keys.libraryUpdateRestriction, emptySet()) | ||||
|     fun libraryUpdateRestriction() = prefs.getStringSet(Keys.libraryUpdateRestriction, setOf("wifi")) | ||||
|  | ||||
|     fun libraryUpdateCategories() = rxPrefs.getStringSet(Keys.libraryUpdateCategories, emptySet()) | ||||
|     fun libraryUpdateCategories() = flowPrefs.getStringSet(Keys.libraryUpdateCategories, emptySet()) | ||||
|  | ||||
|     fun libraryUpdatePrioritization() = rxPrefs.getInteger(Keys.libraryUpdatePrioritization, 0) | ||||
|     fun libraryUpdatePrioritization() = flowPrefs.getInt(Keys.libraryUpdatePrioritization, 0) | ||||
|  | ||||
|     fun libraryAsList() = rxPrefs.getBoolean(Keys.libraryAsList, false) | ||||
|     fun libraryAsList() = flowPrefs.getBoolean(Keys.libraryAsList, false) | ||||
|  | ||||
|     fun downloadBadge() = rxPrefs.getBoolean(Keys.downloadBadge, false) | ||||
|     fun downloadBadge() = flowPrefs.getBoolean(Keys.downloadBadge, false) | ||||
|  | ||||
|     fun downloadedOnly() = flowPrefs.getBoolean(Keys.downloadedOnly, false) | ||||
|  | ||||
|     // J2K converted from boolean to integer | ||||
|     fun filterDownloaded() = rxPrefs.getInteger(Keys.filterDownloaded, 0) | ||||
|     fun filterDownloaded() = flowPrefs.getInt(Keys.filterDownloaded, 0) | ||||
|  | ||||
|     fun filterUnread() = rxPrefs.getInteger(Keys.filterUnread, 0) | ||||
|     fun filterUnread() = flowPrefs.getInt(Keys.filterUnread, 0) | ||||
|  | ||||
|     fun filterCompleted() = rxPrefs.getInteger(Keys.filterCompleted, 0) | ||||
|     fun filterCompleted() = flowPrefs.getInt(Keys.filterCompleted, 0) | ||||
|  | ||||
|     fun librarySortingMode() = rxPrefs.getInteger(Keys.librarySortingMode, 0) | ||||
|     fun librarySortingMode() = flowPrefs.getInt(Keys.librarySortingMode, 0) | ||||
|  | ||||
|     fun librarySortingAscending() = rxPrefs.getBoolean("library_sorting_ascending", true) | ||||
|     fun librarySortingAscending() = flowPrefs.getBoolean("library_sorting_ascending", true) | ||||
|  | ||||
|     fun automaticUpdates() = prefs.getBoolean(Keys.automaticUpdates, false) | ||||
|     fun automaticExtUpdates() = flowPrefs.getBoolean(Keys.automaticExtUpdates, true) | ||||
|  | ||||
|     fun hiddenCatalogues() = rxPrefs.getStringSet("hidden_catalogues", emptySet()) | ||||
|     fun extensionUpdatesCount() = flowPrefs.getInt("ext_updates_count", 0) | ||||
|  | ||||
|     fun downloadNew() = rxPrefs.getBoolean(Keys.downloadNew, false) | ||||
|     fun lastExtCheck() = flowPrefs.getLong("last_ext_check", 0) | ||||
|  | ||||
|     fun downloadNewCategories() = rxPrefs.getStringSet(Keys.downloadNewCategories, emptySet()) | ||||
|     fun searchPinnedSourcesOnly() = prefs.getBoolean(Keys.searchPinnedSourcesOnly, false) | ||||
|  | ||||
|     fun hiddenCatalogues() = flowPrefs.getStringSet("hidden_catalogues", mutableSetOf()) | ||||
|  | ||||
|     fun pinnedCatalogues() = flowPrefs.getStringSet("pinned_catalogues", emptySet()) | ||||
|  | ||||
|     fun downloadNew() = flowPrefs.getBoolean(Keys.downloadNew, false) | ||||
|  | ||||
|     fun downloadNewCategories() = flowPrefs.getStringSet(Keys.downloadNewCategories, emptySet()) | ||||
|  | ||||
|     fun lang() = prefs.getString(Keys.lang, "") | ||||
|  | ||||
| @@ -175,12 +240,24 @@ class PreferencesHelper(val context: Context) { | ||||
|  | ||||
|     fun skipRead() = prefs.getBoolean(Keys.skipRead, false) | ||||
|  | ||||
|     fun migrateFlags() = rxPrefs.getInteger("migrate_flags", Int.MAX_VALUE) | ||||
|     fun skipFiltered() = prefs.getBoolean(Keys.skipFiltered, true) | ||||
|  | ||||
|     fun trustedSignatures() = rxPrefs.getStringSet("trusted_signatures", emptySet()) | ||||
|     fun migrateFlags() = flowPrefs.getInt("migrate_flags", Int.MAX_VALUE) | ||||
|  | ||||
|     fun trustedSignatures() = flowPrefs.getStringSet("trusted_signatures", emptySet()) | ||||
|  | ||||
|     // --> AZ J2K CHERRYPICKING | ||||
|  | ||||
|     fun defaultMangaOrder() = flowPrefs.getString("default_manga_order", "") | ||||
|  | ||||
|     fun migrationSources() = flowPrefs.getString("migrate_sources", "") | ||||
|  | ||||
|     fun smartMigration() = rxPrefs.getBoolean("smart_migrate", false) | ||||
|  | ||||
|     fun useSourceWithMost() = rxPrefs.getBoolean("use_source_with_most", false) | ||||
|  | ||||
|     fun skipPreMigration() = flowPrefs.getBoolean(Keys.skipPreMigration, false) | ||||
|  | ||||
|     fun upgradeFilters() { | ||||
|         val filterDl = rxPrefs.getBoolean(Keys.filterDownloaded, false).getOrDefault() | ||||
|         val filterUn = rxPrefs.getBoolean(Keys.filterUnread, false).getOrDefault() | ||||
| @@ -192,7 +269,6 @@ class PreferencesHelper(val context: Context) { | ||||
|  | ||||
|     // <-- | ||||
|  | ||||
|  | ||||
|     // --> EH | ||||
|     fun enableExhentai() = rxPrefs.getBoolean(Keys.eh_enableExHentai, false) | ||||
|  | ||||
| @@ -210,14 +286,11 @@ class PreferencesHelper(val context: Context) { | ||||
|  | ||||
|     fun thumbnailRows() = rxPrefs.getString("ex_thumb_rows", "tr_2") | ||||
|  | ||||
|     fun migrateLibraryAsked() = rxPrefs.getBoolean("ex_migrate_library3", false) | ||||
|  | ||||
|     fun migrationStatus() = rxPrefs.getInteger("migration_status", MigrationStatus.NOT_INITIALIZED) | ||||
|  | ||||
|     fun hasPerformedURLMigration() = rxPrefs.getBoolean("performed_url_migration", false) | ||||
|  | ||||
|     //EH Cookies | ||||
|     // EH Cookies | ||||
|     fun memberIdVal() = rxPrefs.getString("eh_ipb_member_id", "") | ||||
|  | ||||
|     fun passHashVal() = rxPrefs.getString("eh_ipb_pass_hash", "") | ||||
|     fun igneousVal() = rxPrefs.getString("eh_igneous", "") | ||||
|     fun eh_ehSettingsProfile() = rxPrefs.getInteger(Keys.eh_ehSettingsProfile, -1) | ||||
| @@ -226,7 +299,7 @@ class PreferencesHelper(val context: Context) { | ||||
|     fun eh_sessionCookie() = rxPrefs.getString(Keys.eh_sessionCookie, "") | ||||
|     fun eh_hathPerksCookies() = rxPrefs.getString(Keys.eh_hathPerksCookie, "") | ||||
|  | ||||
|     //Lock | ||||
|     // Lock | ||||
|     fun eh_lockHash() = rxPrefs.getString(Keys.eh_lock_hash, null) | ||||
|  | ||||
|     fun eh_lockSalt() = rxPrefs.getString(Keys.eh_lock_salt, null) | ||||
| @@ -239,7 +312,7 @@ class PreferencesHelper(val context: Context) { | ||||
|  | ||||
|     fun eh_nh_useHighQualityThumbs() = rxPrefs.getBoolean(Keys.eh_nh_useHighQualityThumbs, false) | ||||
|  | ||||
|     fun eh_showSyncIntro() = rxPrefs.getBoolean(Keys.eh_showSyncIntro, true) | ||||
|     fun eh_showSyncIntro() = flowPrefs.getBoolean(Keys.eh_showSyncIntro, true) | ||||
|  | ||||
|     fun eh_readOnlySync() = rxPrefs.getBoolean(Keys.eh_readOnlySync, false) | ||||
|  | ||||
| @@ -247,7 +320,7 @@ class PreferencesHelper(val context: Context) { | ||||
|  | ||||
|     fun eh_ts_aspNetCookie() = rxPrefs.getString(Keys.eh_ts_aspNetCookie, "") | ||||
|  | ||||
|     fun eh_showSettingsUploadWarning() = rxPrefs.getBoolean(Keys.eh_showSettingsUploadWarning, true) | ||||
|     fun eh_showSettingsUploadWarning() = flowPrefs.getBoolean(Keys.eh_showSettingsUploadWarning, true) | ||||
|  | ||||
|     fun eh_expandFilters() = rxPrefs.getBoolean(Keys.eh_expandFilters, false) | ||||
|  | ||||
| @@ -255,14 +328,12 @@ class PreferencesHelper(val context: Context) { | ||||
|  | ||||
|     fun eh_readerInstantRetry() = rxPrefs.getBoolean(Keys.eh_readerInstantRetry, true) | ||||
|  | ||||
|     fun eh_utilAutoscrollInterval() = rxPrefs.getFloat(Keys.eh_utilAutoscrollInterval, 3f) | ||||
|     fun eh_utilAutoscrollInterval() = flowPrefs.getFloat(Keys.eh_utilAutoscrollInterval, 3f) | ||||
|  | ||||
|     fun eh_cacheSize() = rxPrefs.getString(Keys.eh_cacheSize, "75") | ||||
|  | ||||
|     fun eh_preserveReadingPosition() = rxPrefs.getBoolean(Keys.eh_preserveReadingPosition, false) | ||||
|  | ||||
|     fun eh_incogWebview() = rxPrefs.getBoolean(Keys.eh_incogWebview, false) | ||||
|  | ||||
|     fun eh_autoSolveCaptchas() = rxPrefs.getBoolean(Keys.eh_autoSolveCaptchas, false) | ||||
|  | ||||
|     fun eh_delegateSources() = rxPrefs.getBoolean(Keys.eh_delegateSources, true) | ||||
| @@ -271,11 +342,9 @@ class PreferencesHelper(val context: Context) { | ||||
|  | ||||
|     fun eh_savedSearches() = rxPrefs.getStringSet("eh_saved_searches", emptySet()) | ||||
|  | ||||
|     fun eh_showTransitionPages() = rxPrefs.getBoolean(Keys.eh_showTransitionPages, true) | ||||
|  | ||||
|     fun eh_logLevel() = rxPrefs.getInteger(Keys.eh_logLevel, 0) | ||||
|  | ||||
|     fun eh_enableSourceBlacklist() = rxPrefs.getBoolean(Keys.eh_enableSourceBlacklist, true) | ||||
|     fun eh_enableSourceBlacklist() = flowPrefs.getBoolean(Keys.eh_enableSourceBlacklist, true) | ||||
|  | ||||
|     fun eh_autoUpdateFrequency() = rxPrefs.getInteger(Keys.eh_autoUpdateFrequency, 1) | ||||
|  | ||||
| @@ -286,4 +355,8 @@ class PreferencesHelper(val context: Context) { | ||||
|     fun eh_aggressivePageLoading() = rxPrefs.getBoolean(Keys.eh_aggressivePageLoading, false) | ||||
|  | ||||
|     fun eh_hl_useHighQualityThumbs() = rxPrefs.getBoolean(Keys.eh_hl_useHighQualityThumbs, false) | ||||
|  | ||||
|     fun eh_library_corner_radius() = rxPrefs.getInteger(Keys.eh_library_rounded_corners, 4) | ||||
|  | ||||
|     fun eh_preload_size() = rxPrefs.getInteger(Keys.eh_preload_size, 4) | ||||
| } | ||||
|   | ||||
| @@ -1,36 +1,35 @@ | ||||
| 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 | ||||
| import eu.kanade.tachiyomi.data.track.shikimori.Shikimori | ||||
| import eu.kanade.tachiyomi.data.track.bangumi.Bangumi | ||||
|  | ||||
| class TrackManager(private val context: Context) { | ||||
|  | ||||
|     companion object { | ||||
|         const val MYANIMELIST = 1 | ||||
|         const val ANILIST = 2 | ||||
|         const val KITSU = 3 | ||||
|         const val SHIKIMORI = 4 | ||||
|         const val BANGUMI = 5 | ||||
|     } | ||||
|  | ||||
|     val myAnimeList = Myanimelist(context, MYANIMELIST) | ||||
|  | ||||
|     val aniList = Anilist(context, ANILIST) | ||||
|  | ||||
|     val kitsu = Kitsu(context, KITSU) | ||||
|  | ||||
|     val shikimori = Shikimori(context, SHIKIMORI) | ||||
|  | ||||
|     val bangumi = Bangumi(context, BANGUMI) | ||||
|  | ||||
|     val services = listOf(myAnimeList, aniList, kitsu, shikimori, bangumi) | ||||
|  | ||||
|     fun getService(id: Int) = services.find { it.id == id } | ||||
|  | ||||
|     fun hasLoggedServices() = services.any { it.isLogged } | ||||
|  | ||||
| } | ||||
| package eu.kanade.tachiyomi.data.track | ||||
|  | ||||
| import android.content.Context | ||||
| import eu.kanade.tachiyomi.data.track.anilist.Anilist | ||||
| import eu.kanade.tachiyomi.data.track.bangumi.Bangumi | ||||
| import eu.kanade.tachiyomi.data.track.kitsu.Kitsu | ||||
| import eu.kanade.tachiyomi.data.track.myanimelist.MyAnimeList | ||||
| import eu.kanade.tachiyomi.data.track.shikimori.Shikimori | ||||
|  | ||||
| class TrackManager(context: Context) { | ||||
|  | ||||
|     companion object { | ||||
|         const val MYANIMELIST = 1 | ||||
|         const val ANILIST = 2 | ||||
|         const val KITSU = 3 | ||||
|         const val SHIKIMORI = 4 | ||||
|         const val BANGUMI = 5 | ||||
|     } | ||||
|  | ||||
|     val myAnimeList = MyAnimeList(context, MYANIMELIST) | ||||
|  | ||||
|     val aniList = Anilist(context, ANILIST) | ||||
|  | ||||
|     val kitsu = Kitsu(context, KITSU) | ||||
|  | ||||
|     val shikimori = Shikimori(context, SHIKIMORI) | ||||
|  | ||||
|     val bangumi = Bangumi(context, BANGUMI) | ||||
|  | ||||
|     val services = listOf(myAnimeList, aniList, kitsu, shikimori, bangumi) | ||||
|  | ||||
|     fun getService(id: Int) = services.find { it.id == id } | ||||
|  | ||||
|     fun hasLoggedServices() = services.any { it.isLogged } | ||||
| } | ||||
|   | ||||
| @@ -22,6 +22,9 @@ abstract class TrackService(val id: Int) { | ||||
|     // Name of the manga sync service to display | ||||
|     abstract val name: String | ||||
|  | ||||
|     // Application and remote support for reading dates | ||||
|     open val supportsReadingDates: Boolean = false | ||||
|  | ||||
|     @DrawableRes | ||||
|     abstract fun getLogo(): Int | ||||
|  | ||||
| @@ -31,6 +34,8 @@ abstract class TrackService(val id: Int) { | ||||
|  | ||||
|     abstract fun getStatus(status: Int): String | ||||
|  | ||||
|     abstract fun getCompletionStatus(): Int | ||||
|  | ||||
|     abstract fun getScoreList(): List<String> | ||||
|  | ||||
|     open fun indexToScore(index: Int): Float { | ||||
| @@ -57,8 +62,8 @@ abstract class TrackService(val id: Int) { | ||||
|     } | ||||
|  | ||||
|     open val isLogged: Boolean | ||||
|         get() = !getUsername().isEmpty() && | ||||
|                 !getPassword().isEmpty() | ||||
|         get() = getUsername().isNotEmpty() && | ||||
|             getPassword().isNotEmpty() | ||||
|  | ||||
|     fun getUsername() = preferences.trackUsername(this)!! | ||||
|  | ||||
|   | ||||
| @@ -1,214 +1,210 @@ | ||||
| package eu.kanade.tachiyomi.data.track.anilist | ||||
|  | ||||
| import android.content.Context | ||||
| import android.graphics.Color | ||||
| import com.google.gson.Gson | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.database.models.Track | ||||
| import eu.kanade.tachiyomi.data.preference.getOrDefault | ||||
| import eu.kanade.tachiyomi.data.track.TrackService | ||||
| import eu.kanade.tachiyomi.data.track.model.TrackSearch | ||||
| import rx.Completable | ||||
| import rx.Observable | ||||
| import uy.kohesive.injekt.injectLazy | ||||
|  | ||||
| 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 PLANNING = 5 | ||||
|         const val REPEATING = 6 | ||||
|  | ||||
|         const val DEFAULT_STATUS = READING | ||||
|         const val DEFAULT_SCORE = 0 | ||||
|  | ||||
|         const val POINT_100 = "POINT_100" | ||||
|         const val POINT_10 = "POINT_10" | ||||
|         const val POINT_10_DECIMAL = "POINT_10_DECIMAL" | ||||
|         const val POINT_5 = "POINT_5" | ||||
|         const val POINT_3 = "POINT_3" | ||||
|     } | ||||
|  | ||||
|     override val name = "AniList" | ||||
|  | ||||
|     private val gson: Gson by injectLazy() | ||||
|  | ||||
|     private val interceptor by lazy { AnilistInterceptor(this, getPassword()) } | ||||
|  | ||||
|     private val api by lazy { AnilistApi(client, interceptor) } | ||||
|  | ||||
|     private val scorePreference = preferences.anilistScoreType() | ||||
|  | ||||
|     init { | ||||
|         // If the preference is an int from APIv1, logout user to force using APIv2 | ||||
|         try { | ||||
|             scorePreference.get() | ||||
|         } catch (e: ClassCastException) { | ||||
|             logout() | ||||
|             scorePreference.delete() | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun getLogo() = R.drawable.al | ||||
|  | ||||
|     override fun getLogoColor() = Color.rgb(18, 25, 35) | ||||
|  | ||||
|     override fun getStatusList(): List<Int> { | ||||
|         return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLANNING, REPEATING) | ||||
|     } | ||||
|  | ||||
|     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) | ||||
|             PLANNING -> getString(R.string.plan_to_read) | ||||
|             REPEATING -> getString(R.string.repeating) | ||||
|             else -> "" | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun getScoreList(): List<String> { | ||||
|         return when (scorePreference.getOrDefault()) { | ||||
|             // 10 point | ||||
|             POINT_10 -> IntRange(0, 10).map(Int::toString) | ||||
|             // 100 point | ||||
|             POINT_100 -> IntRange(0, 100).map(Int::toString) | ||||
|             // 5 stars | ||||
|             POINT_5 -> IntRange(0, 5).map { "$it ★" } | ||||
|             // Smiley | ||||
|             POINT_3 -> listOf("-", "😦", "😐", "😊") | ||||
|             // 10 point decimal | ||||
|             POINT_10_DECIMAL -> IntRange(0, 100).map { (it / 10f).toString() } | ||||
|             else -> throw Exception("Unknown score type") | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun indexToScore(index: Int): Float { | ||||
|         return when (scorePreference.getOrDefault()) { | ||||
|             // 10 point | ||||
|             POINT_10 -> index * 10f | ||||
|             // 100 point | ||||
|             POINT_100 -> index.toFloat() | ||||
|             // 5 stars | ||||
|             POINT_5 -> when { | ||||
|                 index == 0 -> 0f | ||||
|                 else -> index * 20f - 10f | ||||
|             } | ||||
|             // Smiley | ||||
|             POINT_3 -> when { | ||||
|                 index == 0 -> 0f | ||||
|                 else -> index * 25f + 10f | ||||
|             } | ||||
|             // 10 point decimal | ||||
|             POINT_10_DECIMAL -> index.toFloat() | ||||
|             else -> throw Exception("Unknown score type") | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun displayScore(track: Track): String { | ||||
|         val score = track.score | ||||
|  | ||||
|         return when (scorePreference.getOrDefault()) { | ||||
|             POINT_5 -> when { | ||||
|                 score == 0f -> "0 ★" | ||||
|                 else -> "${((score + 10) / 20).toInt()} ★" | ||||
|             } | ||||
|             POINT_3 -> when { | ||||
|                 score == 0f -> "0" | ||||
|                 score <= 35 -> "😦" | ||||
|                 score <= 60 -> "😐" | ||||
|                 else -> "😊" | ||||
|             } | ||||
|             else -> track.toAnilistScore() | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun add(track: Track): Observable<Track> { | ||||
|         return api.addLibManga(track) | ||||
|     } | ||||
|  | ||||
|     override fun update(track: Track): Observable<Track> { | ||||
|         if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) { | ||||
|             track.status = COMPLETED | ||||
|         } | ||||
|         // If user was using API v1 fetch library_id | ||||
|         if (track.library_id == null || track.library_id!! == 0L){ | ||||
|             return api.findLibManga(track, getUsername().toInt()).flatMap { | ||||
|                 if (it == null) { | ||||
|                     throw Exception("$track not found on user library") | ||||
|                 } | ||||
|                 track.library_id = it.library_id | ||||
|                 api.updateLibManga(track) | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return api.updateLibManga(track) | ||||
|     } | ||||
|  | ||||
|     override fun bind(track: Track): Observable<Track> { | ||||
|         return api.findLibManga(track, getUsername().toInt()) | ||||
|                 .flatMap { remoteTrack -> | ||||
|                     if (remoteTrack != null) { | ||||
|                         track.copyPersonalFrom(remoteTrack) | ||||
|                         track.library_id = remoteTrack.library_id | ||||
|                         update(track) | ||||
|                     } else { | ||||
|                         // Set default fields if it's not found in the list | ||||
|                         track.score = DEFAULT_SCORE.toFloat() | ||||
|                         track.status = DEFAULT_STATUS | ||||
|                         add(track) | ||||
|                     } | ||||
|                 } | ||||
|     } | ||||
|  | ||||
|     override fun search(query: String): Observable<List<TrackSearch>> { | ||||
|         return api.search(query) | ||||
|     } | ||||
|  | ||||
|     override fun refresh(track: Track): Observable<Track> { | ||||
|         return api.getLibManga(track, getUsername().toInt()) | ||||
|                 .map { remoteTrack -> | ||||
|                     track.copyPersonalFrom(remoteTrack) | ||||
|                     track.total_chapters = remoteTrack.total_chapters | ||||
|                     track | ||||
|                 } | ||||
|     } | ||||
|  | ||||
|     override fun login(username: String, password: String) = login(password) | ||||
|  | ||||
|     fun login(token: String): Completable { | ||||
|         val oauth = api.createOAuth(token) | ||||
|         interceptor.setAuth(oauth) | ||||
|         return api.getCurrentUser().map { (username, scoreType) -> | ||||
|             scorePreference.set(scoreType) | ||||
|             saveCredentials(username.toString(), oauth.access_token) | ||||
|          }.doOnError{ | ||||
|             logout() | ||||
|         }.toCompletable() | ||||
|     } | ||||
|  | ||||
|     override fun logout() { | ||||
|         super.logout() | ||||
|         preferences.trackToken(this).set(null) | ||||
|         interceptor.setAuth(null) | ||||
|     } | ||||
|  | ||||
|     fun saveOAuth(oAuth: OAuth?) { | ||||
|         preferences.trackToken(this).set(gson.toJson(oAuth)) | ||||
|     } | ||||
|  | ||||
|     fun loadOAuth(): OAuth? { | ||||
|         return try { | ||||
|             gson.fromJson(preferences.trackToken(this).get(), OAuth::class.java) | ||||
|         } catch (e: Exception) { | ||||
|             null | ||||
|         } | ||||
|     } | ||||
|  | ||||
| } | ||||
|  | ||||
| package eu.kanade.tachiyomi.data.track.anilist | ||||
|  | ||||
| import android.content.Context | ||||
| import android.graphics.Color | ||||
| import com.google.gson.Gson | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.database.models.Track | ||||
| import eu.kanade.tachiyomi.data.track.TrackService | ||||
| import eu.kanade.tachiyomi.data.track.model.TrackSearch | ||||
| import rx.Completable | ||||
| import rx.Observable | ||||
| import uy.kohesive.injekt.injectLazy | ||||
|  | ||||
| class Anilist(private val context: Context, id: Int) : TrackService(id) { | ||||
|  | ||||
|     companion object { | ||||
|         const val READING = 1 | ||||
|         const val COMPLETED = 2 | ||||
|         const val PAUSED = 3 | ||||
|         const val DROPPED = 4 | ||||
|         const val PLANNING = 5 | ||||
|         const val REPEATING = 6 | ||||
|  | ||||
|         const val DEFAULT_STATUS = READING | ||||
|         const val DEFAULT_SCORE = 0 | ||||
|  | ||||
|         const val POINT_100 = "POINT_100" | ||||
|         const val POINT_10 = "POINT_10" | ||||
|         const val POINT_10_DECIMAL = "POINT_10_DECIMAL" | ||||
|         const val POINT_5 = "POINT_5" | ||||
|         const val POINT_3 = "POINT_3" | ||||
|     } | ||||
|  | ||||
|     override val name = "AniList" | ||||
|  | ||||
|     private val gson: Gson by injectLazy() | ||||
|  | ||||
|     private val interceptor by lazy { AnilistInterceptor(this, getPassword()) } | ||||
|  | ||||
|     private val api by lazy { AnilistApi(client, interceptor) } | ||||
|  | ||||
|     private val scorePreference = preferences.anilistScoreType() | ||||
|  | ||||
|     init { | ||||
|         // If the preference is an int from APIv1, logout user to force using APIv2 | ||||
|         try { | ||||
|             scorePreference.get() | ||||
|         } catch (e: ClassCastException) { | ||||
|             logout() | ||||
|             scorePreference.delete() | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun getLogo() = R.drawable.ic_tracker_anilist | ||||
|  | ||||
|     override fun getLogoColor() = Color.rgb(18, 25, 35) | ||||
|  | ||||
|     override fun getStatusList(): List<Int> { | ||||
|         return listOf(READING, PLANNING, COMPLETED, REPEATING, PAUSED, DROPPED) | ||||
|     } | ||||
|  | ||||
|     override fun getStatus(status: Int): String = with(context) { | ||||
|         when (status) { | ||||
|             READING -> getString(R.string.reading) | ||||
|             PLANNING -> getString(R.string.plan_to_read) | ||||
|             COMPLETED -> getString(R.string.completed) | ||||
|             REPEATING -> getString(R.string.repeating) | ||||
|             PAUSED -> getString(R.string.paused) | ||||
|             DROPPED -> getString(R.string.dropped) | ||||
|             else -> "" | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun getCompletionStatus(): Int = COMPLETED | ||||
|  | ||||
|     override fun getScoreList(): List<String> { | ||||
|         return when (scorePreference.get()) { | ||||
|             // 10 point | ||||
|             POINT_10 -> IntRange(0, 10).map(Int::toString) | ||||
|             // 100 point | ||||
|             POINT_100 -> IntRange(0, 100).map(Int::toString) | ||||
|             // 5 stars | ||||
|             POINT_5 -> IntRange(0, 5).map { "$it ★" } | ||||
|             // Smiley | ||||
|             POINT_3 -> listOf("-", "😦", "😐", "😊") | ||||
|             // 10 point decimal | ||||
|             POINT_10_DECIMAL -> IntRange(0, 100).map { (it / 10f).toString() } | ||||
|             else -> throw Exception("Unknown score type") | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun indexToScore(index: Int): Float { | ||||
|         return when (scorePreference.get()) { | ||||
|             // 10 point | ||||
|             POINT_10 -> index * 10f | ||||
|             // 100 point | ||||
|             POINT_100 -> index.toFloat() | ||||
|             // 5 stars | ||||
|             POINT_5 -> when (index) { | ||||
|                 0 -> 0f | ||||
|                 else -> index * 20f - 10f | ||||
|             } | ||||
|             // Smiley | ||||
|             POINT_3 -> when (index) { | ||||
|                 0 -> 0f | ||||
|                 else -> index * 25f + 10f | ||||
|             } | ||||
|             // 10 point decimal | ||||
|             POINT_10_DECIMAL -> index.toFloat() | ||||
|             else -> throw Exception("Unknown score type") | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun displayScore(track: Track): String { | ||||
|         val score = track.score | ||||
|  | ||||
|         return when (scorePreference.get()) { | ||||
|             POINT_5 -> when (score) { | ||||
|                 0f -> "0 ★" | ||||
|                 else -> "${((score + 10) / 20).toInt()} ★" | ||||
|             } | ||||
|             POINT_3 -> when { | ||||
|                 score == 0f -> "0" | ||||
|                 score <= 35 -> "😦" | ||||
|                 score <= 60 -> "😐" | ||||
|                 else -> "😊" | ||||
|             } | ||||
|             else -> track.toAnilistScore() | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun add(track: Track): Observable<Track> { | ||||
|         return api.addLibManga(track) | ||||
|     } | ||||
|  | ||||
|     override fun update(track: Track): Observable<Track> { | ||||
|         // If user was using API v1 fetch library_id | ||||
|         if (track.library_id == null || track.library_id!! == 0L) { | ||||
|             return api.findLibManga(track, getUsername().toInt()).flatMap { | ||||
|                 if (it == null) { | ||||
|                     throw Exception("$track not found on user library") | ||||
|                 } | ||||
|                 track.library_id = it.library_id | ||||
|                 api.updateLibManga(track) | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return api.updateLibManga(track) | ||||
|     } | ||||
|  | ||||
|     override fun bind(track: Track): Observable<Track> { | ||||
|         return api.findLibManga(track, getUsername().toInt()) | ||||
|             .flatMap { remoteTrack -> | ||||
|                 if (remoteTrack != null) { | ||||
|                     track.copyPersonalFrom(remoteTrack) | ||||
|                     track.library_id = remoteTrack.library_id | ||||
|                     update(track) | ||||
|                 } else { | ||||
|                     // Set default fields if it's not found in the list | ||||
|                     track.score = DEFAULT_SCORE.toFloat() | ||||
|                     track.status = DEFAULT_STATUS | ||||
|                     add(track) | ||||
|                 } | ||||
|             } | ||||
|     } | ||||
|  | ||||
|     override fun search(query: String): Observable<List<TrackSearch>> { | ||||
|         return api.search(query) | ||||
|     } | ||||
|  | ||||
|     override fun refresh(track: Track): Observable<Track> { | ||||
|         return api.getLibManga(track, getUsername().toInt()) | ||||
|             .map { remoteTrack -> | ||||
|                 track.copyPersonalFrom(remoteTrack) | ||||
|                 track.total_chapters = remoteTrack.total_chapters | ||||
|                 track | ||||
|             } | ||||
|     } | ||||
|  | ||||
|     override fun login(username: String, password: String) = login(password) | ||||
|  | ||||
|     fun login(token: String): Completable { | ||||
|         val oauth = api.createOAuth(token) | ||||
|         interceptor.setAuth(oauth) | ||||
|         return api.getCurrentUser().map { (username, scoreType) -> | ||||
|             scorePreference.set(scoreType) | ||||
|             saveCredentials(username.toString(), oauth.access_token) | ||||
|         }.doOnError { | ||||
|             logout() | ||||
|         }.toCompletable() | ||||
|     } | ||||
|  | ||||
|     override fun logout() { | ||||
|         super.logout() | ||||
|         preferences.trackToken(this).delete() | ||||
|         interceptor.setAuth(null) | ||||
|     } | ||||
|  | ||||
|     fun saveOAuth(oAuth: OAuth?) { | ||||
|         preferences.trackToken(this).set(gson.toJson(oAuth)) | ||||
|     } | ||||
|  | ||||
|     fun loadOAuth(): OAuth? { | ||||
|         return try { | ||||
|             gson.fromJson(preferences.trackToken(this).get(), OAuth::class.java) | ||||
|         } catch (e: Exception) { | ||||
|             null | ||||
|         } | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -1,28 +1,32 @@ | ||||
| package eu.kanade.tachiyomi.data.track.anilist | ||||
|  | ||||
| import android.net.Uri | ||||
| import com.github.salomonbrys.kotson.* | ||||
| import com.github.salomonbrys.kotson.array | ||||
| import com.github.salomonbrys.kotson.get | ||||
| import com.github.salomonbrys.kotson.jsonObject | ||||
| import com.github.salomonbrys.kotson.nullInt | ||||
| import com.github.salomonbrys.kotson.nullString | ||||
| import com.github.salomonbrys.kotson.obj | ||||
| import com.google.gson.JsonObject | ||||
| import com.google.gson.JsonParser | ||||
| import eu.kanade.tachiyomi.data.database.models.Track | ||||
| import eu.kanade.tachiyomi.data.track.model.TrackSearch | ||||
| import eu.kanade.tachiyomi.network.asObservableSuccess | ||||
| import java.util.Calendar | ||||
| import okhttp3.MediaType.Companion.toMediaTypeOrNull | ||||
| import okhttp3.OkHttpClient | ||||
| import okhttp3.Request | ||||
| import okhttp3.RequestBody | ||||
| import okhttp3.RequestBody.Companion.toRequestBody | ||||
| import rx.Observable | ||||
| import java.util.* | ||||
|  | ||||
|  | ||||
| class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) { | ||||
|  | ||||
|     private val parser = JsonParser() | ||||
|     private val jsonMime = "application/json; charset=utf-8".toMediaTypeOrNull() | ||||
|     private val authClient = client.newBuilder().addInterceptor(interceptor).build() | ||||
|  | ||||
|     fun addLibManga(track: Track): Observable<Track> { | ||||
|         val query = """ | ||||
|         val query = | ||||
|             """ | ||||
|             |mutation AddManga(${'$'}mangaId: Int, ${'$'}progress: Int, ${'$'}status: MediaListStatus) { | ||||
|                 |SaveMediaListEntry (mediaId: ${'$'}mangaId, progress: ${'$'}progress, status: ${'$'}status) {  | ||||
|                 |   id  | ||||
| @@ -31,35 +35,36 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) { | ||||
|             |} | ||||
|             |""".trimMargin() | ||||
|         val variables = jsonObject( | ||||
|                 "mangaId" to track.media_id, | ||||
|                 "progress" to track.last_chapter_read, | ||||
|                 "status" to track.toAnilistStatus() | ||||
|             "mangaId" to track.media_id, | ||||
|             "progress" to track.last_chapter_read, | ||||
|             "status" to track.toAnilistStatus() | ||||
|         ) | ||||
|         val payload = jsonObject( | ||||
|                 "query" to query, | ||||
|                 "variables" to variables | ||||
|             "query" to query, | ||||
|             "variables" to variables | ||||
|         ) | ||||
|         val body = RequestBody.create(jsonMime, payload.toString()) | ||||
|         val body = payload.toString().toRequestBody(jsonMime) | ||||
|         val request = Request.Builder() | ||||
|                 .url(apiUrl) | ||||
|                 .post(body) | ||||
|                 .build() | ||||
|             .url(apiUrl) | ||||
|             .post(body) | ||||
|             .build() | ||||
|         return authClient.newCall(request) | ||||
|                 .asObservableSuccess() | ||||
|                 .map { netResponse -> | ||||
|                     val responseBody = netResponse.body?.string().orEmpty() | ||||
|                     netResponse.close() | ||||
|                     if (responseBody.isEmpty()) { | ||||
|                         throw Exception("Null Response") | ||||
|                     } | ||||
|                     val response = parser.parse(responseBody).obj | ||||
|                     track.library_id = response["data"]["SaveMediaListEntry"]["id"].asLong | ||||
|                     track | ||||
|             .asObservableSuccess() | ||||
|             .map { netResponse -> | ||||
|                 val responseBody = netResponse.body?.string().orEmpty() | ||||
|                 netResponse.close() | ||||
|                 if (responseBody.isEmpty()) { | ||||
|                     throw Exception("Null Response") | ||||
|                 } | ||||
|                 val response = JsonParser.parseString(responseBody).obj | ||||
|                 track.library_id = response["data"]["SaveMediaListEntry"]["id"].asLong | ||||
|                 track | ||||
|             } | ||||
|     } | ||||
|  | ||||
|     fun updateLibManga(track: Track): Observable<Track> { | ||||
|         val query = """ | ||||
|         val query = | ||||
|             """ | ||||
|             |mutation UpdateManga(${'$'}listId: Int, ${'$'}progress: Int, ${'$'}status: MediaListStatus, ${'$'}score: Int) { | ||||
|                 |SaveMediaListEntry (id: ${'$'}listId, progress: ${'$'}progress, status: ${'$'}status, scoreRaw: ${'$'}score) { | ||||
|                     |id | ||||
| @@ -69,29 +74,30 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) { | ||||
|             |} | ||||
|             |""".trimMargin() | ||||
|         val variables = jsonObject( | ||||
|                 "listId" to track.library_id, | ||||
|                 "progress" to track.last_chapter_read, | ||||
|                 "status" to track.toAnilistStatus(), | ||||
|                 "score" to track.score.toInt() | ||||
|             "listId" to track.library_id, | ||||
|             "progress" to track.last_chapter_read, | ||||
|             "status" to track.toAnilistStatus(), | ||||
|             "score" to track.score.toInt() | ||||
|         ) | ||||
|         val payload = jsonObject( | ||||
|                 "query" to query, | ||||
|                 "variables" to variables | ||||
|             "query" to query, | ||||
|             "variables" to variables | ||||
|         ) | ||||
|         val body = RequestBody.create(jsonMime, payload.toString()) | ||||
|         val body = payload.toString().toRequestBody(jsonMime) | ||||
|         val request = Request.Builder() | ||||
|                 .url(apiUrl) | ||||
|                 .post(body) | ||||
|                 .build() | ||||
|             .url(apiUrl) | ||||
|             .post(body) | ||||
|             .build() | ||||
|         return authClient.newCall(request) | ||||
|                 .asObservableSuccess() | ||||
|                 .map { | ||||
|                     track | ||||
|                 } | ||||
|             .asObservableSuccess() | ||||
|             .map { | ||||
|                 track | ||||
|             } | ||||
|     } | ||||
|  | ||||
|     fun search(search: String): Observable<List<TrackSearch>> { | ||||
|         val query = """ | ||||
|         val query = | ||||
|             """ | ||||
|             |query Search(${'$'}query: String) { | ||||
|                 |Page (perPage: 50) { | ||||
|                     |media(search: ${'$'}query, type: MANGA, format_not_in: [NOVEL]) { | ||||
| @@ -116,36 +122,36 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) { | ||||
|             |} | ||||
|             |""".trimMargin() | ||||
|         val variables = jsonObject( | ||||
|                 "query" to search | ||||
|             "query" to search | ||||
|         ) | ||||
|         val payload = jsonObject( | ||||
|                 "query" to query, | ||||
|                 "variables" to variables | ||||
|             "query" to query, | ||||
|             "variables" to variables | ||||
|         ) | ||||
|         val body = RequestBody.create(jsonMime, payload.toString()) | ||||
|         val body = payload.toString().toRequestBody(jsonMime) | ||||
|         val request = Request.Builder() | ||||
|                 .url(apiUrl) | ||||
|                 .post(body) | ||||
|                 .build() | ||||
|             .url(apiUrl) | ||||
|             .post(body) | ||||
|             .build() | ||||
|         return authClient.newCall(request) | ||||
|                 .asObservableSuccess() | ||||
|                 .map { netResponse -> | ||||
|                     val responseBody = netResponse.body?.string().orEmpty() | ||||
|                     if (responseBody.isEmpty()) { | ||||
|                         throw Exception("Null Response") | ||||
|                     } | ||||
|                     val response = parser.parse(responseBody).obj | ||||
|                     val data = response["data"]!!.obj | ||||
|                     val page = data["Page"].obj | ||||
|                     val media = page["media"].array | ||||
|                     val entries = media.map { jsonToALManga(it.obj) } | ||||
|                     entries.map { it.toTrack() } | ||||
|             .asObservableSuccess() | ||||
|             .map { netResponse -> | ||||
|                 val responseBody = netResponse.body?.string().orEmpty() | ||||
|                 if (responseBody.isEmpty()) { | ||||
|                     throw Exception("Null Response") | ||||
|                 } | ||||
|                 val response = JsonParser.parseString(responseBody).obj | ||||
|                 val data = response["data"]!!.obj | ||||
|                 val page = data["Page"].obj | ||||
|                 val media = page["media"].array | ||||
|                 val entries = media.map { jsonToALManga(it.obj) } | ||||
|                 entries.map { it.toTrack() } | ||||
|             } | ||||
|     } | ||||
|  | ||||
|  | ||||
|     fun findLibManga(track: Track, userid: Int): Observable<Track?> { | ||||
|         val query = """ | ||||
|         val query = | ||||
|             """ | ||||
|             |query (${'$'}id: Int!, ${'$'}manga_id: Int!) { | ||||
|                 |Page { | ||||
|                     |mediaList(userId: ${'$'}id, type: MANGA, mediaId: ${'$'}manga_id) { | ||||
| @@ -176,38 +182,37 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) { | ||||
|             |} | ||||
|             |""".trimMargin() | ||||
|         val variables = jsonObject( | ||||
|                 "id" to userid, | ||||
|                 "manga_id" to track.media_id | ||||
|             "id" to userid, | ||||
|             "manga_id" to track.media_id | ||||
|         ) | ||||
|         val payload = jsonObject( | ||||
|                 "query" to query, | ||||
|                 "variables" to variables | ||||
|             "query" to query, | ||||
|             "variables" to variables | ||||
|         ) | ||||
|         val body = RequestBody.create(jsonMime, payload.toString()) | ||||
|         val body = payload.toString().toRequestBody(jsonMime) | ||||
|         val request = Request.Builder() | ||||
|                 .url(apiUrl) | ||||
|                 .post(body) | ||||
|                 .build() | ||||
|             .url(apiUrl) | ||||
|             .post(body) | ||||
|             .build() | ||||
|         return authClient.newCall(request) | ||||
|                 .asObservableSuccess() | ||||
|                 .map { netResponse -> | ||||
|                     val responseBody = netResponse.body?.string().orEmpty() | ||||
|                     if (responseBody.isEmpty()) { | ||||
|                         throw Exception("Null Response") | ||||
|                     } | ||||
|                     val response = parser.parse(responseBody).obj | ||||
|                     val data = response["data"]!!.obj | ||||
|                     val page = data["Page"].obj | ||||
|                     val media = page["mediaList"].array | ||||
|                     val entries = media.map { jsonToALUserManga(it.obj) } | ||||
|                     entries.firstOrNull()?.toTrack() | ||||
|  | ||||
|             .asObservableSuccess() | ||||
|             .map { netResponse -> | ||||
|                 val responseBody = netResponse.body?.string().orEmpty() | ||||
|                 if (responseBody.isEmpty()) { | ||||
|                     throw Exception("Null Response") | ||||
|                 } | ||||
|                 val response = JsonParser.parseString(responseBody).obj | ||||
|                 val data = response["data"]!!.obj | ||||
|                 val page = data["Page"].obj | ||||
|                 val media = page["mediaList"].array | ||||
|                 val entries = media.map { jsonToALUserManga(it.obj) } | ||||
|                 entries.firstOrNull()?.toTrack() | ||||
|             } | ||||
|     } | ||||
|  | ||||
|     fun getLibManga(track: Track, userid: Int): Observable<Track> { | ||||
|         return findLibManga(track, userid) | ||||
|                 .map { it ?: throw Exception("Could not find manga") } | ||||
|             .map { it ?: throw Exception("Could not find manga") } | ||||
|     } | ||||
|  | ||||
|     fun createOAuth(token: String): OAuth { | ||||
| @@ -215,7 +220,8 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) { | ||||
|     } | ||||
|  | ||||
|     fun getCurrentUser(): Observable<Pair<Int, String>> { | ||||
|         val query = """ | ||||
|         val query = | ||||
|             """ | ||||
|             |query User { | ||||
|                 |Viewer { | ||||
|                     |id | ||||
| @@ -226,40 +232,48 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) { | ||||
|             |} | ||||
|             |""".trimMargin() | ||||
|         val payload = jsonObject( | ||||
|                 "query" to query | ||||
|             "query" to query | ||||
|         ) | ||||
|         val body = RequestBody.create(jsonMime, payload.toString()) | ||||
|         val body = payload.toString().toRequestBody(jsonMime) | ||||
|         val request = Request.Builder() | ||||
|                 .url(apiUrl) | ||||
|                 .post(body) | ||||
|                 .build() | ||||
|             .url(apiUrl) | ||||
|             .post(body) | ||||
|             .build() | ||||
|         return authClient.newCall(request) | ||||
|                 .asObservableSuccess() | ||||
|                 .map { netResponse -> | ||||
|                     val responseBody = netResponse.body?.string().orEmpty() | ||||
|                     if (responseBody.isEmpty()) { | ||||
|                         throw Exception("Null Response") | ||||
|                     } | ||||
|                     val response = parser.parse(responseBody).obj | ||||
|                     val data = response["data"]!!.obj | ||||
|                     val viewer = data["Viewer"].obj | ||||
|                     Pair(viewer["id"].asInt, viewer["mediaListOptions"]["scoreFormat"].asString) | ||||
|             .asObservableSuccess() | ||||
|             .map { netResponse -> | ||||
|                 val responseBody = netResponse.body?.string().orEmpty() | ||||
|                 if (responseBody.isEmpty()) { | ||||
|                     throw Exception("Null Response") | ||||
|                 } | ||||
|                 val response = JsonParser.parseString(responseBody).obj | ||||
|                 val data = response["data"]!!.obj | ||||
|                 val viewer = data["Viewer"].obj | ||||
|                 Pair(viewer["id"].asInt, viewer["mediaListOptions"]["scoreFormat"].asString) | ||||
|             } | ||||
|     } | ||||
|  | ||||
|     private fun jsonToALManga(struct: JsonObject): ALManga { | ||||
|         val date = try { | ||||
|             val date = Calendar.getInstance() | ||||
|             date.set(struct["startDate"]["year"].nullInt ?: 0, (struct["startDate"]["month"].nullInt ?: 0) - 1, | ||||
|                     struct["startDate"]["day"].nullInt ?: 0) | ||||
|             date.set( | ||||
|                 struct["startDate"]["year"].nullInt ?: 0, | ||||
|                 ( | ||||
|                     struct["startDate"]["month"].nullInt | ||||
|                         ?: 0 | ||||
|                     ) - 1, | ||||
|                 struct["startDate"]["day"].nullInt ?: 0 | ||||
|             ) | ||||
|             date.timeInMillis | ||||
|         } catch (_: Exception) { | ||||
|             0L | ||||
|         } | ||||
|  | ||||
|         return ALManga(struct["id"].asInt, struct["title"]["romaji"].asString, struct["coverImage"]["large"].asString, | ||||
|                 struct["description"].nullString.orEmpty(), struct["type"].asString, struct["status"].asString, | ||||
|                 date, struct["chapters"].nullInt ?: 0) | ||||
|         return ALManga( | ||||
|             struct["id"].asInt, struct["title"]["romaji"].asString, struct["coverImage"]["large"].asString, | ||||
|             struct["description"].nullString.orEmpty(), struct["type"].asString, struct["status"].asString, | ||||
|             date, struct["chapters"].nullInt ?: 0 | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     private fun jsonToALUserManga(struct: JsonObject): ALUserManga { | ||||
| @@ -278,9 +292,8 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) { | ||||
|         } | ||||
|  | ||||
|         fun authUrl() = Uri.parse("${baseUrl}oauth/authorize").buildUpon() | ||||
|                 .appendQueryParameter("client_id", clientId) | ||||
|                 .appendQueryParameter("response_type", "token") | ||||
|                 .build() | ||||
|             .appendQueryParameter("client_id", clientId) | ||||
|             .appendQueryParameter("response_type", "token") | ||||
|             .build() | ||||
|     } | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -1,58 +1,56 @@ | ||||
| package eu.kanade.tachiyomi.data.track.anilist | ||||
|  | ||||
| import okhttp3.Interceptor | ||||
| import okhttp3.Response | ||||
|  | ||||
|  | ||||
| class AnilistInterceptor(val anilist: Anilist, private var token: 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 (token.isNullOrEmpty()) { | ||||
|             throw Exception("Not authenticated with Anilist") | ||||
|         } | ||||
|         if (oauth == null){ | ||||
|             oauth = anilist.loadOAuth() | ||||
|         } | ||||
|         // Refresh access token if null or expired. | ||||
|         if (oauth!!.isExpired()) { | ||||
|             anilist.logout() | ||||
|             throw Exception("Token expired") | ||||
|         } | ||||
|  | ||||
|         // Throw on null auth. | ||||
|         if (oauth == null) { | ||||
|             throw Exception("No authentication token") | ||||
|         } | ||||
|  | ||||
|         // 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?) { | ||||
|         token = oauth?.access_token | ||||
|         this.oauth = oauth | ||||
|         anilist.saveOAuth(oauth) | ||||
|     } | ||||
|  | ||||
| } | ||||
| package eu.kanade.tachiyomi.data.track.anilist | ||||
|  | ||||
| import okhttp3.Interceptor | ||||
| import okhttp3.Response | ||||
|  | ||||
| class AnilistInterceptor(val anilist: Anilist, private var token: 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 (token.isNullOrEmpty()) { | ||||
|             throw Exception("Not authenticated with Anilist") | ||||
|         } | ||||
|         if (oauth == null) { | ||||
|             oauth = anilist.loadOAuth() | ||||
|         } | ||||
|         // Refresh access token if null or expired. | ||||
|         if (oauth!!.isExpired()) { | ||||
|             anilist.logout() | ||||
|             throw Exception("Token expired") | ||||
|         } | ||||
|  | ||||
|         // Throw on null auth. | ||||
|         if (oauth == null) { | ||||
|             throw Exception("No authentication token") | ||||
|         } | ||||
|  | ||||
|         // 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?) { | ||||
|         token = oauth?.access_token | ||||
|         this.oauth = oauth | ||||
|         anilist.saveOAuth(oauth) | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -2,22 +2,22 @@ package eu.kanade.tachiyomi.data.track.anilist | ||||
|  | ||||
| import eu.kanade.tachiyomi.data.database.models.Track | ||||
| import eu.kanade.tachiyomi.data.preference.PreferencesHelper | ||||
| import eu.kanade.tachiyomi.data.preference.getOrDefault | ||||
| import eu.kanade.tachiyomi.data.track.TrackManager | ||||
| import eu.kanade.tachiyomi.data.track.model.TrackSearch | ||||
| import uy.kohesive.injekt.injectLazy | ||||
| import java.text.SimpleDateFormat | ||||
| import java.util.* | ||||
| import java.util.Locale | ||||
| import uy.kohesive.injekt.injectLazy | ||||
|  | ||||
| data class ALManga( | ||||
|         val media_id: Int, | ||||
|         val title_romaji: String, | ||||
|         val image_url_lge: String, | ||||
|         val description: String?, | ||||
|         val type: String, | ||||
|         val publishing_status: String, | ||||
|         val start_date_fuzzy: Long, | ||||
|         val total_chapters: Int) { | ||||
|     val media_id: Int, | ||||
|     val title_romaji: String, | ||||
|     val image_url_lge: String, | ||||
|     val description: String?, | ||||
|     val type: String, | ||||
|     val publishing_status: String, | ||||
|     val start_date_fuzzy: Long, | ||||
|     val total_chapters: Int | ||||
| ) { | ||||
|  | ||||
|     fun toTrack() = TrackSearch.create(TrackManager.ANILIST).apply { | ||||
|         media_id = this@ALManga.media_id | ||||
| @@ -40,11 +40,12 @@ data class ALManga( | ||||
| } | ||||
|  | ||||
| data class ALUserManga( | ||||
|         val library_id: Long, | ||||
|         val list_status: String, | ||||
|         val score_raw: Int, | ||||
|         val chapters_read: Int, | ||||
|         val manga: ALManga) { | ||||
|     val library_id: Long, | ||||
|     val list_status: String, | ||||
|     val score_raw: Int, | ||||
|     val chapters_read: Int, | ||||
|     val manga: ALManga | ||||
| ) { | ||||
|  | ||||
|     fun toTrack() = Track.create(TrackManager.ANILIST).apply { | ||||
|         media_id = manga.media_id | ||||
| @@ -58,7 +59,7 @@ data class ALUserManga( | ||||
|     fun toTrackStatus() = when (list_status) { | ||||
|         "CURRENT" -> Anilist.READING | ||||
|         "COMPLETED" -> Anilist.COMPLETED | ||||
|         "PAUSED" -> Anilist.ON_HOLD | ||||
|         "PAUSED" -> Anilist.PAUSED | ||||
|         "DROPPED" -> Anilist.DROPPED | ||||
|         "PLANNING" -> Anilist.PLANNING | ||||
|         "REPEATING" -> Anilist.REPEATING | ||||
| @@ -69,7 +70,7 @@ data class ALUserManga( | ||||
| fun Track.toAnilistStatus() = when (status) { | ||||
|     Anilist.READING -> "CURRENT" | ||||
|     Anilist.COMPLETED -> "COMPLETED" | ||||
|     Anilist.ON_HOLD -> "PAUSED" | ||||
|     Anilist.PAUSED -> "PAUSED" | ||||
|     Anilist.DROPPED -> "DROPPED" | ||||
|     Anilist.PLANNING -> "PLANNING" | ||||
|     Anilist.REPEATING -> "REPEATING" | ||||
| @@ -78,7 +79,7 @@ fun Track.toAnilistStatus() = when (status) { | ||||
|  | ||||
| private val preferences: PreferencesHelper by injectLazy() | ||||
|  | ||||
| fun Track.toAnilistScore(): String = when (preferences.anilistScoreType().getOrDefault()) { | ||||
| fun Track.toAnilistScore(): String = when (preferences.anilistScoreType().get()) { | ||||
| // 10 point | ||||
|     "POINT_10" -> (score.toInt() / 10).toString() | ||||
| // 100 point | ||||
|   | ||||
| @@ -1,10 +1,11 @@ | ||||
| package eu.kanade.tachiyomi.data.track.anilist | ||||
|  | ||||
| data class OAuth( | ||||
|         val access_token: String, | ||||
|         val token_type: String, | ||||
|         val expires: Long, | ||||
|         val expires_in: Long) { | ||||
|  | ||||
|     fun isExpired() = System.currentTimeMillis() > expires | ||||
| } | ||||
| package eu.kanade.tachiyomi.data.track.anilist | ||||
|  | ||||
| data class OAuth( | ||||
|     val access_token: String, | ||||
|     val token_type: String, | ||||
|     val expires: Long, | ||||
|     val expires_in: Long | ||||
| ) { | ||||
|  | ||||
|     fun isExpired() = System.currentTimeMillis() > expires | ||||
| } | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| package eu.kanade.tachiyomi.data.track.bangumi | ||||
|  | ||||
| data class Avatar( | ||||
|   val large: String? = "", | ||||
|   val medium: String? = "", | ||||
|   val small: String? = "" | ||||
| ) | ||||
|     val large: String? = "", | ||||
|     val medium: String? = "", | ||||
|     val small: String? = "" | ||||
| ) | ||||
|   | ||||
| @@ -1,144 +1,143 @@ | ||||
| package eu.kanade.tachiyomi.data.track.bangumi | ||||
|  | ||||
| import android.content.Context | ||||
| import android.graphics.Color | ||||
| import com.google.gson.Gson | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.database.models.Track | ||||
| import eu.kanade.tachiyomi.data.track.TrackService | ||||
| import eu.kanade.tachiyomi.data.track.model.TrackSearch | ||||
| import rx.Completable | ||||
| import rx.Observable | ||||
| import uy.kohesive.injekt.injectLazy | ||||
|  | ||||
| class Bangumi(private val context: Context, id: Int) : TrackService(id) { | ||||
|  | ||||
|   override fun getScoreList(): List<String> { | ||||
|     return IntRange(0, 10).map(Int::toString) | ||||
|   } | ||||
|  | ||||
|   override fun displayScore(track: Track): String { | ||||
|     return track.score.toInt().toString() | ||||
|   } | ||||
|  | ||||
|   override fun add(track: Track): Observable<Track> { | ||||
|     return api.addLibManga(track) | ||||
|   } | ||||
|  | ||||
|   override fun update(track: Track): Observable<Track> { | ||||
|     if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) { | ||||
|       track.status = COMPLETED | ||||
|     } | ||||
|     return api.updateLibManga(track) | ||||
|   } | ||||
|  | ||||
|   override fun bind(track: Track): Observable<Track> { | ||||
|     return api.statusLibManga(track) | ||||
|       .flatMap { | ||||
|         api.findLibManga(track).flatMap { remoteTrack -> | ||||
|           if (remoteTrack != null && it != null) { | ||||
|             track.copyPersonalFrom(remoteTrack) | ||||
|             track.library_id = remoteTrack.library_id | ||||
|             track.status = remoteTrack.status | ||||
|             track.last_chapter_read = remoteTrack.last_chapter_read | ||||
|             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) | ||||
|             update(track) | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|   } | ||||
|  | ||||
|   override fun search(query: String): Observable<List<TrackSearch>> { | ||||
|     return api.search(query) | ||||
|   } | ||||
|  | ||||
|   override fun refresh(track: Track): Observable<Track> { | ||||
|     return api.statusLibManga(track) | ||||
|       .flatMap { | ||||
|         track.copyPersonalFrom(it!!) | ||||
|         api.findLibManga(track) | ||||
|           .map { remoteTrack -> | ||||
|             if (remoteTrack != null) { | ||||
|               track.total_chapters = remoteTrack.total_chapters | ||||
|               track.status = remoteTrack.status | ||||
|             } | ||||
|             track | ||||
|           } | ||||
|       } | ||||
|   } | ||||
|  | ||||
|   companion object { | ||||
|     const val READING = 3 | ||||
|     const val COMPLETED = 2 | ||||
|     const val ON_HOLD = 4 | ||||
|     const val DROPPED = 5 | ||||
|     const val PLANNING = 1 | ||||
|  | ||||
|     const val DEFAULT_STATUS = READING | ||||
|     const val DEFAULT_SCORE = 0 | ||||
|   } | ||||
|  | ||||
|   override val name = "Bangumi" | ||||
|  | ||||
|   private val gson: Gson by injectLazy() | ||||
|  | ||||
|   private val interceptor by lazy { BangumiInterceptor(this, gson) } | ||||
|  | ||||
|   private val api by lazy { BangumiApi(client, interceptor) } | ||||
|  | ||||
|   override fun getLogo() = R.drawable.bangumi | ||||
|  | ||||
|   override fun getLogoColor() = Color.rgb(0xF0, 0x91, 0x99) | ||||
|  | ||||
|   override fun getStatusList(): List<Int> { | ||||
|     return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLANNING) | ||||
|   } | ||||
|  | ||||
|   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) | ||||
|       PLANNING -> getString(R.string.plan_to_read) | ||||
|       else -> "" | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   override fun login(username: String, password: String) = login(password) | ||||
|  | ||||
|   fun login(code: String): Completable { | ||||
|     return api.accessToken(code).map { oauth: OAuth? -> | ||||
|       interceptor.newAuth(oauth) | ||||
|       if (oauth != null) { | ||||
|         saveCredentials(oauth.user_id.toString(), oauth.access_token) | ||||
|       } | ||||
|     }.doOnError { | ||||
|       logout() | ||||
|     }.toCompletable() | ||||
|   } | ||||
|  | ||||
|   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 logout() { | ||||
|     super.logout() | ||||
|     preferences.trackToken(this).set(null) | ||||
|     interceptor.newAuth(null) | ||||
|   } | ||||
| } | ||||
| package eu.kanade.tachiyomi.data.track.bangumi | ||||
|  | ||||
| import android.content.Context | ||||
| import android.graphics.Color | ||||
| import com.google.gson.Gson | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.database.models.Track | ||||
| import eu.kanade.tachiyomi.data.track.TrackService | ||||
| import eu.kanade.tachiyomi.data.track.model.TrackSearch | ||||
| import rx.Completable | ||||
| import rx.Observable | ||||
| import uy.kohesive.injekt.injectLazy | ||||
|  | ||||
| class Bangumi(private val context: Context, id: Int) : TrackService(id) { | ||||
|  | ||||
|     override val name = "Bangumi" | ||||
|  | ||||
|     private val gson: Gson by injectLazy() | ||||
|  | ||||
|     private val interceptor by lazy { BangumiInterceptor(this, gson) } | ||||
|  | ||||
|     private val api by lazy { BangumiApi(client, interceptor) } | ||||
|  | ||||
|     override fun getScoreList(): List<String> { | ||||
|         return IntRange(0, 10).map(Int::toString) | ||||
|     } | ||||
|  | ||||
|     override fun displayScore(track: Track): String { | ||||
|         return track.score.toInt().toString() | ||||
|     } | ||||
|  | ||||
|     override fun add(track: Track): Observable<Track> { | ||||
|         return api.addLibManga(track) | ||||
|     } | ||||
|  | ||||
|     override fun update(track: Track): Observable<Track> { | ||||
|         return api.updateLibManga(track) | ||||
|     } | ||||
|  | ||||
|     override fun bind(track: Track): Observable<Track> { | ||||
|         return api.statusLibManga(track) | ||||
|             .flatMap { | ||||
|                 api.findLibManga(track).flatMap { remoteTrack -> | ||||
|                     if (remoteTrack != null && it != null) { | ||||
|                         track.copyPersonalFrom(remoteTrack) | ||||
|                         track.library_id = remoteTrack.library_id | ||||
|                         track.status = remoteTrack.status | ||||
|                         track.last_chapter_read = remoteTrack.last_chapter_read | ||||
|                         refresh(track) | ||||
|                     } else { | ||||
|                         // Set default fields if it's not found in the list | ||||
|                         track.score = DEFAULT_SCORE.toFloat() | ||||
|                         track.status = DEFAULT_STATUS | ||||
|                         add(track) | ||||
|                         update(track) | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|     } | ||||
|  | ||||
|     override fun search(query: String): Observable<List<TrackSearch>> { | ||||
|         return api.search(query) | ||||
|     } | ||||
|  | ||||
|     override fun refresh(track: Track): Observable<Track> { | ||||
|         return api.statusLibManga(track) | ||||
|             .flatMap { | ||||
|                 track.copyPersonalFrom(it!!) | ||||
|                 api.findLibManga(track) | ||||
|                     .map { remoteTrack -> | ||||
|                         if (remoteTrack != null) { | ||||
|                             track.total_chapters = remoteTrack.total_chapters | ||||
|                             track.status = remoteTrack.status | ||||
|                         } | ||||
|                         track | ||||
|                     } | ||||
|             } | ||||
|     } | ||||
|  | ||||
|     override fun getLogo() = R.drawable.ic_tracker_bangumi | ||||
|  | ||||
|     override fun getLogoColor() = Color.rgb(240, 145, 153) | ||||
|  | ||||
|     override fun getStatusList(): List<Int> { | ||||
|         return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLANNING) | ||||
|     } | ||||
|  | ||||
|     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) | ||||
|             PLANNING -> getString(R.string.plan_to_read) | ||||
|             else -> "" | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun getCompletionStatus(): Int = COMPLETED | ||||
|  | ||||
|     override fun login(username: String, password: String) = login(password) | ||||
|  | ||||
|     fun login(code: String): Completable { | ||||
|         return api.accessToken(code).map { oauth: OAuth? -> | ||||
|             interceptor.newAuth(oauth) | ||||
|             if (oauth != null) { | ||||
|                 saveCredentials(oauth.user_id.toString(), oauth.access_token) | ||||
|             } | ||||
|         }.doOnError { | ||||
|             logout() | ||||
|         }.toCompletable() | ||||
|     } | ||||
|  | ||||
|     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 logout() { | ||||
|         super.logout() | ||||
|         preferences.trackToken(this).delete() | ||||
|         interceptor.newAuth(null) | ||||
|     } | ||||
|  | ||||
|     companion object { | ||||
|         const val READING = 3 | ||||
|         const val COMPLETED = 2 | ||||
|         const val ON_HOLD = 4 | ||||
|         const val DROPPED = 5 | ||||
|         const val PLANNING = 1 | ||||
|  | ||||
|         const val DEFAULT_STATUS = READING | ||||
|         const val DEFAULT_SCORE = 0 | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -11,198 +11,207 @@ import eu.kanade.tachiyomi.data.track.TrackManager | ||||
| import eu.kanade.tachiyomi.data.track.model.TrackSearch | ||||
| import eu.kanade.tachiyomi.network.POST | ||||
| import eu.kanade.tachiyomi.network.asObservableSuccess | ||||
| import java.net.URLEncoder | ||||
| import okhttp3.CacheControl | ||||
| import okhttp3.FormBody | ||||
| import okhttp3.OkHttpClient | ||||
| import okhttp3.Request | ||||
| import rx.Observable | ||||
| import uy.kohesive.injekt.injectLazy | ||||
| import java.net.URLEncoder | ||||
|  | ||||
| class BangumiApi(private val client: OkHttpClient, interceptor: BangumiInterceptor) { | ||||
|  | ||||
|   private val gson: Gson by injectLazy() | ||||
|   private val parser = JsonParser() | ||||
|   private val authClient = client.newBuilder().addInterceptor(interceptor).build() | ||||
|     private val gson: Gson by injectLazy() | ||||
|     private val authClient = client.newBuilder().addInterceptor(interceptor).build() | ||||
|  | ||||
|   fun addLibManga(track: Track): Observable<Track> { | ||||
|     val body = FormBody.Builder() | ||||
|       .add("rating", track.score.toInt().toString()) | ||||
|       .add("status", track.toBangumiStatus()) | ||||
|       .build() | ||||
|     val request = Request.Builder() | ||||
|       .url("$apiUrl/collection/${track.media_id}/update") | ||||
|       .post(body) | ||||
|       .build() | ||||
|     return authClient.newCall(request) | ||||
|       .asObservableSuccess() | ||||
|       .map { | ||||
|         track | ||||
|       } | ||||
|   } | ||||
|     fun addLibManga(track: Track): Observable<Track> { | ||||
|         val body = FormBody.Builder() | ||||
|             .add("rating", track.score.toInt().toString()) | ||||
|             .add("status", track.toBangumiStatus()) | ||||
|             .build() | ||||
|         val request = Request.Builder() | ||||
|             .url("$apiUrl/collection/${track.media_id}/update") | ||||
|             .post(body) | ||||
|             .build() | ||||
|         return authClient.newCall(request) | ||||
|             .asObservableSuccess() | ||||
|             .map { | ||||
|                 track | ||||
|             } | ||||
|     } | ||||
|  | ||||
|   fun updateLibManga(track: Track): Observable<Track> { | ||||
|     // chapter update | ||||
|     val body = FormBody.Builder() | ||||
|       .add("watched_eps", track.last_chapter_read.toString()) | ||||
|       .build() | ||||
|     val request = Request.Builder() | ||||
|       .url("$apiUrl/subject/${track.media_id}/update/watched_eps") | ||||
|       .post(body) | ||||
|       .build() | ||||
|     fun updateLibManga(track: Track): Observable<Track> { | ||||
|         // chapter update | ||||
|         val body = FormBody.Builder() | ||||
|             .add("watched_eps", track.last_chapter_read.toString()) | ||||
|             .build() | ||||
|         val request = Request.Builder() | ||||
|             .url("$apiUrl/subject/${track.media_id}/update/watched_eps") | ||||
|             .post(body) | ||||
|             .build() | ||||
|  | ||||
|     // read status update | ||||
|     val sbody = FormBody.Builder() | ||||
|       .add("status", track.toBangumiStatus()) | ||||
|       .build() | ||||
|     val srequest = Request.Builder() | ||||
|       .url("$apiUrl/collection/${track.media_id}/update") | ||||
|       .post(sbody) | ||||
|       .build() | ||||
|     return authClient.newCall(request) | ||||
|       .asObservableSuccess() | ||||
|       .map { | ||||
|         track | ||||
|       }.flatMap { | ||||
|         authClient.newCall(srequest) | ||||
|           .asObservableSuccess() | ||||
|           .map { | ||||
|             track | ||||
|           } | ||||
|       } | ||||
|   } | ||||
|         // read status update | ||||
|         val sbody = FormBody.Builder() | ||||
|             .add("status", track.toBangumiStatus()) | ||||
|             .build() | ||||
|         val srequest = Request.Builder() | ||||
|             .url("$apiUrl/collection/${track.media_id}/update") | ||||
|             .post(sbody) | ||||
|             .build() | ||||
|         return authClient.newCall(srequest) | ||||
|             .asObservableSuccess() | ||||
|             .map { | ||||
|                 track | ||||
|             }.flatMap { | ||||
|                 authClient.newCall(request) | ||||
|                     .asObservableSuccess() | ||||
|                     .map { | ||||
|                         track | ||||
|                     } | ||||
|             } | ||||
|     } | ||||
|  | ||||
|   fun search(search: String): Observable<List<TrackSearch>> { | ||||
|     val url = Uri.parse( | ||||
|       "$apiUrl/search/subject/${URLEncoder.encode(search, Charsets.UTF_8.name())}").buildUpon() | ||||
|       .appendQueryParameter("max_results", "20") | ||||
|       .build() | ||||
|     val request = Request.Builder() | ||||
|       .url(url.toString()) | ||||
|       .get() | ||||
|       .build() | ||||
|     return authClient.newCall(request) | ||||
|       .asObservableSuccess() | ||||
|       .map { netResponse -> | ||||
|           val responseBody = netResponse.body?.string().orEmpty() | ||||
|         if (responseBody.isEmpty()) { | ||||
|           throw Exception("Null Response") | ||||
|     fun search(search: String): Observable<List<TrackSearch>> { | ||||
|         val url = Uri.parse( | ||||
|             "$apiUrl/search/subject/${URLEncoder.encode(search, Charsets.UTF_8.name())}" | ||||
|         ).buildUpon() | ||||
|             .appendQueryParameter("max_results", "20") | ||||
|             .build() | ||||
|         val request = Request.Builder() | ||||
|             .url(url.toString()) | ||||
|             .get() | ||||
|             .build() | ||||
|         return authClient.newCall(request) | ||||
|             .asObservableSuccess() | ||||
|             .map { netResponse -> | ||||
|                 var responseBody = netResponse.body?.string().orEmpty() | ||||
|                 if (responseBody.isEmpty()) { | ||||
|                     throw Exception("Null Response") | ||||
|                 } | ||||
|                 if (responseBody.contains("\"code\":404")) { | ||||
|                     responseBody = "{\"results\":0,\"list\":[]}" | ||||
|                 } | ||||
|                 val response = JsonParser.parseString(responseBody).obj["list"]?.array | ||||
|                 response?.filter { it.obj["type"].asInt == 1 }?.map { jsonToSearch(it.obj) } | ||||
|             } | ||||
|     } | ||||
|  | ||||
|     private fun jsonToSearch(obj: JsonObject): TrackSearch { | ||||
|         return TrackSearch.create(TrackManager.BANGUMI).apply { | ||||
|             media_id = obj["id"].asInt | ||||
|             title = obj["name_cn"].asString | ||||
|             cover_url = obj["images"].obj["common"].asString | ||||
|             summary = obj["name"].asString | ||||
|             tracking_url = obj["url"].asString | ||||
|         } | ||||
|         val response = parser.parse(responseBody).obj["list"]?.array | ||||
|         response?.filter { it.obj["type"].asInt == 1 }?.map { jsonToSearch(it.obj) } | ||||
|       } | ||||
|  | ||||
|   } | ||||
|  | ||||
|   private fun jsonToSearch(obj: JsonObject): TrackSearch { | ||||
|     return TrackSearch.create(TrackManager.BANGUMI).apply { | ||||
|       media_id = obj["id"].asInt | ||||
|       title = obj["name_cn"].asString | ||||
|       cover_url = obj["images"].obj["common"].asString | ||||
|       summary = obj["name"].asString | ||||
|       tracking_url = obj["url"].asString | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   private fun jsonToTrack(mangas: JsonObject): Track { | ||||
|     return Track.create(TrackManager.BANGUMI).apply { | ||||
|       title = mangas["name"].asString | ||||
|       media_id = mangas["id"].asInt | ||||
|       score = if (mangas["rating"] != null) | ||||
|         (if (mangas["rating"].isJsonObject) mangas["rating"].obj["score"].asFloat else 0f) | ||||
|       else 0f | ||||
|       status = Bangumi.DEFAULT_STATUS | ||||
|       tracking_url = mangas["url"].asString | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   fun findLibManga(track: Track): Observable<Track?> { | ||||
|     val urlMangas = "$apiUrl/subject/${track.media_id}" | ||||
|     val requestMangas = Request.Builder() | ||||
|       .url(urlMangas) | ||||
|       .get() | ||||
|       .build() | ||||
|  | ||||
|     return authClient.newCall(requestMangas) | ||||
|       .asObservableSuccess() | ||||
|       .map { netResponse -> | ||||
|         // get comic info | ||||
|           val responseBody = netResponse.body?.string().orEmpty() | ||||
|         jsonToTrack(parser.parse(responseBody).obj) | ||||
|       } | ||||
|   } | ||||
|  | ||||
|   fun statusLibManga(track: Track): Observable<Track?> { | ||||
|     val urlUserRead = "$apiUrl/collection/${track.media_id}" | ||||
|     val requestUserRead = Request.Builder() | ||||
|       .url(urlUserRead) | ||||
|       .cacheControl(CacheControl.FORCE_NETWORK) | ||||
|       .get() | ||||
|       .build() | ||||
|  | ||||
|     // todo get user readed chapter here | ||||
|     return authClient.newCall(requestUserRead) | ||||
|       .asObservableSuccess() | ||||
|       .map { netResponse -> | ||||
|           val resp = netResponse.body?.string() | ||||
|         val coll = gson.fromJson(resp, Collection::class.java) | ||||
|         track.status = coll.status?.id!! | ||||
|         track.last_chapter_read = coll.ep_status!! | ||||
|         track | ||||
|       } | ||||
|   } | ||||
|  | ||||
|   fun accessToken(code: String): Observable<OAuth> { | ||||
|     return client.newCall(accessTokenRequest(code)).asObservableSuccess().map { netResponse -> | ||||
|         val responseBody = netResponse.body?.string().orEmpty() | ||||
|       if (responseBody.isEmpty()) { | ||||
|         throw Exception("Null Response") | ||||
|       } | ||||
|       gson.fromJson(responseBody, OAuth::class.java) | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   private fun accessTokenRequest(code: String) = POST(oauthUrl, | ||||
|     body = FormBody.Builder() | ||||
|       .add("grant_type", "authorization_code") | ||||
|       .add("client_id", clientId) | ||||
|       .add("client_secret", clientSecret) | ||||
|       .add("code", code) | ||||
|       .add("redirect_uri", redirectUrl) | ||||
|       .build() | ||||
|   ) | ||||
|  | ||||
|   companion object { | ||||
|     private const val clientId = "bgm10555cda0762e80ca" | ||||
|     private const val clientSecret = "8fff394a8627b4c388cbf349ec865775" | ||||
|  | ||||
|     private const val baseUrl = "https://bangumi.org" | ||||
|     private const val apiUrl = "https://api.bgm.tv" | ||||
|     private const val oauthUrl = "https://bgm.tv/oauth/access_token" | ||||
|     private const val loginUrl = "https://bgm.tv/oauth/authorize" | ||||
|  | ||||
|     private const val redirectUrl = "tachiyomi://bangumi-auth" | ||||
|     private const val baseMangaUrl = "$apiUrl/mangas" | ||||
|  | ||||
|     fun mangaUrl(remoteId: Int): String { | ||||
|       return "$baseMangaUrl/$remoteId" | ||||
|     } | ||||
|  | ||||
|     fun authUrl() = | ||||
|       Uri.parse(loginUrl).buildUpon() | ||||
|         .appendQueryParameter("client_id", clientId) | ||||
|         .appendQueryParameter("response_type", "code") | ||||
|         .appendQueryParameter("redirect_uri", redirectUrl) | ||||
|         .build() | ||||
|     private fun jsonToTrack(mangas: JsonObject): Track { | ||||
|         return Track.create(TrackManager.BANGUMI).apply { | ||||
|             title = mangas["name"].asString | ||||
|             media_id = mangas["id"].asInt | ||||
|             score = if (mangas["rating"] != null) { | ||||
|                 if (mangas["rating"].isJsonObject) { | ||||
|                     mangas["rating"].obj["score"].asFloat | ||||
|                 } else { | ||||
|                     0f | ||||
|                 } | ||||
|             } else { | ||||
|                 0f | ||||
|             } | ||||
|             status = Bangumi.DEFAULT_STATUS | ||||
|             tracking_url = mangas["url"].asString | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fun refreshTokenRequest(token: String) = POST(oauthUrl, | ||||
|       body = FormBody.Builder() | ||||
|         .add("grant_type", "refresh_token") | ||||
|         .add("client_id", clientId) | ||||
|         .add("client_secret", clientSecret) | ||||
|         .add("refresh_token", token) | ||||
|         .add("redirect_uri", redirectUrl) | ||||
|         .build()) | ||||
|   } | ||||
|     fun findLibManga(track: Track): Observable<Track?> { | ||||
|         val urlMangas = "$apiUrl/subject/${track.media_id}" | ||||
|         val requestMangas = Request.Builder() | ||||
|             .url(urlMangas) | ||||
|             .get() | ||||
|             .build() | ||||
|  | ||||
|         return authClient.newCall(requestMangas) | ||||
|             .asObservableSuccess() | ||||
|             .map { netResponse -> | ||||
|                 // get comic info | ||||
|                 val responseBody = netResponse.body?.string().orEmpty() | ||||
|                 jsonToTrack(JsonParser.parseString(responseBody).obj) | ||||
|             } | ||||
|     } | ||||
|  | ||||
|     fun statusLibManga(track: Track): Observable<Track?> { | ||||
|         val urlUserRead = "$apiUrl/collection/${track.media_id}" | ||||
|         val requestUserRead = Request.Builder() | ||||
|             .url(urlUserRead) | ||||
|             .cacheControl(CacheControl.FORCE_NETWORK) | ||||
|             .get() | ||||
|             .build() | ||||
|  | ||||
|         // todo get user readed chapter here | ||||
|         return authClient.newCall(requestUserRead) | ||||
|             .asObservableSuccess() | ||||
|             .map { netResponse -> | ||||
|                 val resp = netResponse.body?.string() | ||||
|                 val coll = gson.fromJson(resp, Collection::class.java) | ||||
|                 track.status = coll.status?.id!! | ||||
|                 track.last_chapter_read = coll.ep_status!! | ||||
|                 track | ||||
|             } | ||||
|     } | ||||
|  | ||||
|     fun accessToken(code: String): Observable<OAuth> { | ||||
|         return client.newCall(accessTokenRequest(code)).asObservableSuccess().map { netResponse -> | ||||
|             val responseBody = netResponse.body?.string().orEmpty() | ||||
|             if (responseBody.isEmpty()) { | ||||
|                 throw Exception("Null Response") | ||||
|             } | ||||
|             gson.fromJson(responseBody, OAuth::class.java) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private fun accessTokenRequest(code: String) = POST( | ||||
|         oauthUrl, | ||||
|         body = FormBody.Builder() | ||||
|             .add("grant_type", "authorization_code") | ||||
|             .add("client_id", clientId) | ||||
|             .add("client_secret", clientSecret) | ||||
|             .add("code", code) | ||||
|             .add("redirect_uri", redirectUrl) | ||||
|             .build() | ||||
|     ) | ||||
|  | ||||
|     companion object { | ||||
|         private const val clientId = "bgm10555cda0762e80ca" | ||||
|         private const val clientSecret = "8fff394a8627b4c388cbf349ec865775" | ||||
|  | ||||
|         private const val apiUrl = "https://api.bgm.tv" | ||||
|         private const val oauthUrl = "https://bgm.tv/oauth/access_token" | ||||
|         private const val loginUrl = "https://bgm.tv/oauth/authorize" | ||||
|  | ||||
|         private const val redirectUrl = "tachiyomi://bangumi-auth" | ||||
|         private const val baseMangaUrl = "$apiUrl/mangas" | ||||
|  | ||||
|         fun mangaUrl(remoteId: Int): String { | ||||
|             return "$baseMangaUrl/$remoteId" | ||||
|         } | ||||
|  | ||||
|         fun authUrl() = | ||||
|             Uri.parse(loginUrl).buildUpon() | ||||
|                 .appendQueryParameter("client_id", clientId) | ||||
|                 .appendQueryParameter("response_type", "code") | ||||
|                 .appendQueryParameter("redirect_uri", redirectUrl) | ||||
|                 .build() | ||||
|  | ||||
|         fun refreshTokenRequest(token: String) = POST( | ||||
|             oauthUrl, | ||||
|             body = FormBody.Builder() | ||||
|                 .add("grant_type", "refresh_token") | ||||
|                 .add("client_id", clientId) | ||||
|                 .add("client_secret", clientSecret) | ||||
|                 .add("refresh_token", token) | ||||
|                 .add("redirect_uri", redirectUrl) | ||||
|                 .build() | ||||
|         ) | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -7,55 +7,58 @@ import okhttp3.Response | ||||
|  | ||||
| class BangumiInterceptor(val bangumi: Bangumi, val gson: Gson) : Interceptor { | ||||
|  | ||||
|   /** | ||||
|    * OAuth object used for authenticated requests. | ||||
|    */ | ||||
|   private var oauth: OAuth? = bangumi.restoreToken() | ||||
|     /** | ||||
|      * OAuth object used for authenticated requests. | ||||
|      */ | ||||
|     private var oauth: OAuth? = bangumi.restoreToken() | ||||
|  | ||||
|   fun addTocken(tocken: String, oidFormBody: FormBody): FormBody { | ||||
|     val newFormBody = FormBody.Builder() | ||||
|       for (i in 0 until oidFormBody.size) { | ||||
|       newFormBody.add(oidFormBody.name(i), oidFormBody.value(i)) | ||||
|     } | ||||
|     newFormBody.add("access_token", tocken) | ||||
|     return newFormBody.build() | ||||
|   } | ||||
|  | ||||
|   override fun intercept(chain: Interceptor.Chain): Response { | ||||
|     val originalRequest = chain.request() | ||||
|  | ||||
|     val currAuth = oauth ?: throw Exception("Not authenticated with Bangumi") | ||||
|  | ||||
|     if (currAuth.isExpired()) { | ||||
|       val response = chain.proceed(BangumiApi.refreshTokenRequest(currAuth.refresh_token!!)) | ||||
|       if (response.isSuccessful) { | ||||
|           newAuth(gson.fromJson(response.body!!.string(), OAuth::class.java)) | ||||
|       } else { | ||||
|         response.close() | ||||
|       } | ||||
|     fun addTocken(tocken: String, oidFormBody: FormBody): FormBody { | ||||
|         val newFormBody = FormBody.Builder() | ||||
|         for (i in 0 until oidFormBody.size) { | ||||
|             newFormBody.add(oidFormBody.name(i), oidFormBody.value(i)) | ||||
|         } | ||||
|         newFormBody.add("access_token", tocken) | ||||
|         return newFormBody.build() | ||||
|     } | ||||
|  | ||||
|       var authRequest = if (originalRequest.method == "GET") originalRequest.newBuilder() | ||||
|       .header("User-Agent", "Tachiyomi") | ||||
|               .url(originalRequest.url.newBuilder() | ||||
|         .addQueryParameter("access_token", currAuth.access_token).build()) | ||||
|       .build() else originalRequest.newBuilder() | ||||
|               .post(addTocken(currAuth.access_token, originalRequest.body as FormBody)) | ||||
|       .header("User-Agent", "Tachiyomi") | ||||
|       .build() | ||||
|     override fun intercept(chain: Interceptor.Chain): Response { | ||||
|         val originalRequest = chain.request() | ||||
|  | ||||
|     return chain.proceed(authRequest) | ||||
|   } | ||||
|         val currAuth = oauth ?: throw Exception("Not authenticated with Bangumi") | ||||
|  | ||||
|   fun newAuth(oauth: OAuth?) { | ||||
|     this.oauth = if (oauth == null) null else OAuth( | ||||
|       oauth.access_token, | ||||
|       oauth.token_type, | ||||
|       System.currentTimeMillis() / 1000, | ||||
|       oauth.expires_in, | ||||
|       oauth.refresh_token, | ||||
|       this.oauth?.user_id) | ||||
|         if (currAuth.isExpired()) { | ||||
|             val response = chain.proceed(BangumiApi.refreshTokenRequest(currAuth.refresh_token!!)) | ||||
|             if (response.isSuccessful) { | ||||
|                 newAuth(gson.fromJson(response.body!!.string(), OAuth::class.java)) | ||||
|             } else { | ||||
|                 response.close() | ||||
|             } | ||||
|         } | ||||
|  | ||||
|     bangumi.saveToken(oauth) | ||||
|   } | ||||
|         val authRequest = if (originalRequest.method == "GET") originalRequest.newBuilder() | ||||
|             .header("User-Agent", "Tachiyomi") | ||||
|             .url( | ||||
|                 originalRequest.url.newBuilder() | ||||
|                     .addQueryParameter("access_token", currAuth.access_token).build() | ||||
|             ) | ||||
|             .build() else originalRequest.newBuilder() | ||||
|             .post(addTocken(currAuth.access_token, originalRequest.body as FormBody)) | ||||
|             .header("User-Agent", "Tachiyomi") | ||||
|             .build() | ||||
|  | ||||
|         return chain.proceed(authRequest) | ||||
|     } | ||||
|  | ||||
|     fun newAuth(oauth: OAuth?) { | ||||
|         this.oauth = if (oauth == null) null else OAuth( | ||||
|             oauth.access_token, | ||||
|             oauth.token_type, | ||||
|             System.currentTimeMillis() / 1000, | ||||
|             oauth.expires_in, | ||||
|             oauth.refresh_token, | ||||
|             this.oauth?.user_id | ||||
|         ) | ||||
|  | ||||
|         bangumi.saveToken(oauth) | ||||
|     } | ||||
| } | ||||
|   | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user