mirror of
				https://github.com/mihonapp/mihon.git
				synced 2025-10-30 22:07:57 +01:00 
			
		
		
		
	| @@ -155,20 +155,6 @@ | ||||
|             android:name=".data.notification.NotificationReceiver" | ||||
|             android:exported="false" /> | ||||
|  | ||||
|         <receiver | ||||
|             android:name="tachiyomi.presentation.widget.UpdatesGridGlanceReceiver" | ||||
|             android:enabled="@bool/glance_appwidget_available" | ||||
|             android:exported="false" | ||||
|             android:label="@string/label_recent_updates"> | ||||
|             <intent-filter> | ||||
|                 <action android:name="android.appwidget.action.APPWIDGET_UPDATE" /> | ||||
|             </intent-filter> | ||||
|  | ||||
|             <meta-data | ||||
|                 android:name="android.appwidget.provider" | ||||
|                 android:resource="@xml/updates_grid_glance_widget_info" /> | ||||
|         </receiver> | ||||
|  | ||||
|         <service | ||||
|             android:name=".data.download.DownloadService" | ||||
|             android:exported="false" /> | ||||
|   | ||||
| @@ -1,7 +1,6 @@ | ||||
| package eu.kanade.domain | ||||
|  | ||||
| import eu.kanade.domain.chapter.interactor.SetReadStatus | ||||
| import eu.kanade.domain.chapter.interactor.SyncChapterProgressWithTrack | ||||
| import eu.kanade.domain.chapter.interactor.SyncChaptersWithSource | ||||
| import eu.kanade.domain.download.interactor.DeleteDownload | ||||
| import eu.kanade.domain.extension.interactor.GetExtensionLanguages | ||||
| @@ -16,7 +15,9 @@ import eu.kanade.domain.source.interactor.SetMigrateSorting | ||||
| import eu.kanade.domain.source.interactor.ToggleLanguage | ||||
| import eu.kanade.domain.source.interactor.ToggleSource | ||||
| import eu.kanade.domain.source.interactor.ToggleSourcePin | ||||
| import eu.kanade.domain.track.interactor.AddTracks | ||||
| import eu.kanade.domain.track.interactor.RefreshTracks | ||||
| import eu.kanade.domain.track.interactor.SyncChapterProgressWithTrack | ||||
| import eu.kanade.domain.track.interactor.TrackChapter | ||||
| import tachiyomi.data.category.CategoryRepositoryImpl | ||||
| import tachiyomi.data.chapter.ChapterRepositoryImpl | ||||
| @@ -50,6 +51,7 @@ import tachiyomi.domain.history.interactor.GetTotalReadDuration | ||||
| import tachiyomi.domain.history.interactor.RemoveHistory | ||||
| import tachiyomi.domain.history.interactor.UpsertHistory | ||||
| import tachiyomi.domain.history.repository.HistoryRepository | ||||
| import tachiyomi.domain.manga.interactor.FetchInterval | ||||
| import tachiyomi.domain.manga.interactor.GetDuplicateLibraryManga | ||||
| import tachiyomi.domain.manga.interactor.GetFavorites | ||||
| import tachiyomi.domain.manga.interactor.GetLibraryManga | ||||
| @@ -57,7 +59,6 @@ import tachiyomi.domain.manga.interactor.GetManga | ||||
| import tachiyomi.domain.manga.interactor.GetMangaWithChapters | ||||
| import tachiyomi.domain.manga.interactor.NetworkToLocalManga | ||||
| import tachiyomi.domain.manga.interactor.ResetViewerFlags | ||||
| import tachiyomi.domain.manga.interactor.SetFetchInterval | ||||
| import tachiyomi.domain.manga.interactor.SetMangaChapterFlags | ||||
| import tachiyomi.domain.manga.repository.MangaRepository | ||||
| import tachiyomi.domain.release.interactor.GetApplicationRelease | ||||
| @@ -102,7 +103,7 @@ class DomainModule : InjektModule { | ||||
|         addFactory { GetNextChapters(get(), get(), get()) } | ||||
|         addFactory { ResetViewerFlags(get()) } | ||||
|         addFactory { SetMangaChapterFlags(get()) } | ||||
|         addFactory { SetFetchInterval(get()) } | ||||
|         addFactory { FetchInterval(get()) } | ||||
|         addFactory { SetMangaDefaultChapterFlags(get(), get(), get()) } | ||||
|         addFactory { SetMangaViewerFlags(get()) } | ||||
|         addFactory { NetworkToLocalManga(get()) } | ||||
| @@ -114,11 +115,13 @@ class DomainModule : InjektModule { | ||||
|  | ||||
|         addSingletonFactory<TrackRepository> { TrackRepositoryImpl(get()) } | ||||
|         addFactory { TrackChapter(get(), get(), get(), get()) } | ||||
|         addFactory { AddTracks(get(), get(), get()) } | ||||
|         addFactory { RefreshTracks(get(), get(), get(), get()) } | ||||
|         addFactory { DeleteTrack(get()) } | ||||
|         addFactory { GetTracksPerManga(get()) } | ||||
|         addFactory { GetTracks(get()) } | ||||
|         addFactory { InsertTrack(get()) } | ||||
|         addFactory { SyncChapterProgressWithTrack(get(), get(), get()) } | ||||
|  | ||||
|         addSingletonFactory<ChapterRepository> { ChapterRepositoryImpl(get()) } | ||||
|         addFactory { GetChapter(get()) } | ||||
| @@ -127,7 +130,6 @@ class DomainModule : InjektModule { | ||||
|         addFactory { SetReadStatus(get(), get(), get(), get()) } | ||||
|         addFactory { ShouldUpdateDbChapter() } | ||||
|         addFactory { SyncChaptersWithSource(get(), get(), get(), get(), get(), get(), get()) } | ||||
|         addFactory { SyncChapterProgressWithTrack(get(), get(), get()) } | ||||
|  | ||||
|         addSingletonFactory<HistoryRepository> { HistoryRepositoryImpl(get()) } | ||||
|         addFactory { GetHistory(get()) } | ||||
|   | ||||
| @@ -3,7 +3,7 @@ package eu.kanade.domain.manga.interactor | ||||
| import eu.kanade.domain.manga.model.hasCustomCover | ||||
| import eu.kanade.tachiyomi.data.cache.CoverCache | ||||
| import eu.kanade.tachiyomi.source.model.SManga | ||||
| import tachiyomi.domain.manga.interactor.SetFetchInterval | ||||
| import tachiyomi.domain.manga.interactor.FetchInterval | ||||
| import tachiyomi.domain.manga.model.Manga | ||||
| import tachiyomi.domain.manga.model.MangaUpdate | ||||
| import tachiyomi.domain.manga.repository.MangaRepository | ||||
| @@ -15,7 +15,7 @@ import java.util.Date | ||||
|  | ||||
| class UpdateManga( | ||||
|     private val mangaRepository: MangaRepository, | ||||
|     private val setFetchInterval: SetFetchInterval, | ||||
|     private val fetchInterval: FetchInterval, | ||||
| ) { | ||||
|  | ||||
|     suspend fun await(mangaUpdate: MangaUpdate): Boolean { | ||||
| @@ -79,9 +79,9 @@ class UpdateManga( | ||||
|     suspend fun awaitUpdateFetchInterval( | ||||
|         manga: Manga, | ||||
|         dateTime: ZonedDateTime = ZonedDateTime.now(), | ||||
|         window: Pair<Long, Long> = setFetchInterval.getWindow(dateTime), | ||||
|         window: Pair<Long, Long> = fetchInterval.getWindow(dateTime), | ||||
|     ): Boolean { | ||||
|         return setFetchInterval.toMangaUpdateOrNull(manga, dateTime, window) | ||||
|         return fetchInterval.toMangaUpdateOrNull(manga, dateTime, window) | ||||
|             ?.let { mangaRepository.update(it) } | ||||
|             ?: false | ||||
|     } | ||||
|   | ||||
| @@ -0,0 +1,45 @@ | ||||
| package eu.kanade.domain.track.interactor | ||||
|  | ||||
| import eu.kanade.domain.track.model.toDomainTrack | ||||
| import eu.kanade.tachiyomi.data.track.EnhancedTracker | ||||
| import eu.kanade.tachiyomi.data.track.Tracker | ||||
| import eu.kanade.tachiyomi.source.Source | ||||
| import logcat.LogPriority | ||||
| import tachiyomi.core.util.lang.withNonCancellableContext | ||||
| import tachiyomi.core.util.system.logcat | ||||
| import tachiyomi.domain.manga.model.Manga | ||||
| import tachiyomi.domain.track.interactor.GetTracks | ||||
| import tachiyomi.domain.track.interactor.InsertTrack | ||||
|  | ||||
| class AddTracks( | ||||
|     private val getTracks: GetTracks, | ||||
|     private val insertTrack: InsertTrack, | ||||
|     private val syncChapterProgressWithTrack: SyncChapterProgressWithTrack, | ||||
| ) { | ||||
|  | ||||
|     suspend fun bindEnhancedTracks(manga: Manga, source: Source) = withNonCancellableContext { | ||||
|         getTracks.await(manga.id) | ||||
|             .filterIsInstance<EnhancedTracker>() | ||||
|             .filter { it.accept(source) } | ||||
|             .forEach { service -> | ||||
|                 try { | ||||
|                     service.match(manga)?.let { track -> | ||||
|                         track.manga_id = manga.id | ||||
|                         (service as Tracker).bind(track) | ||||
|                         insertTrack.await(track.toDomainTrack()!!) | ||||
|  | ||||
|                         syncChapterProgressWithTrack.await( | ||||
|                             manga.id, | ||||
|                             track.toDomainTrack()!!, | ||||
|                             service, | ||||
|                         ) | ||||
|                     } | ||||
|                 } catch (e: Exception) { | ||||
|                     logcat( | ||||
|                         LogPriority.WARN, | ||||
|                         e, | ||||
|                     ) { "Could not match manga: ${manga.title} with service $service" } | ||||
|                 } | ||||
|             } | ||||
|     } | ||||
| } | ||||
| @@ -1,10 +1,9 @@ | ||||
| package eu.kanade.domain.track.interactor | ||||
|  | ||||
| import eu.kanade.domain.chapter.interactor.SyncChapterProgressWithTrack | ||||
| import eu.kanade.domain.track.model.toDbTrack | ||||
| import eu.kanade.domain.track.model.toDomainTrack | ||||
| import eu.kanade.tachiyomi.data.track.TrackManager | ||||
| import eu.kanade.tachiyomi.data.track.TrackService | ||||
| import eu.kanade.tachiyomi.data.track.Tracker | ||||
| import eu.kanade.tachiyomi.data.track.TrackerManager | ||||
| import kotlinx.coroutines.async | ||||
| import kotlinx.coroutines.awaitAll | ||||
| import kotlinx.coroutines.supervisorScope | ||||
| @@ -13,7 +12,7 @@ import tachiyomi.domain.track.interactor.InsertTrack | ||||
|  | ||||
| class RefreshTracks( | ||||
|     private val getTracks: GetTracks, | ||||
|     private val trackManager: TrackManager, | ||||
|     private val trackerManager: TrackerManager, | ||||
|     private val insertTrack: InsertTrack, | ||||
|     private val syncChapterProgressWithTrack: SyncChapterProgressWithTrack, | ||||
| ) { | ||||
| @@ -23,18 +22,17 @@ class RefreshTracks( | ||||
|      * | ||||
|      * @return Failed updates. | ||||
|      */ | ||||
|     suspend fun await(mangaId: Long): List<Pair<TrackService?, Throwable>> { | ||||
|     suspend fun await(mangaId: Long): List<Pair<Tracker?, Throwable>> { | ||||
|         return supervisorScope { | ||||
|             return@supervisorScope getTracks.await(mangaId) | ||||
|                 .map { track -> | ||||
|                 .map { it to trackerManager.get(it.syncId) } | ||||
|                 .filter { (_, service) -> service?.isLoggedIn == true } | ||||
|                 .map { (track, service) -> | ||||
|                     async { | ||||
|                         val service = trackManager.getService(track.syncId) | ||||
|                         return@async try { | ||||
|                             if (service?.isLoggedIn == true) { | ||||
|                                 val updatedTrack = service.refresh(track.toDbTrack()) | ||||
|                                 insertTrack.await(updatedTrack.toDomainTrack()!!) | ||||
|                                 syncChapterProgressWithTrack.await(mangaId, track, service) | ||||
|                             } | ||||
|                             val updatedTrack = service!!.refresh(track.toDbTrack()) | ||||
|                             insertTrack.await(updatedTrack.toDomainTrack()!!) | ||||
|                             syncChapterProgressWithTrack.await(mangaId, track, service) | ||||
|                             null | ||||
|                         } catch (e: Throwable) { | ||||
|                             service to e | ||||
|   | ||||
| @@ -1,8 +1,8 @@ | ||||
| package eu.kanade.domain.chapter.interactor | ||||
| package eu.kanade.domain.track.interactor | ||||
| 
 | ||||
| import eu.kanade.domain.track.model.toDbTrack | ||||
| import eu.kanade.tachiyomi.data.track.EnhancedTrackService | ||||
| import eu.kanade.tachiyomi.data.track.TrackService | ||||
| import eu.kanade.tachiyomi.data.track.EnhancedTracker | ||||
| import eu.kanade.tachiyomi.data.track.Tracker | ||||
| import logcat.LogPriority | ||||
| import tachiyomi.core.util.system.logcat | ||||
| import tachiyomi.domain.chapter.interactor.GetChapterByMangaId | ||||
| @@ -20,9 +20,9 @@ class SyncChapterProgressWithTrack( | ||||
|     suspend fun await( | ||||
|         mangaId: Long, | ||||
|         remoteTrack: Track, | ||||
|         service: TrackService, | ||||
|         tracker: Tracker, | ||||
|     ) { | ||||
|         if (service !is EnhancedTrackService) { | ||||
|         if (tracker !is EnhancedTracker) { | ||||
|             return | ||||
|         } | ||||
| 
 | ||||
| @@ -39,7 +39,7 @@ class SyncChapterProgressWithTrack( | ||||
|         val updatedTrack = remoteTrack.copy(lastChapterRead = localLastRead.toDouble()) | ||||
| 
 | ||||
|         try { | ||||
|             service.update(updatedTrack.toDbTrack()) | ||||
|             tracker.update(updatedTrack.toDbTrack()) | ||||
|             updateChapter.awaitAll(chapterUpdates) | ||||
|             insertTrack.await(updatedTrack) | ||||
|         } catch (e: Throwable) { | ||||
| @@ -4,30 +4,29 @@ import android.content.Context | ||||
| import eu.kanade.domain.track.model.toDbTrack | ||||
| import eu.kanade.domain.track.service.DelayedTrackingUpdateJob | ||||
| import eu.kanade.domain.track.store.DelayedTrackingStore | ||||
| import eu.kanade.tachiyomi.data.track.TrackManager | ||||
| import eu.kanade.tachiyomi.data.track.TrackerManager | ||||
| import kotlinx.coroutines.async | ||||
| import kotlinx.coroutines.awaitAll | ||||
| import kotlinx.coroutines.coroutineScope | ||||
| import logcat.LogPriority | ||||
| import tachiyomi.core.util.lang.launchNonCancellable | ||||
| import tachiyomi.core.util.lang.withNonCancellableContext | ||||
| import tachiyomi.core.util.system.logcat | ||||
| import tachiyomi.domain.track.interactor.GetTracks | ||||
| import tachiyomi.domain.track.interactor.InsertTrack | ||||
|  | ||||
| class TrackChapter( | ||||
|     private val getTracks: GetTracks, | ||||
|     private val trackManager: TrackManager, | ||||
|     private val trackerManager: TrackerManager, | ||||
|     private val insertTrack: InsertTrack, | ||||
|     private val delayedTrackingStore: DelayedTrackingStore, | ||||
| ) { | ||||
|  | ||||
|     suspend fun await(context: Context, mangaId: Long, chapterNumber: Double) = coroutineScope { | ||||
|         launchNonCancellable { | ||||
|     suspend fun await(context: Context, mangaId: Long, chapterNumber: Double) { | ||||
|         withNonCancellableContext { | ||||
|             val tracks = getTracks.await(mangaId) | ||||
|             if (tracks.isEmpty()) return@launchNonCancellable | ||||
|             if (tracks.isEmpty()) return@withNonCancellableContext | ||||
|  | ||||
|             tracks.mapNotNull { track -> | ||||
|                 val service = trackManager.getService(track.syncId) | ||||
|                 val service = trackerManager.get(track.syncId) | ||||
|                 if (service == null || !service.isLoggedIn || chapterNumber <= track.lastChapterRead) { | ||||
|                     return@mapNotNull null | ||||
|                 } | ||||
|   | ||||
| @@ -10,7 +10,7 @@ import androidx.work.OneTimeWorkRequestBuilder | ||||
| import androidx.work.WorkerParameters | ||||
| import eu.kanade.domain.track.model.toDbTrack | ||||
| import eu.kanade.domain.track.store.DelayedTrackingStore | ||||
| import eu.kanade.tachiyomi.data.track.TrackManager | ||||
| import eu.kanade.tachiyomi.data.track.TrackerManager | ||||
| import eu.kanade.tachiyomi.util.system.workManager | ||||
| import logcat.LogPriority | ||||
| import tachiyomi.core.util.lang.withIOContext | ||||
| @@ -33,7 +33,7 @@ class DelayedTrackingUpdateJob(context: Context, workerParams: WorkerParameters) | ||||
|         val getTracks = Injekt.get<GetTracks>() | ||||
|         val insertTrack = Injekt.get<InsertTrack>() | ||||
|  | ||||
|         val trackManager = Injekt.get<TrackManager>() | ||||
|         val trackerManager = Injekt.get<TrackerManager>() | ||||
|         val delayedTrackingStore = Injekt.get<DelayedTrackingStore>() | ||||
|  | ||||
|         withIOContext { | ||||
| @@ -47,7 +47,7 @@ class DelayedTrackingUpdateJob(context: Context, workerParams: WorkerParameters) | ||||
|                 } | ||||
|                 .forEach { track -> | ||||
|                     try { | ||||
|                         val service = trackManager.getService(track.syncId) | ||||
|                         val service = trackerManager.get(track.syncId) | ||||
|                         if (service != null && service.isLoggedIn) { | ||||
|                             logcat(LogPriority.DEBUG) { "Updating delayed track item: ${track.id}, last chapter read: ${track.lastChapterRead}" } | ||||
|                             service.update(track.toDbTrack(), true) | ||||
|   | ||||
| @@ -1,33 +1,34 @@ | ||||
| package eu.kanade.domain.track.service | ||||
|  | ||||
| import eu.kanade.tachiyomi.data.track.TrackService | ||||
| import eu.kanade.tachiyomi.data.track.Tracker | ||||
| import eu.kanade.tachiyomi.data.track.anilist.Anilist | ||||
| import tachiyomi.core.preference.Preference | ||||
| import tachiyomi.core.preference.PreferenceStore | ||||
|  | ||||
| class TrackPreferences( | ||||
|     private val preferenceStore: PreferenceStore, | ||||
| ) { | ||||
|  | ||||
|     fun trackUsername(sync: TrackService) = preferenceStore.getString(trackUsername(sync.id), "") | ||||
|     fun trackUsername(sync: Tracker) = preferenceStore.getString(trackUsername(sync.id), "") | ||||
|  | ||||
|     fun trackPassword(sync: TrackService) = preferenceStore.getString(trackPassword(sync.id), "") | ||||
|     fun trackPassword(sync: Tracker) = preferenceStore.getString(trackPassword(sync.id), "") | ||||
|  | ||||
|     fun setTrackCredentials(sync: TrackService, username: String, password: String) { | ||||
|     fun setCredentials(sync: Tracker, username: String, password: String) { | ||||
|         trackUsername(sync).set(username) | ||||
|         trackPassword(sync).set(password) | ||||
|     } | ||||
|  | ||||
|     fun trackToken(sync: TrackService) = preferenceStore.getString(trackToken(sync.id), "") | ||||
|     fun trackToken(sync: Tracker) = preferenceStore.getString(trackToken(sync.id), "") | ||||
|  | ||||
|     fun anilistScoreType() = preferenceStore.getString("anilist_score_type", Anilist.POINT_10) | ||||
|  | ||||
|     fun autoUpdateTrack() = preferenceStore.getBoolean("pref_auto_update_manga_sync_key", true) | ||||
|  | ||||
|     companion object { | ||||
|         fun trackUsername(syncId: Long) = "pref_mangasync_username_$syncId" | ||||
|         fun trackUsername(syncId: Long) = Preference.privateKey("pref_mangasync_username_$syncId") | ||||
|  | ||||
|         private fun trackPassword(syncId: Long) = "pref_mangasync_password_$syncId" | ||||
|         private fun trackPassword(syncId: Long) = Preference.privateKey("pref_mangasync_password_$syncId") | ||||
|  | ||||
|         private fun trackToken(syncId: Long) = "track_token_$syncId" | ||||
|         private fun trackToken(syncId: Long) = Preference.privateKey("track_token_$syncId") | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -28,6 +28,8 @@ class UiPreferences( | ||||
|  | ||||
|     fun themeDarkAmoled() = preferenceStore.getBoolean("pref_theme_dark_amoled_key", false) | ||||
|  | ||||
|     fun relativeTime() = preferenceStore.getBoolean("relative_time_v2", true) | ||||
|  | ||||
|     fun dateFormat() = preferenceStore.getString("app_date_format", "") | ||||
|  | ||||
|     fun tabletUiMode() = preferenceStore.getEnum("tablet_ui_mode", TabletUiMode.AUTOMATIC) | ||||
|   | ||||
| @@ -7,13 +7,22 @@ import androidx.compose.foundation.lazy.LazyColumn | ||||
| import androidx.compose.foundation.lazy.LazyListState | ||||
| import androidx.compose.foundation.lazy.itemsIndexed | ||||
| import androidx.compose.foundation.lazy.rememberLazyListState | ||||
| import androidx.compose.material.icons.Icons | ||||
| import androidx.compose.material.icons.outlined.ArrowBack | ||||
| import androidx.compose.material.icons.outlined.SortByAlpha | ||||
| import androidx.compose.material3.Icon | ||||
| import androidx.compose.material3.IconButton | ||||
| import androidx.compose.material3.MaterialTheme | ||||
| import androidx.compose.material3.Text | ||||
| import androidx.compose.material3.TopAppBar | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.ui.Modifier | ||||
| import androidx.compose.ui.res.stringResource | ||||
| import androidx.compose.ui.unit.dp | ||||
| import eu.kanade.presentation.category.components.CategoryFloatingActionButton | ||||
| import eu.kanade.presentation.category.components.CategoryListItem | ||||
| import eu.kanade.presentation.components.AppBar | ||||
| import eu.kanade.presentation.components.AppBarActions | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.ui.category.CategoryScreenState | ||||
| import tachiyomi.domain.category.model.Category | ||||
| @@ -27,6 +36,7 @@ import tachiyomi.presentation.core.util.plus | ||||
| fun CategoryScreen( | ||||
|     state: CategoryScreenState.Success, | ||||
|     onClickCreate: () -> Unit, | ||||
|     onClickSortAlphabetically: () -> Unit, | ||||
|     onClickRename: (Category) -> Unit, | ||||
|     onClickDelete: (Category) -> Unit, | ||||
|     onClickMoveUp: (Category) -> Unit, | ||||
| @@ -36,9 +46,32 @@ fun CategoryScreen( | ||||
|     val lazyListState = rememberLazyListState() | ||||
|     Scaffold( | ||||
|         topBar = { scrollBehavior -> | ||||
|             AppBar( | ||||
|                 title = stringResource(R.string.action_edit_categories), | ||||
|                 navigateUp = navigateUp, | ||||
|             TopAppBar( | ||||
|                 title = { | ||||
|                     Text( | ||||
|                         text = stringResource(R.string.action_edit_categories), | ||||
|                         modifier = Modifier.padding(start = 8.dp), | ||||
|                     ) | ||||
|                 }, | ||||
|                 navigationIcon = { | ||||
|                     IconButton(onClick = navigateUp) { | ||||
|                         Icon( | ||||
|                             imageVector = Icons.Outlined.ArrowBack, | ||||
|                             contentDescription = stringResource(R.string.abc_action_bar_up_description), | ||||
|                         ) | ||||
|                     } | ||||
|                 }, | ||||
|                 actions = { | ||||
|                     AppBarActions( | ||||
|                         listOf( | ||||
|                             AppBar.Action( | ||||
|                                 title = stringResource(R.string.action_sort), | ||||
|                                 icon = Icons.Outlined.SortByAlpha, | ||||
|                                 onClick = onClickSortAlphabetically, | ||||
|                             ), | ||||
|                         ), | ||||
|                     ) | ||||
|                 }, | ||||
|                 scrollBehavior = scrollBehavior, | ||||
|             ) | ||||
|         }, | ||||
|   | ||||
| @@ -162,7 +162,7 @@ fun CategoryDeleteDialog( | ||||
|             TextButton(onClick = { | ||||
|                 onDelete() | ||||
|                 onDismissRequest() | ||||
|             },) { | ||||
|             }) { | ||||
|                 Text(text = stringResource(R.string.action_ok)) | ||||
|             } | ||||
|         }, | ||||
| @@ -180,6 +180,35 @@ fun CategoryDeleteDialog( | ||||
|     ) | ||||
| } | ||||
|  | ||||
| @Composable | ||||
| fun CategorySortAlphabeticallyDialog( | ||||
|     onDismissRequest: () -> Unit, | ||||
|     onSort: () -> Unit, | ||||
| ) { | ||||
|     AlertDialog( | ||||
|         onDismissRequest = onDismissRequest, | ||||
|         confirmButton = { | ||||
|             TextButton(onClick = { | ||||
|                 onSort() | ||||
|                 onDismissRequest() | ||||
|             }) { | ||||
|                 Text(text = stringResource(R.string.action_ok)) | ||||
|             } | ||||
|         }, | ||||
|         dismissButton = { | ||||
|             TextButton(onClick = onDismissRequest) { | ||||
|                 Text(text = stringResource(R.string.action_cancel)) | ||||
|             } | ||||
|         }, | ||||
|         title = { | ||||
|             Text(text = stringResource(R.string.action_sort_category)) | ||||
|         }, | ||||
|         text = { | ||||
|             Text(text = stringResource(R.string.sort_category_confirmation)) | ||||
|         }, | ||||
|     ) | ||||
| } | ||||
|  | ||||
| @Composable | ||||
| fun ChangeCategoryDialog( | ||||
|     initialSelection: List<CheckboxState<Category>>, | ||||
| @@ -217,7 +246,7 @@ fun ChangeCategoryDialog( | ||||
|                 tachiyomi.presentation.core.components.material.TextButton(onClick = { | ||||
|                     onDismissRequest() | ||||
|                     onEditCategories() | ||||
|                 },) { | ||||
|                 }) { | ||||
|                     Text(text = stringResource(R.string.action_edit)) | ||||
|                 } | ||||
|                 Spacer(modifier = Modifier.weight(1f)) | ||||
|   | ||||
| @@ -13,13 +13,18 @@ import java.util.Date | ||||
| fun RelativeDateHeader( | ||||
|     modifier: Modifier = Modifier, | ||||
|     date: Date, | ||||
|     relativeTime: Boolean, | ||||
|     dateFormat: DateFormat, | ||||
| ) { | ||||
|     val context = LocalContext.current | ||||
|     ListGroupHeader( | ||||
|         modifier = modifier, | ||||
|         text = remember { | ||||
|             date.toRelativeString(context, dateFormat) | ||||
|             date.toRelativeString( | ||||
|                 context, | ||||
|                 relativeTime, | ||||
|                 dateFormat, | ||||
|             ) | ||||
|         }, | ||||
|     ) | ||||
| } | ||||
|   | ||||
| @@ -27,7 +27,6 @@ import tachiyomi.presentation.core.screens.EmptyScreen | ||||
| import tachiyomi.presentation.core.screens.LoadingScreen | ||||
| import uy.kohesive.injekt.Injekt | ||||
| import uy.kohesive.injekt.api.get | ||||
| import java.text.DateFormat | ||||
| import java.util.Date | ||||
|  | ||||
| @Composable | ||||
| @@ -98,7 +97,8 @@ private fun HistoryScreenContent( | ||||
|     onClickDelete: (HistoryWithRelations) -> Unit, | ||||
|     preferences: UiPreferences = Injekt.get(), | ||||
| ) { | ||||
|     val dateFormat: DateFormat = remember { UiPreferences.dateFormat(preferences.dateFormat().get()) } | ||||
|     val relativeTime = remember { preferences.relativeTime().get() } | ||||
|     val dateFormat = remember { UiPreferences.dateFormat(preferences.dateFormat().get()) } | ||||
|  | ||||
|     FastScrollLazyColumn( | ||||
|         contentPadding = contentPadding, | ||||
| @@ -118,6 +118,7 @@ private fun HistoryScreenContent( | ||||
|                     RelativeDateHeader( | ||||
|                         modifier = Modifier.animateItemPlacement(), | ||||
|                         date = item.date, | ||||
|                         relativeTime = relativeTime, | ||||
|                         dateFormat = dateFormat, | ||||
|                     ) | ||||
|                 } | ||||
|   | ||||
| @@ -61,7 +61,7 @@ fun HistoryDeleteDialog( | ||||
|             TextButton(onClick = { | ||||
|                 onDelete(removeEverything) | ||||
|                 onDismissRequest() | ||||
|             },) { | ||||
|             }) { | ||||
|                 Text(text = stringResource(R.string.action_remove)) | ||||
|             } | ||||
|         }, | ||||
| @@ -90,7 +90,7 @@ fun HistoryDeleteAllDialog( | ||||
|             TextButton(onClick = { | ||||
|                 onDelete() | ||||
|                 onDismissRequest() | ||||
|             },) { | ||||
|             }) { | ||||
|                 Text(text = stringResource(R.string.action_ok)) | ||||
|             } | ||||
|         }, | ||||
|   | ||||
| @@ -108,13 +108,13 @@ private fun ColumnScope.FilterPage( | ||||
|         onClick = { screenModel.toggleFilter(LibraryPreferences::filterCompleted) }, | ||||
|     ) | ||||
|  | ||||
|     val trackServices = remember { screenModel.trackServices } | ||||
|     when (trackServices.size) { | ||||
|     val trackers = remember { screenModel.trackers } | ||||
|     when (trackers.size) { | ||||
|         0 -> { | ||||
|             // No trackers | ||||
|         } | ||||
|         1 -> { | ||||
|             val service = trackServices[0] | ||||
|             val service = trackers[0] | ||||
|             val filterTracker by screenModel.libraryPreferences.filterTracking(service.id.toInt()).collectAsState() | ||||
|             TriStateItem( | ||||
|                 label = stringResource(R.string.action_filter_tracked), | ||||
| @@ -124,7 +124,7 @@ private fun ColumnScope.FilterPage( | ||||
|         } | ||||
|         else -> { | ||||
|             HeadingItem(R.string.action_filter_tracked) | ||||
|             trackServices.map { service -> | ||||
|             trackers.map { service -> | ||||
|                 val filterTracker by screenModel.libraryPreferences.filterTracking(service.id.toInt()).collectAsState() | ||||
|                 TriStateItem( | ||||
|                     label = service.name, | ||||
|   | ||||
| @@ -85,6 +85,7 @@ fun MangaScreen( | ||||
|     state: MangaScreenModel.State.Success, | ||||
|     snackbarHostState: SnackbarHostState, | ||||
|     fetchInterval: Int?, | ||||
|     dateRelativeTime: Boolean, | ||||
|     dateFormat: DateFormat, | ||||
|     isTabletUi: Boolean, | ||||
|     chapterSwipeStartAction: LibraryPreferences.ChapterSwipeAction, | ||||
| @@ -140,6 +141,7 @@ fun MangaScreen( | ||||
|         MangaScreenSmallImpl( | ||||
|             state = state, | ||||
|             snackbarHostState = snackbarHostState, | ||||
|             dateRelativeTime = dateRelativeTime, | ||||
|             dateFormat = dateFormat, | ||||
|             fetchInterval = fetchInterval, | ||||
|             chapterSwipeStartAction = chapterSwipeStartAction, | ||||
| @@ -176,6 +178,7 @@ fun MangaScreen( | ||||
|         MangaScreenLargeImpl( | ||||
|             state = state, | ||||
|             snackbarHostState = snackbarHostState, | ||||
|             dateRelativeTime = dateRelativeTime, | ||||
|             chapterSwipeStartAction = chapterSwipeStartAction, | ||||
|             chapterSwipeEndAction = chapterSwipeEndAction, | ||||
|             dateFormat = dateFormat, | ||||
| @@ -215,6 +218,7 @@ fun MangaScreen( | ||||
| private fun MangaScreenSmallImpl( | ||||
|     state: MangaScreenModel.State.Success, | ||||
|     snackbarHostState: SnackbarHostState, | ||||
|     dateRelativeTime: Boolean, | ||||
|     dateFormat: DateFormat, | ||||
|     fetchInterval: Int?, | ||||
|     chapterSwipeStartAction: LibraryPreferences.ChapterSwipeAction, | ||||
| @@ -264,8 +268,14 @@ private fun MangaScreenSmallImpl( | ||||
|  | ||||
|     val chapters = remember(state) { state.processedChapters } | ||||
|  | ||||
|     val isAnySelected by remember { | ||||
|         derivedStateOf { | ||||
|             chapters.fastAny { it.selected } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     val internalOnBackPressed = { | ||||
|         if (chapters.fastAny { it.selected }) { | ||||
|         if (isAnySelected) { | ||||
|             onAllChapterSelected(false) | ||||
|         } else { | ||||
|             onBackClicked() | ||||
| @@ -275,19 +285,22 @@ private fun MangaScreenSmallImpl( | ||||
|  | ||||
|     Scaffold( | ||||
|         topBar = { | ||||
|             val firstVisibleItemIndex by remember { | ||||
|                 derivedStateOf { chapterListState.firstVisibleItemIndex } | ||||
|             val selectedChapterCount: Int = remember(chapters) { | ||||
|                 chapters.count { it.selected } | ||||
|             } | ||||
|             val firstVisibleItemScrollOffset by remember { | ||||
|                 derivedStateOf { chapterListState.firstVisibleItemScrollOffset } | ||||
|             val isFirstItemVisible by remember { | ||||
|                 derivedStateOf { chapterListState.firstVisibleItemIndex == 0 } | ||||
|             } | ||||
|             val isFirstItemScrolled by remember { | ||||
|                 derivedStateOf { chapterListState.firstVisibleItemScrollOffset > 0 } | ||||
|             } | ||||
|             val animatedTitleAlpha by animateFloatAsState( | ||||
|                 if (firstVisibleItemIndex > 0) 1f else 0f, | ||||
|                 label = "titleAlpha", | ||||
|                 if (!isFirstItemVisible) 1f else 0f, | ||||
|                 label = "Top Bar Title", | ||||
|             ) | ||||
|             val animatedBgAlpha by animateFloatAsState( | ||||
|                 if (firstVisibleItemIndex > 0 || firstVisibleItemScrollOffset > 0) 1f else 0f, | ||||
|                 label = "bgAlpha", | ||||
|                 if (!isFirstItemVisible || isFirstItemScrolled) 1f else 0f, | ||||
|                 label = "Top Bar Background", | ||||
|             ) | ||||
|             MangaToolbar( | ||||
|                 title = state.manga.title, | ||||
| @@ -301,14 +314,17 @@ private fun MangaScreenSmallImpl( | ||||
|                 onClickEditCategory = onEditCategoryClicked, | ||||
|                 onClickRefresh = onRefresh, | ||||
|                 onClickMigrate = onMigrateClicked, | ||||
|                 actionModeCounter = chapters.count { it.selected }, | ||||
|                 actionModeCounter = selectedChapterCount, | ||||
|                 onSelectAll = { onAllChapterSelected(true) }, | ||||
|                 onInvertSelection = { onInvertSelection() }, | ||||
|             ) | ||||
|         }, | ||||
|         bottomBar = { | ||||
|             val selectedChapters = remember(chapters) { | ||||
|                 chapters.filter { it.selected } | ||||
|             } | ||||
|             SharedMangaBottomActionMenu( | ||||
|                 selected = chapters.filter { it.selected }, | ||||
|                 selected = selectedChapters, | ||||
|                 onMultiBookmarkClicked = onMultiBookmarkClicked, | ||||
|                 onMultiMarkAsReadClicked = onMultiMarkAsReadClicked, | ||||
|                 onMarkPreviousAsReadClicked = onMarkPreviousAsReadClicked, | ||||
| @@ -319,19 +335,20 @@ private fun MangaScreenSmallImpl( | ||||
|         }, | ||||
|         snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, | ||||
|         floatingActionButton = { | ||||
|             val isFABVisible = remember(chapters) { | ||||
|                 chapters.fastAny { !it.chapter.read } && !isAnySelected | ||||
|             } | ||||
|             AnimatedVisibility( | ||||
|                 visible = chapters.fastAny { !it.chapter.read } && chapters.fastAll { !it.selected }, | ||||
|                 visible = isFABVisible, | ||||
|                 enter = fadeIn(), | ||||
|                 exit = fadeOut(), | ||||
|             ) { | ||||
|                 ExtendedFloatingActionButton( | ||||
|                     text = { | ||||
|                         val id = if (state.chapters.fastAny { it.chapter.read }) { | ||||
|                             R.string.action_resume | ||||
|                         } else { | ||||
|                             R.string.action_start | ||||
|                         val isReading = remember(state.chapters) { | ||||
|                             state.chapters.fastAny { it.chapter.read } | ||||
|                         } | ||||
|                         Text(text = stringResource(id)) | ||||
|                         Text(text = stringResource(if (isReading) R.string.action_resume else R.string.action_start)) | ||||
|                     }, | ||||
|                     icon = { Icon(imageVector = Icons.Filled.PlayArrow, contentDescription = null) }, | ||||
|                     onClick = onContinueReading, | ||||
| @@ -345,7 +362,7 @@ private fun MangaScreenSmallImpl( | ||||
|         PullRefresh( | ||||
|             refreshing = state.isRefreshingData, | ||||
|             onRefresh = onRefresh, | ||||
|             enabled = chapters.fastAll { !it.selected }, | ||||
|             enabled = !isAnySelected, | ||||
|             indicatorPadding = WindowInsets.systemBars.only(WindowInsetsSides.Top).asPaddingValues(), | ||||
|         ) { | ||||
|             val layoutDirection = LocalLayoutDirection.current | ||||
| @@ -417,10 +434,13 @@ private fun MangaScreenSmallImpl( | ||||
|                         key = MangaScreenItem.CHAPTER_HEADER, | ||||
|                         contentType = MangaScreenItem.CHAPTER_HEADER, | ||||
|                     ) { | ||||
|                         val missingChapterCount = remember(chapters) { | ||||
|                             chapters.map { it.chapter.chapterNumber }.missingChaptersCount() | ||||
|                         } | ||||
|                         ChapterHeader( | ||||
|                             enabled = chapters.fastAll { !it.selected }, | ||||
|                             enabled = !isAnySelected, | ||||
|                             chapterCount = chapters.size, | ||||
|                             missingChapterCount = chapters.map { it.chapter.chapterNumber }.missingChaptersCount(), | ||||
|                             missingChapterCount = missingChapterCount, | ||||
|                             onClick = onFilterClicked, | ||||
|                         ) | ||||
|                     } | ||||
| @@ -428,6 +448,7 @@ private fun MangaScreenSmallImpl( | ||||
|                     sharedChapterItems( | ||||
|                         manga = state.manga, | ||||
|                         chapters = chapters, | ||||
|                         dateRelativeTime = dateRelativeTime, | ||||
|                         dateFormat = dateFormat, | ||||
|                         chapterSwipeStartAction = chapterSwipeStartAction, | ||||
|                         chapterSwipeEndAction = chapterSwipeEndAction, | ||||
| @@ -446,6 +467,7 @@ private fun MangaScreenSmallImpl( | ||||
| fun MangaScreenLargeImpl( | ||||
|     state: MangaScreenModel.State.Success, | ||||
|     snackbarHostState: SnackbarHostState, | ||||
|     dateRelativeTime: Boolean, | ||||
|     dateFormat: DateFormat, | ||||
|     fetchInterval: Int?, | ||||
|     chapterSwipeStartAction: LibraryPreferences.ChapterSwipeAction, | ||||
| @@ -496,12 +518,18 @@ fun MangaScreenLargeImpl( | ||||
|  | ||||
|     val chapters = remember(state) { state.processedChapters } | ||||
|  | ||||
|     val isAnySelected by remember { | ||||
|         derivedStateOf { | ||||
|             chapters.fastAny { it.selected } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     val insetPadding = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal).asPaddingValues() | ||||
|     var topBarHeight by remember { mutableIntStateOf(0) } | ||||
|     PullRefresh( | ||||
|         refreshing = state.isRefreshingData, | ||||
|         onRefresh = onRefresh, | ||||
|         enabled = chapters.fastAll { !it.selected }, | ||||
|         enabled = !isAnySelected, | ||||
|         indicatorPadding = PaddingValues( | ||||
|             start = insetPadding.calculateStartPadding(layoutDirection), | ||||
|             top = with(density) { topBarHeight.toDp() }, | ||||
| @@ -511,7 +539,7 @@ fun MangaScreenLargeImpl( | ||||
|         val chapterListState = rememberLazyListState() | ||||
|  | ||||
|         val internalOnBackPressed = { | ||||
|             if (chapters.fastAny { it.selected }) { | ||||
|             if (isAnySelected) { | ||||
|                 onAllChapterSelected(false) | ||||
|             } else { | ||||
|                 onBackClicked() | ||||
| @@ -521,10 +549,13 @@ fun MangaScreenLargeImpl( | ||||
|  | ||||
|         Scaffold( | ||||
|             topBar = { | ||||
|                 val selectedChapterCount = remember(chapters) { | ||||
|                     chapters.count { it.selected } | ||||
|                 } | ||||
|                 MangaToolbar( | ||||
|                     modifier = Modifier.onSizeChanged { topBarHeight = it.height }, | ||||
|                     title = state.manga.title, | ||||
|                     titleAlphaProvider = { if (chapters.fastAny { it.selected }) 1f else 0f }, | ||||
|                     titleAlphaProvider = { if (isAnySelected) 1f else 0f }, | ||||
|                     backgroundAlphaProvider = { 1f }, | ||||
|                     hasFilters = state.manga.chaptersFiltered(), | ||||
|                     onBackClicked = internalOnBackPressed, | ||||
| @@ -534,7 +565,7 @@ fun MangaScreenLargeImpl( | ||||
|                     onClickEditCategory = onEditCategoryClicked, | ||||
|                     onClickRefresh = onRefresh, | ||||
|                     onClickMigrate = onMigrateClicked, | ||||
|                     actionModeCounter = chapters.count { it.selected }, | ||||
|                     actionModeCounter = selectedChapterCount, | ||||
|                     onSelectAll = { onAllChapterSelected(true) }, | ||||
|                     onInvertSelection = { onInvertSelection() }, | ||||
|                 ) | ||||
| @@ -544,8 +575,11 @@ fun MangaScreenLargeImpl( | ||||
|                     modifier = Modifier.fillMaxWidth(), | ||||
|                     contentAlignment = Alignment.BottomEnd, | ||||
|                 ) { | ||||
|                     val selectedChapters = remember(chapters) { | ||||
|                         chapters.filter { it.selected } | ||||
|                     } | ||||
|                     SharedMangaBottomActionMenu( | ||||
|                         selected = chapters.filter { it.selected }, | ||||
|                         selected = selectedChapters, | ||||
|                         onMultiBookmarkClicked = onMultiBookmarkClicked, | ||||
|                         onMultiMarkAsReadClicked = onMultiMarkAsReadClicked, | ||||
|                         onMarkPreviousAsReadClicked = onMarkPreviousAsReadClicked, | ||||
| @@ -557,19 +591,20 @@ fun MangaScreenLargeImpl( | ||||
|             }, | ||||
|             snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, | ||||
|             floatingActionButton = { | ||||
|                 val isFABVisible = remember(chapters) { | ||||
|                     chapters.fastAny { !it.chapter.read } && !isAnySelected | ||||
|                 } | ||||
|                 AnimatedVisibility( | ||||
|                     visible = chapters.fastAny { !it.chapter.read } && chapters.fastAll { !it.selected }, | ||||
|                     visible = isFABVisible, | ||||
|                     enter = fadeIn(), | ||||
|                     exit = fadeOut(), | ||||
|                 ) { | ||||
|                     ExtendedFloatingActionButton( | ||||
|                         text = { | ||||
|                             val id = if (state.chapters.fastAny { it.chapter.read }) { | ||||
|                                 R.string.action_resume | ||||
|                             } else { | ||||
|                                 R.string.action_start | ||||
|                             val isReading = remember(state.chapters) { | ||||
|                                 state.chapters.fastAny { it.chapter.read } | ||||
|                             } | ||||
|                             Text(text = stringResource(id)) | ||||
|                             Text(text = stringResource(if (isReading) R.string.action_resume else R.string.action_start)) | ||||
|                         }, | ||||
|                         icon = { Icon(imageVector = Icons.Filled.PlayArrow, contentDescription = null) }, | ||||
|                         onClick = onContinueReading, | ||||
| @@ -640,10 +675,13 @@ fun MangaScreenLargeImpl( | ||||
|                                 key = MangaScreenItem.CHAPTER_HEADER, | ||||
|                                 contentType = MangaScreenItem.CHAPTER_HEADER, | ||||
|                             ) { | ||||
|                                 val missingChapterCount = remember(chapters) { | ||||
|                                     chapters.map { it.chapter.chapterNumber }.missingChaptersCount() | ||||
|                                 } | ||||
|                                 ChapterHeader( | ||||
|                                     enabled = chapters.fastAll { !it.selected }, | ||||
|                                     enabled = !isAnySelected, | ||||
|                                     chapterCount = chapters.size, | ||||
|                                     missingChapterCount = chapters.map { it.chapter.chapterNumber }.missingChaptersCount(), | ||||
|                                     missingChapterCount = missingChapterCount, | ||||
|                                     onClick = onFilterButtonClicked, | ||||
|                                 ) | ||||
|                             } | ||||
| @@ -651,6 +689,7 @@ fun MangaScreenLargeImpl( | ||||
|                             sharedChapterItems( | ||||
|                                 manga = state.manga, | ||||
|                                 chapters = chapters, | ||||
|                                 dateRelativeTime = dateRelativeTime, | ||||
|                                 dateFormat = dateFormat, | ||||
|                                 chapterSwipeStartAction = chapterSwipeStartAction, | ||||
|                                 chapterSwipeEndAction = chapterSwipeEndAction, | ||||
| @@ -712,6 +751,7 @@ private fun SharedMangaBottomActionMenu( | ||||
| private fun LazyListScope.sharedChapterItems( | ||||
|     manga: Manga, | ||||
|     chapters: List<ChapterItem>, | ||||
|     dateRelativeTime: Boolean, | ||||
|     dateFormat: DateFormat, | ||||
|     chapterSwipeStartAction: LibraryPreferences.ChapterSwipeAction, | ||||
|     chapterSwipeEndAction: LibraryPreferences.ChapterSwipeAction, | ||||
| @@ -740,7 +780,11 @@ private fun LazyListScope.sharedChapterItems( | ||||
|             date = chapterItem.chapter.dateUpload | ||||
|                 .takeIf { it > 0L } | ||||
|                 ?.let { | ||||
|                     Date(it).toRelativeString(context, dateFormat) | ||||
|                     Date(it).toRelativeString( | ||||
|                         context, | ||||
|                         dateRelativeTime, | ||||
|                         dateFormat, | ||||
|                     ) | ||||
|                 }, | ||||
|             readProgress = chapterItem.chapter.lastPageRead | ||||
|                 .takeIf { !chapterItem.chapter.read && it > 0L } | ||||
|   | ||||
| @@ -143,7 +143,7 @@ fun MangaBottomActionMenu( | ||||
|                 if (onMarkPreviousAsReadClicked != null) { | ||||
|                     Button( | ||||
|                         title = stringResource(R.string.action_mark_previous_as_read), | ||||
|                         icon = ImageVector.vectorResource(id = R.drawable.ic_done_prev_24dp), | ||||
|                         icon = ImageVector.vectorResource(R.drawable.ic_done_prev_24dp), | ||||
|                         toConfirm = confirm[4], | ||||
|                         onLongClick = { onLongClickItem(4) }, | ||||
|                         onClick = onMarkPreviousAsReadClicked, | ||||
|   | ||||
| @@ -16,7 +16,7 @@ import androidx.compose.ui.res.stringResource | ||||
| import androidx.compose.ui.unit.DpSize | ||||
| import androidx.compose.ui.unit.dp | ||||
| import eu.kanade.tachiyomi.R | ||||
| import tachiyomi.domain.manga.interactor.MAX_FETCH_INTERVAL | ||||
| import tachiyomi.domain.manga.interactor.FetchInterval | ||||
| import tachiyomi.presentation.core.components.WheelTextPicker | ||||
|  | ||||
| @Composable | ||||
| @@ -67,7 +67,7 @@ fun SetIntervalDialog( | ||||
|                 contentAlignment = Alignment.Center, | ||||
|             ) { | ||||
|                 val size = DpSize(width = maxWidth / 2, height = 128.dp) | ||||
|                 val items = (0..MAX_FETCH_INTERVAL).map { | ||||
|                 val items = (0..FetchInterval.MAX_INTERVAL).map { | ||||
|                     if (it == 0) { | ||||
|                         stringResource(R.string.label_default) | ||||
|                     } else { | ||||
| @@ -91,7 +91,7 @@ fun SetIntervalDialog( | ||||
|             TextButton(onClick = { | ||||
|                 onValueChanged(selectedInterval) | ||||
|                 onDismissRequest() | ||||
|             },) { | ||||
|             }) { | ||||
|                 Text(text = stringResource(R.string.action_ok)) | ||||
|             } | ||||
|         }, | ||||
|   | ||||
| @@ -286,7 +286,7 @@ fun ExpandableMangaDescription( | ||||
|                     ) { | ||||
|                         tags.forEach { | ||||
|                             TagsChip( | ||||
|                                 modifier = Modifier.padding(vertical = 4.dp), | ||||
|                                 modifier = DefaultTagChipModifier, | ||||
|                                 text = it, | ||||
|                                 onClick = { | ||||
|                                     tagSelected = it | ||||
| @@ -302,7 +302,7 @@ fun ExpandableMangaDescription( | ||||
|                     ) { | ||||
|                         items(items = tags) { | ||||
|                             TagsChip( | ||||
|                                 modifier = Modifier.padding(vertical = 4.dp), | ||||
|                                 modifier = DefaultTagChipModifier, | ||||
|                                 text = it, | ||||
|                                 onClick = { | ||||
|                                     tagSelected = it | ||||
| @@ -654,6 +654,8 @@ private fun MangaSummary( | ||||
|     } | ||||
| } | ||||
|  | ||||
| private val DefaultTagChipModifier = Modifier.padding(vertical = 4.dp) | ||||
|  | ||||
| @Composable | ||||
| private fun TagsChip( | ||||
|     text: String, | ||||
|   | ||||
| @@ -62,7 +62,7 @@ fun MoreScreen( | ||||
|                     WarningBanner( | ||||
|                         textRes = R.string.fdroid_warning, | ||||
|                         modifier = Modifier.clickable { | ||||
|                             uriHandler.openUri("https://tachiyomi.org/help/faq/#how-do-i-migrate-from-the-f-droid-version") | ||||
|                             uriHandler.openUri("https://tachiyomi.org/docs/faq/general#how-do-i-update-from-the-f-droid-builds") | ||||
|                         }, | ||||
|                     ) | ||||
|                 } | ||||
| @@ -108,11 +108,11 @@ fun MoreScreen( | ||||
|                                 stringResource(R.string.paused) | ||||
|                             } else { | ||||
|                                 "${stringResource(R.string.paused)} • ${ | ||||
|                                 pluralStringResource( | ||||
|                                     id = R.plurals.download_queue_summary, | ||||
|                                     count = pending, | ||||
|                                     pending, | ||||
|                                 ) | ||||
|                                     pluralStringResource( | ||||
|                                         id = R.plurals.download_queue_summary, | ||||
|                                         count = pending, | ||||
|                                         pending, | ||||
|                                     ) | ||||
|                                 }" | ||||
|                             } | ||||
|                         } | ||||
|   | ||||
| @@ -4,9 +4,8 @@ import androidx.compose.runtime.Composable | ||||
| import androidx.compose.runtime.remember | ||||
| import androidx.compose.ui.graphics.vector.ImageVector | ||||
| import androidx.compose.ui.res.stringResource | ||||
| import eu.kanade.presentation.more.settings.Preference.PreferenceItem | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.track.TrackService | ||||
| import eu.kanade.tachiyomi.data.track.Tracker | ||||
| import tachiyomi.core.preference.Preference as PreferenceData | ||||
| 
 | ||||
| sealed class Preference { | ||||
| @@ -133,10 +132,10 @@ sealed class Preference { | ||||
|         ) : PreferenceItem<String>() | ||||
| 
 | ||||
|         /** | ||||
|          * A [PreferenceItem] for individual tracking service. | ||||
|          * A [PreferenceItem] for individual tracker. | ||||
|          */ | ||||
|         data class TrackingPreference( | ||||
|             val service: TrackService, | ||||
|         data class TrackerPreference( | ||||
|             val tracker: Tracker, | ||||
|             override val title: String, | ||||
|             val login: () -> Unit, | ||||
|             val logout: () -> Unit, | ||||
| @@ -156,13 +156,13 @@ internal fun PreferenceItem( | ||||
|                     }, | ||||
|                 ) | ||||
|             } | ||||
|             is Preference.PreferenceItem.TrackingPreference -> { | ||||
|             is Preference.PreferenceItem.TrackerPreference -> { | ||||
|                 val uName by Injekt.get<PreferenceStore>() | ||||
|                     .getString(TrackPreferences.trackUsername(item.service.id)) | ||||
|                     .getString(TrackPreferences.trackUsername(item.tracker.id)) | ||||
|                     .collectAsState() | ||||
|                 item.service.run { | ||||
|                 item.tracker.run { | ||||
|                     TrackingPreferenceWidget( | ||||
|                         service = this, | ||||
|                         tracker = this, | ||||
|                         checked = uName.isNotEmpty(), | ||||
|                         onClick = { if (isLoggedIn) item.logout() else item.login() }, | ||||
|                     ) | ||||
|   | ||||
| @@ -34,7 +34,7 @@ import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.cache.ChapterCache | ||||
| import eu.kanade.tachiyomi.data.download.DownloadCache | ||||
| import eu.kanade.tachiyomi.data.library.LibraryUpdateJob | ||||
| import eu.kanade.tachiyomi.data.track.TrackManager | ||||
| import eu.kanade.tachiyomi.data.track.TrackerManager | ||||
| import eu.kanade.tachiyomi.network.NetworkHelper | ||||
| import eu.kanade.tachiyomi.network.NetworkPreferences | ||||
| import eu.kanade.tachiyomi.network.PREF_DOH_360 | ||||
| @@ -328,7 +328,7 @@ object SettingsAdvancedScreen : SearchableSettings { | ||||
|     private fun getLibraryGroup(): Preference.PreferenceGroup { | ||||
|         val scope = rememberCoroutineScope() | ||||
|         val context = LocalContext.current | ||||
|         val trackManager = remember { Injekt.get<TrackManager>() } | ||||
|         val trackerManager = remember { Injekt.get<TrackerManager>() } | ||||
|  | ||||
|         return Preference.PreferenceGroup( | ||||
|             title = stringResource(R.string.label_library), | ||||
| @@ -340,7 +340,7 @@ object SettingsAdvancedScreen : SearchableSettings { | ||||
|                 Preference.PreferenceItem.TextPreference( | ||||
|                     title = stringResource(R.string.pref_refresh_library_tracking), | ||||
|                     subtitle = stringResource(R.string.pref_refresh_library_tracking_summary), | ||||
|                     enabled = trackManager.hasLoggedServices(), | ||||
|                     enabled = trackerManager.hasLoggedIn(), | ||||
|                     onClick = { LibraryUpdateJob.startNow(context, target = LibraryUpdateJob.Target.TRACKING) }, | ||||
|                 ), | ||||
|                 Preference.PreferenceItem.TextPreference( | ||||
|   | ||||
| @@ -123,6 +123,11 @@ object SettingsAppearanceScreen : SearchableSettings { | ||||
|         var currentLanguage by remember { mutableStateOf(AppCompatDelegate.getApplicationLocales().get(0)?.toLanguageTag() ?: "") } | ||||
|         val now = remember { Date().time } | ||||
|  | ||||
|         val dateFormat by uiPreferences.dateFormat().collectAsState() | ||||
|         val formattedNow = remember(dateFormat) { | ||||
|             UiPreferences.dateFormat(dateFormat).format(now) | ||||
|         } | ||||
|  | ||||
|         LaunchedEffect(currentLanguage) { | ||||
|             val locale = if (currentLanguage.isEmpty()) { | ||||
|                 LocaleListCompat.getEmptyLocaleList() | ||||
| @@ -162,6 +167,15 @@ object SettingsAppearanceScreen : SearchableSettings { | ||||
|                             "${it.ifEmpty { stringResource(R.string.label_default) }} ($formattedDate)" | ||||
|                         }, | ||||
|                 ), | ||||
|                 Preference.PreferenceItem.SwitchPreference( | ||||
|                     pref = uiPreferences.relativeTime(), | ||||
|                     title = stringResource(R.string.pref_relative_format), | ||||
|                     subtitle = stringResource( | ||||
|                         R.string.pref_relative_format_summary, | ||||
|                         stringResource(R.string.relative_time_today), | ||||
|                         formattedNow, | ||||
|                     ), | ||||
|                 ), | ||||
|             ), | ||||
|         ) | ||||
|     } | ||||
|   | ||||
| @@ -211,7 +211,7 @@ object SettingsBackupAndSyncScreen : SearchableSettings { | ||||
|                     showCreateDialog = false | ||||
|                     flag = it | ||||
|                     try { | ||||
|                         chooseBackupDir.launch(Backup.getBackupFilename()) | ||||
|                         chooseBackupDir.launch(Backup.getFilename()) | ||||
|                     } catch (e: ActivityNotFoundException) { | ||||
|                         flag = 0 | ||||
|                         context.toast(R.string.file_picker_error) | ||||
| @@ -250,6 +250,8 @@ object SettingsBackupAndSyncScreen : SearchableSettings { | ||||
|                 BackupConst.BACKUP_CHAPTER to R.string.chapters, | ||||
|                 BackupConst.BACKUP_TRACK to R.string.track, | ||||
|                 BackupConst.BACKUP_HISTORY to R.string.history, | ||||
|                 BackupConst.BACKUP_APP_PREFS to R.string.app_settings, | ||||
|                 BackupConst.BACKUP_SOURCE_PREFS to R.string.source_settings, | ||||
|             ) | ||||
|         } | ||||
|         val flags = remember { choices.keys.toMutableStateList() } | ||||
|   | ||||
| @@ -23,7 +23,7 @@ import eu.kanade.presentation.more.settings.Preference | ||||
| import eu.kanade.presentation.more.settings.widget.TriStateListDialog | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.library.LibraryUpdateJob | ||||
| import eu.kanade.tachiyomi.data.track.TrackManager | ||||
| import eu.kanade.tachiyomi.data.track.TrackerManager | ||||
| import eu.kanade.tachiyomi.ui.category.CategoryScreen | ||||
| import kotlinx.coroutines.launch | ||||
| import kotlinx.coroutines.runBlocking | ||||
| @@ -199,7 +199,7 @@ object SettingsLibraryScreen : SearchableSettings { | ||||
|                 ), | ||||
|                 Preference.PreferenceItem.SwitchPreference( | ||||
|                     pref = libraryPreferences.autoUpdateTrackers(), | ||||
|                     enabled = Injekt.get<TrackManager>().hasLoggedServices(), | ||||
|                     enabled = Injekt.get<TrackerManager>().hasLoggedIn(), | ||||
|                     title = stringResource(R.string.pref_library_update_refresh_trackers), | ||||
|                     subtitle = stringResource(R.string.pref_library_update_refresh_trackers_summary), | ||||
|                 ), | ||||
|   | ||||
| @@ -13,7 +13,6 @@ import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.ui.reader.setting.OrientationType | ||||
| import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences | ||||
| import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType | ||||
| import eu.kanade.tachiyomi.util.system.isReleaseBuildType | ||||
| import tachiyomi.presentation.core.util.collectAsState | ||||
| import uy.kohesive.injekt.Injekt | ||||
| import uy.kohesive.injekt.api.get | ||||
| @@ -305,12 +304,6 @@ object SettingsReaderScreen : SearchableSettings { | ||||
|                     subtitle = stringResource(R.string.pref_dual_page_invert_summary), | ||||
|                     enabled = dualPageSplit, | ||||
|                 ), | ||||
|                 Preference.PreferenceItem.SwitchPreference( | ||||
|                     pref = readerPreferences.longStripSplitWebtoon(), | ||||
|                     title = stringResource(R.string.pref_long_strip_split), | ||||
|                     subtitle = stringResource(R.string.split_tall_images_summary), | ||||
|                     enabled = !isReleaseBuildType, // TODO: Show in release build when the feature is stable | ||||
|                 ), | ||||
|                 Preference.PreferenceItem.SwitchPreference( | ||||
|                     pref = readerPreferences.webtoonDoubleTapZoomEnabled(), | ||||
|                     title = stringResource(R.string.pref_double_tap_zoom), | ||||
| @@ -349,11 +342,6 @@ object SettingsReaderScreen : SearchableSettings { | ||||
|                     pref = readerPreferences.readWithLongTap(), | ||||
|                     title = stringResource(R.string.pref_read_with_long_tap), | ||||
|                 ), | ||||
|                 Preference.PreferenceItem.SwitchPreference( | ||||
|                     pref = readerPreferences.folderPerManga(), | ||||
|                     title = stringResource(R.string.pref_create_folder_per_manga), | ||||
|                     subtitle = stringResource(R.string.pref_create_folder_per_manga_summary), | ||||
|                 ), | ||||
|             ), | ||||
|         ) | ||||
|     } | ||||
|   | ||||
| @@ -44,9 +44,9 @@ import androidx.compose.ui.unit.dp | ||||
| import eu.kanade.domain.track.service.TrackPreferences | ||||
| import eu.kanade.presentation.more.settings.Preference | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.track.EnhancedTrackService | ||||
| import eu.kanade.tachiyomi.data.track.TrackManager | ||||
| import eu.kanade.tachiyomi.data.track.TrackService | ||||
| import eu.kanade.tachiyomi.data.track.EnhancedTracker | ||||
| import eu.kanade.tachiyomi.data.track.Tracker | ||||
| import eu.kanade.tachiyomi.data.track.TrackerManager | ||||
| import eu.kanade.tachiyomi.data.track.anilist.AnilistApi | ||||
| import eu.kanade.tachiyomi.data.track.bangumi.BangumiApi | ||||
| import eu.kanade.tachiyomi.data.track.myanimelist.MyAnimeListApi | ||||
| @@ -70,7 +70,7 @@ object SettingsTrackingScreen : SearchableSettings { | ||||
|     @Composable | ||||
|     override fun RowScope.AppBarAction() { | ||||
|         val uriHandler = LocalUriHandler.current | ||||
|         IconButton(onClick = { uriHandler.openUri("https://tachiyomi.org/help/guides/tracking/") }) { | ||||
|         IconButton(onClick = { uriHandler.openUri("https://tachiyomi.org/docs/guides/tracking") }) { | ||||
|             Icon( | ||||
|                 imageVector = Icons.Outlined.HelpOutline, | ||||
|                 contentDescription = stringResource(R.string.tracking_guide), | ||||
| @@ -82,7 +82,7 @@ object SettingsTrackingScreen : SearchableSettings { | ||||
|     override fun getPreferences(): List<Preference> { | ||||
|         val context = LocalContext.current | ||||
|         val trackPreferences = remember { Injekt.get<TrackPreferences>() } | ||||
|         val trackManager = remember { Injekt.get<TrackManager>() } | ||||
|         val trackerManager = remember { Injekt.get<TrackerManager>() } | ||||
|         val sourceManager = remember { Injekt.get<SourceManager>() } | ||||
|  | ||||
|         var dialog by remember { mutableStateOf<Any?>(null) } | ||||
| @@ -90,24 +90,24 @@ object SettingsTrackingScreen : SearchableSettings { | ||||
|             when (this) { | ||||
|                 is LoginDialog -> { | ||||
|                     TrackingLoginDialog( | ||||
|                         service = service, | ||||
|                         tracker = tracker, | ||||
|                         uNameStringRes = uNameStringRes, | ||||
|                         onDismissRequest = { dialog = null }, | ||||
|                     ) | ||||
|                 } | ||||
|                 is LogoutDialog -> { | ||||
|                     TrackingLogoutDialog( | ||||
|                         service = service, | ||||
|                         tracker = tracker, | ||||
|                         onDismissRequest = { dialog = null }, | ||||
|                     ) | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         val enhancedTrackers = trackManager.services | ||||
|             .filter { it is EnhancedTrackService } | ||||
|         val enhancedTrackers = trackerManager.trackers | ||||
|             .filter { it is EnhancedTracker } | ||||
|             .partition { service -> | ||||
|                 val acceptedSources = (service as EnhancedTrackService).getAcceptedSources() | ||||
|                 val acceptedSources = (service as EnhancedTracker).getAcceptedSources() | ||||
|                 sourceManager.getCatalogueSources().any { it::class.qualifiedName in acceptedSources } | ||||
|             } | ||||
|         var enhancedTrackerInfo = stringResource(R.string.enhanced_tracking_info) | ||||
| @@ -127,41 +127,41 @@ object SettingsTrackingScreen : SearchableSettings { | ||||
|             Preference.PreferenceGroup( | ||||
|                 title = stringResource(R.string.services), | ||||
|                 preferenceItems = listOf( | ||||
|                     Preference.PreferenceItem.TrackingPreference( | ||||
|                         title = trackManager.myAnimeList.name, | ||||
|                         service = trackManager.myAnimeList, | ||||
|                     Preference.PreferenceItem.TrackerPreference( | ||||
|                         title = trackerManager.myAnimeList.name, | ||||
|                         tracker = trackerManager.myAnimeList, | ||||
|                         login = { context.openInBrowser(MyAnimeListApi.authUrl(), forceDefaultBrowser = true) }, | ||||
|                         logout = { dialog = LogoutDialog(trackManager.myAnimeList) }, | ||||
|                         logout = { dialog = LogoutDialog(trackerManager.myAnimeList) }, | ||||
|                     ), | ||||
|                     Preference.PreferenceItem.TrackingPreference( | ||||
|                         title = trackManager.aniList.name, | ||||
|                         service = trackManager.aniList, | ||||
|                     Preference.PreferenceItem.TrackerPreference( | ||||
|                         title = trackerManager.aniList.name, | ||||
|                         tracker = trackerManager.aniList, | ||||
|                         login = { context.openInBrowser(AnilistApi.authUrl(), forceDefaultBrowser = true) }, | ||||
|                         logout = { dialog = LogoutDialog(trackManager.aniList) }, | ||||
|                         logout = { dialog = LogoutDialog(trackerManager.aniList) }, | ||||
|                     ), | ||||
|                     Preference.PreferenceItem.TrackingPreference( | ||||
|                         title = trackManager.kitsu.name, | ||||
|                         service = trackManager.kitsu, | ||||
|                         login = { dialog = LoginDialog(trackManager.kitsu, R.string.email) }, | ||||
|                         logout = { dialog = LogoutDialog(trackManager.kitsu) }, | ||||
|                     Preference.PreferenceItem.TrackerPreference( | ||||
|                         title = trackerManager.kitsu.name, | ||||
|                         tracker = trackerManager.kitsu, | ||||
|                         login = { dialog = LoginDialog(trackerManager.kitsu, R.string.email) }, | ||||
|                         logout = { dialog = LogoutDialog(trackerManager.kitsu) }, | ||||
|                     ), | ||||
|                     Preference.PreferenceItem.TrackingPreference( | ||||
|                         title = trackManager.mangaUpdates.name, | ||||
|                         service = trackManager.mangaUpdates, | ||||
|                         login = { dialog = LoginDialog(trackManager.mangaUpdates, R.string.username) }, | ||||
|                         logout = { dialog = LogoutDialog(trackManager.mangaUpdates) }, | ||||
|                     Preference.PreferenceItem.TrackerPreference( | ||||
|                         title = trackerManager.mangaUpdates.name, | ||||
|                         tracker = trackerManager.mangaUpdates, | ||||
|                         login = { dialog = LoginDialog(trackerManager.mangaUpdates, R.string.username) }, | ||||
|                         logout = { dialog = LogoutDialog(trackerManager.mangaUpdates) }, | ||||
|                     ), | ||||
|                     Preference.PreferenceItem.TrackingPreference( | ||||
|                         title = trackManager.shikimori.name, | ||||
|                         service = trackManager.shikimori, | ||||
|                     Preference.PreferenceItem.TrackerPreference( | ||||
|                         title = trackerManager.shikimori.name, | ||||
|                         tracker = trackerManager.shikimori, | ||||
|                         login = { context.openInBrowser(ShikimoriApi.authUrl(), forceDefaultBrowser = true) }, | ||||
|                         logout = { dialog = LogoutDialog(trackManager.shikimori) }, | ||||
|                         logout = { dialog = LogoutDialog(trackerManager.shikimori) }, | ||||
|                     ), | ||||
|                     Preference.PreferenceItem.TrackingPreference( | ||||
|                         title = trackManager.bangumi.name, | ||||
|                         service = trackManager.bangumi, | ||||
|                     Preference.PreferenceItem.TrackerPreference( | ||||
|                         title = trackerManager.bangumi.name, | ||||
|                         tracker = trackerManager.bangumi, | ||||
|                         login = { context.openInBrowser(BangumiApi.authUrl(), forceDefaultBrowser = true) }, | ||||
|                         logout = { dialog = LogoutDialog(trackManager.bangumi) }, | ||||
|                         logout = { dialog = LogoutDialog(trackerManager.bangumi) }, | ||||
|                     ), | ||||
|                     Preference.PreferenceItem.InfoPreference(stringResource(R.string.tracking_info)), | ||||
|                 ), | ||||
| @@ -170,10 +170,10 @@ object SettingsTrackingScreen : SearchableSettings { | ||||
|                 title = stringResource(R.string.enhanced_services), | ||||
|                 preferenceItems = enhancedTrackers.first | ||||
|                     .map { service -> | ||||
|                         Preference.PreferenceItem.TrackingPreference( | ||||
|                         Preference.PreferenceItem.TrackerPreference( | ||||
|                             title = service.name, | ||||
|                             service = service, | ||||
|                             login = { (service as EnhancedTrackService).loginNoop() }, | ||||
|                             tracker = service, | ||||
|                             login = { (service as EnhancedTracker).loginNoop() }, | ||||
|                             logout = service::logout, | ||||
|                         ) | ||||
|                     } + listOf(Preference.PreferenceItem.InfoPreference(enhancedTrackerInfo)), | ||||
| @@ -183,15 +183,15 @@ object SettingsTrackingScreen : SearchableSettings { | ||||
|  | ||||
|     @Composable | ||||
|     private fun TrackingLoginDialog( | ||||
|         service: TrackService, | ||||
|         tracker: Tracker, | ||||
|         @StringRes uNameStringRes: Int, | ||||
|         onDismissRequest: () -> Unit, | ||||
|     ) { | ||||
|         val context = LocalContext.current | ||||
|         val scope = rememberCoroutineScope() | ||||
|  | ||||
|         var username by remember { mutableStateOf(TextFieldValue(service.getUsername())) } | ||||
|         var password by remember { mutableStateOf(TextFieldValue(service.getPassword())) } | ||||
|         var username by remember { mutableStateOf(TextFieldValue(tracker.getUsername())) } | ||||
|         var password by remember { mutableStateOf(TextFieldValue(tracker.getPassword())) } | ||||
|         var processing by remember { mutableStateOf(false) } | ||||
|         var inputError by remember { mutableStateOf(false) } | ||||
|  | ||||
| @@ -200,7 +200,7 @@ object SettingsTrackingScreen : SearchableSettings { | ||||
|             title = { | ||||
|                 Row(verticalAlignment = Alignment.CenterVertically) { | ||||
|                     Text( | ||||
|                         text = stringResource(R.string.login_title, service.name), | ||||
|                         text = stringResource(R.string.login_title, tracker.name), | ||||
|                         modifier = Modifier.weight(1f), | ||||
|                     ) | ||||
|                     IconButton(onClick = onDismissRequest) { | ||||
| @@ -264,7 +264,7 @@ object SettingsTrackingScreen : SearchableSettings { | ||||
|                             processing = true | ||||
|                             val result = checkLogin( | ||||
|                                 context = context, | ||||
|                                 service = service, | ||||
|                                 tracker = tracker, | ||||
|                                 username = username.text, | ||||
|                                 password = password.text, | ||||
|                             ) | ||||
| @@ -283,16 +283,16 @@ object SettingsTrackingScreen : SearchableSettings { | ||||
|  | ||||
|     private suspend fun checkLogin( | ||||
|         context: Context, | ||||
|         service: TrackService, | ||||
|         tracker: Tracker, | ||||
|         username: String, | ||||
|         password: String, | ||||
|     ): Boolean { | ||||
|         return try { | ||||
|             service.login(username, password) | ||||
|             tracker.login(username, password) | ||||
|             withUIContext { context.toast(R.string.login_success) } | ||||
|             true | ||||
|         } catch (e: Throwable) { | ||||
|             service.logout() | ||||
|             tracker.logout() | ||||
|             withUIContext { context.toast(e.message.toString()) } | ||||
|             false | ||||
|         } | ||||
| @@ -300,7 +300,7 @@ object SettingsTrackingScreen : SearchableSettings { | ||||
|  | ||||
|     @Composable | ||||
|     private fun TrackingLogoutDialog( | ||||
|         service: TrackService, | ||||
|         tracker: Tracker, | ||||
|         onDismissRequest: () -> Unit, | ||||
|     ) { | ||||
|         val context = LocalContext.current | ||||
| @@ -308,7 +308,7 @@ object SettingsTrackingScreen : SearchableSettings { | ||||
|             onDismissRequest = onDismissRequest, | ||||
|             title = { | ||||
|                 Text( | ||||
|                     text = stringResource(R.string.logout_title, service.name), | ||||
|                     text = stringResource(R.string.logout_title, tracker.name), | ||||
|                     textAlign = TextAlign.Center, | ||||
|                     modifier = Modifier.fillMaxWidth(), | ||||
|                 ) | ||||
| @@ -324,7 +324,7 @@ object SettingsTrackingScreen : SearchableSettings { | ||||
|                     Button( | ||||
|                         modifier = Modifier.weight(1f), | ||||
|                         onClick = { | ||||
|                             service.logout() | ||||
|                             tracker.logout() | ||||
|                             onDismissRequest() | ||||
|                             context.toast(R.string.logout_success) | ||||
|                         }, | ||||
| @@ -342,10 +342,10 @@ object SettingsTrackingScreen : SearchableSettings { | ||||
| } | ||||
|  | ||||
| private data class LoginDialog( | ||||
|     val service: TrackService, | ||||
|     val tracker: Tracker, | ||||
|     @StringRes val uNameStringRes: Int, | ||||
| ) | ||||
|  | ||||
| private data class LogoutDialog( | ||||
|     val service: TrackService, | ||||
|     val tracker: Tracker, | ||||
| ) | ||||
|   | ||||
| @@ -23,12 +23,6 @@ import androidx.compose.ui.res.stringResource | ||||
| import androidx.compose.ui.unit.dp | ||||
| import cafe.adriel.voyager.navigator.LocalNavigator | ||||
| import cafe.adriel.voyager.navigator.currentOrThrow | ||||
| import compose.icons.SimpleIcons | ||||
| import compose.icons.simpleicons.Discord | ||||
| import compose.icons.simpleicons.Facebook | ||||
| import compose.icons.simpleicons.Github | ||||
| import compose.icons.simpleicons.Reddit | ||||
| import compose.icons.simpleicons.Twitter | ||||
| import eu.kanade.domain.ui.UiPreferences | ||||
| import eu.kanade.presentation.components.AppBar | ||||
| import eu.kanade.presentation.more.LogoHeader | ||||
| @@ -53,6 +47,12 @@ import tachiyomi.domain.release.interactor.GetApplicationRelease | ||||
| import tachiyomi.presentation.core.components.LinkIcon | ||||
| import tachiyomi.presentation.core.components.ScrollbarLazyColumn | ||||
| import tachiyomi.presentation.core.components.material.Scaffold | ||||
| import tachiyomi.presentation.core.icons.CustomIcons | ||||
| import tachiyomi.presentation.core.icons.Discord | ||||
| import tachiyomi.presentation.core.icons.Facebook | ||||
| import tachiyomi.presentation.core.icons.Github | ||||
| import tachiyomi.presentation.core.icons.Reddit | ||||
| import tachiyomi.presentation.core.icons.X | ||||
| import uy.kohesive.injekt.Injekt | ||||
| import uy.kohesive.injekt.api.get | ||||
| import java.text.DateFormat | ||||
| @@ -149,7 +149,7 @@ object AboutScreen : Screen() { | ||||
|                 item { | ||||
|                     TextPreferenceWidget( | ||||
|                         title = stringResource(R.string.help_translate), | ||||
|                         onPreferenceClick = { uriHandler.openUri("https://tachiyomi.org/help/contribution/#translation") }, | ||||
|                         onPreferenceClick = { uriHandler.openUri("https://tachiyomi.org/docs/contribute#translation") }, | ||||
|                     ) | ||||
|                 } | ||||
|  | ||||
| @@ -163,7 +163,7 @@ object AboutScreen : Screen() { | ||||
|                 item { | ||||
|                     TextPreferenceWidget( | ||||
|                         title = stringResource(R.string.privacy_policy), | ||||
|                         onPreferenceClick = { uriHandler.openUri("https://tachiyomi.org/privacy") }, | ||||
|                         onPreferenceClick = { uriHandler.openUri("https://tachiyomi.org/privacy/") }, | ||||
|                     ) | ||||
|                 } | ||||
|  | ||||
| @@ -181,27 +181,27 @@ object AboutScreen : Screen() { | ||||
|                         ) | ||||
|                         LinkIcon( | ||||
|                             label = "Discord", | ||||
|                             icon = SimpleIcons.Discord, | ||||
|                             icon = CustomIcons.Discord, | ||||
|                             url = "https://discord.gg/tachiyomi", | ||||
|                         ) | ||||
|                         LinkIcon( | ||||
|                             label = "Twitter", | ||||
|                             icon = SimpleIcons.Twitter, | ||||
|                             url = "https://twitter.com/tachiyomiorg", | ||||
|                             label = "X", | ||||
|                             icon = CustomIcons.X, | ||||
|                             url = "https://x.com/tachiyomiorg", | ||||
|                         ) | ||||
|                         LinkIcon( | ||||
|                             label = "Facebook", | ||||
|                             icon = SimpleIcons.Facebook, | ||||
|                             icon = CustomIcons.Facebook, | ||||
|                             url = "https://facebook.com/tachiyomiorg", | ||||
|                         ) | ||||
|                         LinkIcon( | ||||
|                             label = "Reddit", | ||||
|                             icon = SimpleIcons.Reddit, | ||||
|                             icon = CustomIcons.Reddit, | ||||
|                             url = "https://www.reddit.com/r/Tachiyomi", | ||||
|                         ) | ||||
|                         LinkIcon( | ||||
|                             label = "GitHub", | ||||
|                             icon = SimpleIcons.Github, | ||||
|                             icon = CustomIcons.Github, | ||||
|                             url = "https://github.com/tachiyomiorg", | ||||
|                         ) | ||||
|                     } | ||||
|   | ||||
| @@ -41,9 +41,9 @@ class OpenSourceLicensesScreen : Screen() { | ||||
|                 ), | ||||
|                 onLibraryClick = { | ||||
|                     val libraryLicenseScreen = OpenSourceLibraryLicenseScreen( | ||||
|                         name = it.name, | ||||
|                         website = it.website, | ||||
|                         license = it.licenses.firstOrNull()?.htmlReadyLicenseContent.orEmpty(), | ||||
|                         name = it.library.name, | ||||
|                         website = it.library.website, | ||||
|                         license = it.library.licenses.firstOrNull()?.htmlReadyLicenseContent.orEmpty(), | ||||
|                     ) | ||||
|                     navigator.push(libraryLicenseScreen) | ||||
|                 }, | ||||
|   | ||||
| @@ -20,12 +20,12 @@ import androidx.compose.ui.unit.dp | ||||
| import eu.kanade.presentation.more.settings.LocalPreferenceHighlighted | ||||
| import eu.kanade.presentation.track.components.TrackLogoIcon | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.track.TrackService | ||||
| import eu.kanade.tachiyomi.data.track.Tracker | ||||
|  | ||||
| @Composable | ||||
| fun TrackingPreferenceWidget( | ||||
|     modifier: Modifier = Modifier, | ||||
|     service: TrackService, | ||||
|     tracker: Tracker, | ||||
|     checked: Boolean, | ||||
|     onClick: (() -> Unit)? = null, | ||||
| ) { | ||||
| @@ -38,9 +38,9 @@ fun TrackingPreferenceWidget( | ||||
|                 .padding(horizontal = PrefsHorizontalPadding, vertical = 8.dp), | ||||
|             verticalAlignment = Alignment.CenterVertically, | ||||
|         ) { | ||||
|             TrackLogoIcon(service) | ||||
|             TrackLogoIcon(tracker) | ||||
|             Text( | ||||
|                 text = service.name, | ||||
|                 text = tracker.name, | ||||
|                 modifier = Modifier | ||||
|                     .weight(1f) | ||||
|                     .padding(horizontal = 16.dp), | ||||
|   | ||||
| @@ -1,25 +1,25 @@ | ||||
| package eu.kanade.presentation.reader | ||||
|  | ||||
| import androidx.compose.foundation.layout.Arrangement | ||||
| import androidx.compose.foundation.layout.Row | ||||
| import androidx.compose.foundation.layout.Box | ||||
| import androidx.compose.foundation.layout.fillMaxWidth | ||||
| import androidx.compose.foundation.layout.padding | ||||
| import androidx.compose.material3.FilterChip | ||||
| import androidx.compose.material3.MaterialTheme | ||||
| import androidx.compose.material3.Text | ||||
| import androidx.compose.foundation.lazy.grid.items | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.runtime.collectAsState | ||||
| import androidx.compose.runtime.getValue | ||||
| import androidx.compose.runtime.remember | ||||
| import androidx.compose.ui.Modifier | ||||
| import androidx.compose.ui.graphics.vector.ImageVector | ||||
| import androidx.compose.ui.res.stringResource | ||||
| import androidx.compose.ui.res.vectorResource | ||||
| import androidx.compose.ui.unit.dp | ||||
| import eu.kanade.domain.manga.model.orientationType | ||||
| import eu.kanade.presentation.components.AdaptiveSheet | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.ui.reader.setting.OrientationType | ||||
| import eu.kanade.tachiyomi.ui.reader.setting.ReaderSettingsScreenModel | ||||
| import tachiyomi.presentation.core.components.SettingsChipRow | ||||
| import tachiyomi.presentation.core.components.material.padding | ||||
| import tachiyomi.presentation.core.components.SettingsIconGrid | ||||
| import tachiyomi.presentation.core.components.material.IconToggleButton | ||||
|  | ||||
| private val orientationTypeOptions = OrientationType.entries.map { it.stringRes to it } | ||||
|  | ||||
| @@ -32,22 +32,20 @@ fun OrientationModeSelectDialog( | ||||
|     val manga by screenModel.mangaFlow.collectAsState() | ||||
|     val orientationType = remember(manga) { OrientationType.fromPreference(manga?.orientationType?.toInt()) } | ||||
|  | ||||
|     AdaptiveSheet( | ||||
|         onDismissRequest = onDismissRequest, | ||||
|     ) { | ||||
|         Row( | ||||
|             modifier = Modifier.padding(vertical = 16.dp), | ||||
|             horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small), | ||||
|         ) { | ||||
|             SettingsChipRow(R.string.rotation_type) { | ||||
|                 orientationTypeOptions.map { (stringRes, it) -> | ||||
|                     FilterChip( | ||||
|                         selected = it == orientationType, | ||||
|                         onClick = { | ||||
|                             screenModel.onChangeOrientation(it) | ||||
|     AdaptiveSheet(onDismissRequest = onDismissRequest) { | ||||
|         Box(modifier = Modifier.padding(vertical = 16.dp)) { | ||||
|             SettingsIconGrid(R.string.rotation_type) { | ||||
|                 items(orientationTypeOptions) { (stringRes, mode) -> | ||||
|                     IconToggleButton( | ||||
|                         checked = mode == orientationType, | ||||
|                         onCheckedChange = { | ||||
|                             screenModel.onChangeOrientation(mode) | ||||
|                             onChange(stringRes) | ||||
|                             onDismissRequest() | ||||
|                         }, | ||||
|                         label = { Text(stringResource(stringRes)) }, | ||||
|                         modifier = Modifier.fillMaxWidth(), | ||||
|                         imageVector = ImageVector.vectorResource(mode.iconRes), | ||||
|                         title = stringResource(stringRes), | ||||
|                     ) | ||||
|                 } | ||||
|             } | ||||
|   | ||||
| @@ -6,12 +6,10 @@ import androidx.compose.material3.Text | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.ui.graphics.Color | ||||
| import androidx.compose.ui.graphics.drawscope.Stroke | ||||
| import androidx.compose.ui.text.ExperimentalTextApi | ||||
| import androidx.compose.ui.text.TextStyle | ||||
| import androidx.compose.ui.text.font.FontWeight | ||||
| import androidx.compose.ui.unit.sp | ||||
|  | ||||
| @OptIn(ExperimentalTextApi::class) | ||||
| @Composable | ||||
| fun PageIndicatorText( | ||||
|     currentPage: Int, | ||||
|   | ||||
| @@ -1,24 +1,25 @@ | ||||
| package eu.kanade.presentation.reader | ||||
|  | ||||
| import androidx.compose.foundation.layout.Arrangement | ||||
| import androidx.compose.foundation.layout.Row | ||||
| import androidx.compose.foundation.layout.Box | ||||
| import androidx.compose.foundation.layout.fillMaxWidth | ||||
| import androidx.compose.foundation.layout.padding | ||||
| import androidx.compose.material3.FilterChip | ||||
| import androidx.compose.foundation.lazy.grid.items | ||||
| import androidx.compose.material3.MaterialTheme | ||||
| import androidx.compose.material3.Text | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.runtime.collectAsState | ||||
| import androidx.compose.runtime.getValue | ||||
| import androidx.compose.runtime.remember | ||||
| import androidx.compose.ui.Modifier | ||||
| import androidx.compose.ui.graphics.vector.ImageVector | ||||
| import androidx.compose.ui.res.stringResource | ||||
| import androidx.compose.ui.unit.dp | ||||
| import androidx.compose.ui.res.vectorResource | ||||
| import eu.kanade.domain.manga.model.readingModeType | ||||
| import eu.kanade.presentation.components.AdaptiveSheet | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.ui.reader.setting.ReaderSettingsScreenModel | ||||
| import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType | ||||
| import tachiyomi.presentation.core.components.SettingsChipRow | ||||
| import tachiyomi.presentation.core.components.SettingsIconGrid | ||||
| import tachiyomi.presentation.core.components.material.IconToggleButton | ||||
| import tachiyomi.presentation.core.components.material.padding | ||||
|  | ||||
| private val readingModeOptions = ReadingModeType.entries.map { it.stringRes to it } | ||||
| @@ -32,22 +33,20 @@ fun ReadingModeSelectDialog( | ||||
|     val manga by screenModel.mangaFlow.collectAsState() | ||||
|     val readingMode = remember(manga) { ReadingModeType.fromPreference(manga?.readingModeType?.toInt()) } | ||||
|  | ||||
|     AdaptiveSheet( | ||||
|         onDismissRequest = onDismissRequest, | ||||
|     ) { | ||||
|         Row( | ||||
|             modifier = Modifier.padding(vertical = 16.dp), | ||||
|             horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small), | ||||
|         ) { | ||||
|             SettingsChipRow(R.string.pref_category_reading_mode) { | ||||
|                 readingModeOptions.map { (stringRes, it) -> | ||||
|                     FilterChip( | ||||
|                         selected = it == readingMode, | ||||
|                         onClick = { | ||||
|                             screenModel.onChangeReadingMode(it) | ||||
|     AdaptiveSheet(onDismissRequest = onDismissRequest) { | ||||
|         Box(modifier = Modifier.padding(vertical = MaterialTheme.padding.medium)) { | ||||
|             SettingsIconGrid(R.string.pref_category_reading_mode) { | ||||
|                 items(readingModeOptions) { (stringRes, mode) -> | ||||
|                     IconToggleButton( | ||||
|                         checked = mode == readingMode, | ||||
|                         onCheckedChange = { | ||||
|                             screenModel.onChangeReadingMode(mode) | ||||
|                             onChange(stringRes) | ||||
|                             onDismissRequest() | ||||
|                         }, | ||||
|                         label = { Text(stringResource(stringRes)) }, | ||||
|                         modifier = Modifier.fillMaxWidth(), | ||||
|                         imageVector = ImageVector.vectorResource(mode.iconRes), | ||||
|                         title = stringResource(stringRes), | ||||
|                     ) | ||||
|                 } | ||||
|             } | ||||
|   | ||||
| @@ -16,7 +16,6 @@ import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences | ||||
| import eu.kanade.tachiyomi.ui.reader.setting.ReaderSettingsScreenModel | ||||
| import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType | ||||
| import eu.kanade.tachiyomi.ui.reader.viewer.webtoon.WebtoonViewer | ||||
| import eu.kanade.tachiyomi.util.system.isReleaseBuildType | ||||
| import tachiyomi.presentation.core.components.CheckboxItem | ||||
| import tachiyomi.presentation.core.components.HeadingItem | ||||
| import tachiyomi.presentation.core.components.SettingsChipRow | ||||
| @@ -185,13 +184,6 @@ private fun ColumnScope.WebtoonViewerSettings(screenModel: ReaderSettingsScreenM | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     if (!isReleaseBuildType) { | ||||
|         CheckboxItem( | ||||
|             label = stringResource(R.string.pref_long_strip_split), | ||||
|             pref = screenModel.preferences.longStripSplitWebtoon(), | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     CheckboxItem( | ||||
|         label = stringResource(R.string.pref_double_tap_zoom), | ||||
|         pref = screenModel.preferences.webtoonDoubleTapZoomEnabled(), | ||||
|   | ||||
| @@ -49,7 +49,7 @@ import eu.kanade.domain.track.model.toDbTrack | ||||
| import eu.kanade.presentation.components.DropdownMenu | ||||
| import eu.kanade.presentation.track.components.TrackLogoIcon | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.track.TrackService | ||||
| import eu.kanade.tachiyomi.data.track.Tracker | ||||
| import eu.kanade.tachiyomi.ui.manga.track.TrackItem | ||||
| import eu.kanade.tachiyomi.util.system.copyToClipboard | ||||
| import java.text.DateFormat | ||||
| @@ -80,12 +80,12 @@ fun TrackInfoDialogHome( | ||||
|     ) { | ||||
|         trackItems.forEach { item -> | ||||
|             if (item.track != null) { | ||||
|                 val supportsScoring = item.service.getScoreList().isNotEmpty() | ||||
|                 val supportsReadingDates = item.service.supportsReadingDates | ||||
|                 val supportsScoring = item.tracker.getScoreList().isNotEmpty() | ||||
|                 val supportsReadingDates = item.tracker.supportsReadingDates | ||||
|                 TrackInfoItem( | ||||
|                     title = item.track.title, | ||||
|                     service = item.service, | ||||
|                     status = item.service.getStatus(item.track.status.toInt()), | ||||
|                     tracker = item.tracker, | ||||
|                     status = item.tracker.getStatus(item.track.status.toInt()), | ||||
|                     onStatusClick = { onStatusClick(item) }, | ||||
|                     chapters = "${item.track.lastChapterRead.toInt()}".let { | ||||
|                         val totalChapters = item.track.totalChapters | ||||
| @@ -97,7 +97,7 @@ fun TrackInfoDialogHome( | ||||
|                         } | ||||
|                     }, | ||||
|                     onChaptersClick = { onChapterClick(item) }, | ||||
|                     score = item.service.displayScore(item.track.toDbTrack()) | ||||
|                     score = item.tracker.displayScore(item.track.toDbTrack()) | ||||
|                         .takeIf { supportsScoring && item.track.score != 0.0 }, | ||||
|                     onScoreClick = { onScoreClick(item) } | ||||
|                         .takeIf { supportsScoring }, | ||||
| @@ -115,7 +115,7 @@ fun TrackInfoDialogHome( | ||||
|                 ) | ||||
|             } else { | ||||
|                 TrackInfoItemEmpty( | ||||
|                     service = item.service, | ||||
|                     tracker = item.tracker, | ||||
|                     onNewSearch = { onNewSearch(item) }, | ||||
|                 ) | ||||
|             } | ||||
| @@ -126,7 +126,7 @@ fun TrackInfoDialogHome( | ||||
| @Composable | ||||
| private fun TrackInfoItem( | ||||
|     title: String, | ||||
|     service: TrackService, | ||||
|     tracker: Tracker, | ||||
|     @StringRes status: Int?, | ||||
|     onStatusClick: () -> Unit, | ||||
|     chapters: String, | ||||
| @@ -147,7 +147,7 @@ private fun TrackInfoItem( | ||||
|             verticalAlignment = Alignment.CenterVertically, | ||||
|         ) { | ||||
|             TrackLogoIcon( | ||||
|                 service = service, | ||||
|                 tracker = tracker, | ||||
|                 onClick = onOpenInBrowser, | ||||
|             ) | ||||
|             Box( | ||||
| @@ -260,13 +260,13 @@ private fun TrackDetailsItem( | ||||
|  | ||||
| @Composable | ||||
| private fun TrackInfoItemEmpty( | ||||
|     service: TrackService, | ||||
|     tracker: Tracker, | ||||
|     onNewSearch: () -> Unit, | ||||
| ) { | ||||
|     Row( | ||||
|         verticalAlignment = Alignment.CenterVertically, | ||||
|     ) { | ||||
|         TrackLogoIcon(service) | ||||
|         TrackLogoIcon(tracker) | ||||
|         TextButton( | ||||
|             onClick = onNewSearch, | ||||
|             modifier = Modifier | ||||
|   | ||||
| @@ -70,7 +70,7 @@ import tachiyomi.presentation.core.util.runOnEnterKeyPressed | ||||
| import tachiyomi.presentation.core.util.secondaryItemAlpha | ||||
| 
 | ||||
| @Composable | ||||
| fun TrackServiceSearch( | ||||
| fun TrackerSearch( | ||||
|     query: TextFieldValue, | ||||
|     onQueryChange: (TextFieldValue) -> Unit, | ||||
|     onDispatchQuery: () -> Unit, | ||||
| @@ -223,6 +223,7 @@ private fun SearchResultItem( | ||||
|     val borderColor = if (selected) MaterialTheme.colorScheme.outline else Color.Transparent | ||||
|     Box( | ||||
|         modifier = Modifier | ||||
|             .fillMaxWidth() | ||||
|             .padding(horizontal = 12.dp) | ||||
|             .clip(shape) | ||||
|             .background(MaterialTheme.colorScheme.surface) | ||||
| @@ -12,12 +12,12 @@ import androidx.compose.ui.Modifier | ||||
| import androidx.compose.ui.graphics.Color | ||||
| import androidx.compose.ui.res.painterResource | ||||
| import androidx.compose.ui.unit.dp | ||||
| import eu.kanade.tachiyomi.data.track.TrackService | ||||
| import eu.kanade.tachiyomi.data.track.Tracker | ||||
| import tachiyomi.presentation.core.util.clickableNoIndication | ||||
|  | ||||
| @Composable | ||||
| fun TrackLogoIcon( | ||||
|     service: TrackService, | ||||
|     tracker: Tracker, | ||||
|     onClick: (() -> Unit)? = null, | ||||
| ) { | ||||
|     val modifier = if (onClick != null) { | ||||
| @@ -29,13 +29,13 @@ fun TrackLogoIcon( | ||||
|     Box( | ||||
|         modifier = modifier | ||||
|             .size(48.dp) | ||||
|             .background(color = Color(service.getLogoColor()), shape = MaterialTheme.shapes.medium) | ||||
|             .background(color = Color(tracker.getLogoColor()), shape = MaterialTheme.shapes.medium) | ||||
|             .padding(4.dp), | ||||
|         contentAlignment = Alignment.Center, | ||||
|     ) { | ||||
|         Image( | ||||
|             painter = painterResource(service.getLogo()), | ||||
|             contentDescription = service.name, | ||||
|             painter = painterResource(tracker.getLogo()), | ||||
|             contentDescription = tracker.name, | ||||
|         ) | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -21,7 +21,7 @@ fun UpdatesDeleteConfirmationDialog( | ||||
|             TextButton(onClick = { | ||||
|                 onConfirm() | ||||
|                 onDismissRequest() | ||||
|             },) { | ||||
|             }) { | ||||
|                 Text(text = stringResource(R.string.action_ok)) | ||||
|             } | ||||
|         }, | ||||
|   | ||||
| @@ -43,6 +43,7 @@ fun UpdateScreen( | ||||
|     state: UpdatesScreenModel.State, | ||||
|     snackbarHostState: SnackbarHostState, | ||||
|     lastUpdated: Long, | ||||
|     relativeTime: Boolean, | ||||
|     onClickCover: (UpdatesItem) -> Unit, | ||||
|     onSelectAll: (Boolean) -> Unit, | ||||
|     onInvertSelection: () -> Unit, | ||||
| @@ -113,7 +114,7 @@ fun UpdateScreen( | ||||
|                         } | ||||
|  | ||||
|                         updatesUiItems( | ||||
|                             uiModels = state.getUiModel(context), | ||||
|                             uiModels = state.getUiModel(context, relativeTime), | ||||
|                             selectionMode = state.selectionMode, | ||||
|                             onUpdateSelected = onUpdateSelected, | ||||
|                             onClickCover = onClickCover, | ||||
|   | ||||
| @@ -175,7 +175,7 @@ fun WebViewScreenContent( | ||||
|                 WarningBanner( | ||||
|                     textRes = R.string.information_cloudflare_help, | ||||
|                     modifier = Modifier.clickable { | ||||
|                         uriHandler.openUri("https://tachiyomi.org/help/guides/troubleshooting/#solving-cloudflare-issues") | ||||
|                         uriHandler.openUri("https://tachiyomi.org/docs/guides/troubleshooting/#cloudflare") | ||||
|                     }, | ||||
|                 ) | ||||
|             } | ||||
|   | ||||
| @@ -19,8 +19,8 @@ import eu.kanade.tachiyomi.data.download.DownloadCache | ||||
| import eu.kanade.tachiyomi.data.download.DownloadManager | ||||
| import eu.kanade.tachiyomi.data.download.DownloadProvider | ||||
| import eu.kanade.tachiyomi.data.saver.ImageSaver | ||||
| import eu.kanade.tachiyomi.data.track.TrackerManager | ||||
| import eu.kanade.tachiyomi.data.sync.service.GoogleDriveService | ||||
| import eu.kanade.tachiyomi.data.track.TrackManager | ||||
| import eu.kanade.tachiyomi.extension.ExtensionManager | ||||
| import eu.kanade.tachiyomi.network.JavaScriptEngine | ||||
| import eu.kanade.tachiyomi.network.NetworkHelper | ||||
| @@ -134,7 +134,7 @@ class AppModule(val app: Application) : InjektModule { | ||||
|         addSingletonFactory { DownloadManager(app) } | ||||
|         addSingletonFactory { DownloadCache(app) } | ||||
|  | ||||
|         addSingletonFactory { TrackManager(app) } | ||||
|         addSingletonFactory { TrackerManager() } | ||||
|         addSingletonFactory { DelayedTrackingStore(app) } | ||||
|  | ||||
|         addSingletonFactory { ImageSaver(app) } | ||||
|   | ||||
| @@ -9,15 +9,15 @@ import eu.kanade.domain.ui.UiPreferences | ||||
| import eu.kanade.tachiyomi.core.security.SecurityPreferences | ||||
| import eu.kanade.tachiyomi.data.backup.BackupCreateJob | ||||
| import eu.kanade.tachiyomi.data.library.LibraryUpdateJob | ||||
| import eu.kanade.tachiyomi.data.track.TrackManager | ||||
| import eu.kanade.tachiyomi.data.track.TrackerManager | ||||
| import eu.kanade.tachiyomi.network.NetworkPreferences | ||||
| import eu.kanade.tachiyomi.network.PREF_DOH_CLOUDFLARE | ||||
| import eu.kanade.tachiyomi.ui.reader.setting.OrientationType | ||||
| import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences | ||||
| import eu.kanade.tachiyomi.util.system.DeviceUtil | ||||
| import eu.kanade.tachiyomi.util.system.isReleaseBuildType | ||||
| import eu.kanade.tachiyomi.util.system.toast | ||||
| import eu.kanade.tachiyomi.util.system.workManager | ||||
| import tachiyomi.core.preference.Preference | ||||
| import tachiyomi.core.preference.PreferenceStore | ||||
| import tachiyomi.core.preference.TriState | ||||
| import tachiyomi.core.preference.getAndSet | ||||
| @@ -47,7 +47,7 @@ object Migrations { | ||||
|         libraryPreferences: LibraryPreferences, | ||||
|         readerPreferences: ReaderPreferences, | ||||
|         backupPreferences: BackupPreferences, | ||||
|         trackManager: TrackManager, | ||||
|         trackerManager: TrackerManager, | ||||
|     ): Boolean { | ||||
|         val lastVersionCode = preferenceStore.getInt("last_version_code", 0) | ||||
|         val oldVersion = lastVersionCode.get() | ||||
| @@ -135,8 +135,8 @@ object Migrations { | ||||
|                 // Force MAL log out due to login flow change | ||||
|                 // v52: switched from scraping to WebView | ||||
|                 // v53: switched from WebView to OAuth | ||||
|                 if (trackManager.myAnimeList.isLoggedIn) { | ||||
|                     trackManager.myAnimeList.logout() | ||||
|                 if (trackerManager.myAnimeList.isLoggedIn) { | ||||
|                     trackerManager.myAnimeList.logout() | ||||
|                     context.toast(R.string.myanimelist_relogin) | ||||
|                 } | ||||
|             } | ||||
| @@ -342,7 +342,7 @@ object Migrations { | ||||
|                     "pref_filter_library_started", | ||||
|                     "pref_filter_library_bookmarked", | ||||
|                     "pref_filter_library_completed", | ||||
|                 ) + trackManager.services.map { "pref_filter_library_tracked_${it.id}" } | ||||
|                 ) + trackerManager.trackers.map { "pref_filter_library_tracked_${it.id}" } | ||||
|  | ||||
|                 prefKeys.forEach { key -> | ||||
|                     val pref = preferenceStore.getInt(key, 0) | ||||
| @@ -362,19 +362,31 @@ object Migrations { | ||||
|             if (oldVersion < 100) { | ||||
|                 BackupCreateJob.setupTask(context) | ||||
|             } | ||||
|             if (oldVersion < 102) { | ||||
|                 // This was accidentally visible from the reader settings sheet, but should always | ||||
|                 // be disabled in release builds. | ||||
|                 if (isReleaseBuildType) { | ||||
|                     readerPreferences.longStripSplitWebtoon().set(false) | ||||
|                 } | ||||
|             } | ||||
|             if (oldVersion < 105) { | ||||
|                 val pref = libraryPreferences.autoUpdateDeviceRestrictions() | ||||
|                 if (pref.isSet() && "battery_not_low" in pref.get()) { | ||||
|                     pref.getAndSet { it - "battery_not_low" } | ||||
|                 } | ||||
|             } | ||||
|             if (oldVersion < 106) { | ||||
|                 val pref = preferenceStore.getInt("relative_time", 7) | ||||
|                 if (pref.get() == 0) { | ||||
|                     uiPreferences.relativeTime().set(false) | ||||
|                 } | ||||
|             } | ||||
|             if (oldVersion < 107) { | ||||
|                 preferenceStore.getAll() | ||||
|                     .filter { it.key.startsWith("pref_mangasync_") || it.key.startsWith("track_token_") } | ||||
|                     .forEach { (key, value) -> | ||||
|                         if (value is String) { | ||||
|                             preferenceStore | ||||
|                                 .getString(Preference.privateKey(key)) | ||||
|                                 .set(value) | ||||
|  | ||||
|                             preferenceStore.getString(key).delete() | ||||
|                         } | ||||
|                     } | ||||
|             } | ||||
|             return true | ||||
|         } | ||||
|  | ||||
|   | ||||
| @@ -4,11 +4,21 @@ package eu.kanade.tachiyomi.data.backup | ||||
| internal object BackupConst { | ||||
|     const val BACKUP_CATEGORY = 0x1 | ||||
|     const val BACKUP_CATEGORY_MASK = 0x1 | ||||
|  | ||||
|     const val BACKUP_CHAPTER = 0x2 | ||||
|     const val BACKUP_CHAPTER_MASK = 0x2 | ||||
|  | ||||
|     const val BACKUP_HISTORY = 0x4 | ||||
|     const val BACKUP_HISTORY_MASK = 0x4 | ||||
|  | ||||
|     const val BACKUP_TRACK = 0x8 | ||||
|     const val BACKUP_TRACK_MASK = 0x8 | ||||
|     const val BACKUP_ALL = 0xF | ||||
|  | ||||
|     const val BACKUP_APP_PREFS = 0x10 | ||||
|     const val BACKUP_APP_PREFS_MASK = 0x10 | ||||
|  | ||||
|     const val BACKUP_SOURCE_PREFS = 0x20 | ||||
|     const val BACKUP_SOURCE_PREFS_MASK = 0x20 | ||||
|  | ||||
|     const val BACKUP_ALL = 0x3F | ||||
| } | ||||
|   | ||||
| @@ -49,7 +49,7 @@ class BackupCreateJob(private val context: Context, workerParams: WorkerParamete | ||||
|         } | ||||
|  | ||||
|         return try { | ||||
|             val location = BackupManager(context).createBackup(uri, flags, isAutoBackup) | ||||
|             val location = BackupCreator(context).createBackup(uri, flags, isAutoBackup) | ||||
|             if (!isAutoBackup) notifier.showBackupComplete(UniFile.fromUri(context, location.toUri())) | ||||
|             Result.success() | ||||
|         } catch (e: Exception) { | ||||
|   | ||||
| @@ -0,0 +1,268 @@ | ||||
| package eu.kanade.tachiyomi.data.backup | ||||
|  | ||||
| import android.Manifest | ||||
| import android.content.Context | ||||
| import android.net.Uri | ||||
| import com.hippo.unifile.UniFile | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_APP_PREFS | ||||
| import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_APP_PREFS_MASK | ||||
| import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_CATEGORY | ||||
| import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_CATEGORY_MASK | ||||
| import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_CHAPTER | ||||
| import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_CHAPTER_MASK | ||||
| import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_HISTORY | ||||
| import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_HISTORY_MASK | ||||
| import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_SOURCE_PREFS | ||||
| import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_SOURCE_PREFS_MASK | ||||
| import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_TRACK | ||||
| import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_TRACK_MASK | ||||
| import eu.kanade.tachiyomi.data.backup.models.Backup | ||||
| import eu.kanade.tachiyomi.data.backup.models.BackupCategory | ||||
| import eu.kanade.tachiyomi.data.backup.models.BackupHistory | ||||
| import eu.kanade.tachiyomi.data.backup.models.BackupManga | ||||
| import eu.kanade.tachiyomi.data.backup.models.BackupPreference | ||||
| import eu.kanade.tachiyomi.data.backup.models.BackupSerializer | ||||
| import eu.kanade.tachiyomi.data.backup.models.BackupSource | ||||
| import eu.kanade.tachiyomi.data.backup.models.BackupSourcePreferences | ||||
| import eu.kanade.tachiyomi.data.backup.models.BooleanPreferenceValue | ||||
| import eu.kanade.tachiyomi.data.backup.models.FloatPreferenceValue | ||||
| import eu.kanade.tachiyomi.data.backup.models.IntPreferenceValue | ||||
| import eu.kanade.tachiyomi.data.backup.models.LongPreferenceValue | ||||
| import eu.kanade.tachiyomi.data.backup.models.StringPreferenceValue | ||||
| import eu.kanade.tachiyomi.data.backup.models.StringSetPreferenceValue | ||||
| import eu.kanade.tachiyomi.data.backup.models.backupCategoryMapper | ||||
| import eu.kanade.tachiyomi.data.backup.models.backupChapterMapper | ||||
| import eu.kanade.tachiyomi.data.backup.models.backupTrackMapper | ||||
| import eu.kanade.tachiyomi.source.ConfigurableSource | ||||
| import eu.kanade.tachiyomi.source.preferenceKey | ||||
| import eu.kanade.tachiyomi.source.sourcePreferences | ||||
| import eu.kanade.tachiyomi.util.system.hasPermission | ||||
| import kotlinx.serialization.protobuf.ProtoBuf | ||||
| import logcat.LogPriority | ||||
| import okio.buffer | ||||
| import okio.gzip | ||||
| import okio.sink | ||||
| import tachiyomi.core.preference.Preference | ||||
| import tachiyomi.core.preference.PreferenceStore | ||||
| import tachiyomi.core.util.system.logcat | ||||
| import tachiyomi.data.DatabaseHandler | ||||
| import tachiyomi.domain.backup.service.BackupPreferences | ||||
| import tachiyomi.domain.category.interactor.GetCategories | ||||
| import tachiyomi.domain.category.model.Category | ||||
| import tachiyomi.domain.history.interactor.GetHistory | ||||
| import tachiyomi.domain.manga.interactor.GetFavorites | ||||
| import tachiyomi.domain.manga.model.Manga | ||||
| import tachiyomi.domain.source.service.SourceManager | ||||
| import uy.kohesive.injekt.Injekt | ||||
| import uy.kohesive.injekt.api.get | ||||
| import java.io.FileOutputStream | ||||
|  | ||||
| class BackupCreator( | ||||
|     private val context: Context, | ||||
| ) { | ||||
|  | ||||
|     private val handler: DatabaseHandler = Injekt.get() | ||||
|     private val sourceManager: SourceManager = Injekt.get() | ||||
|     private val backupPreferences: BackupPreferences = Injekt.get() | ||||
|     private val getCategories: GetCategories = Injekt.get() | ||||
|     private val getFavorites: GetFavorites = Injekt.get() | ||||
|     private val getHistory: GetHistory = Injekt.get() | ||||
|     private val preferenceStore: PreferenceStore = Injekt.get() | ||||
|  | ||||
|     internal val parser = ProtoBuf | ||||
|  | ||||
|     /** | ||||
|      * Create backup file. | ||||
|      * | ||||
|      * @param uri path of Uri | ||||
|      * @param isAutoBackup backup called from scheduled backup job | ||||
|      */ | ||||
|     suspend fun createBackup(uri: Uri, flags: Int, isAutoBackup: Boolean): String { | ||||
|         if (!context.hasPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE)) { | ||||
|             throw IllegalStateException(context.getString(R.string.missing_storage_permission)) | ||||
|         } | ||||
|  | ||||
|         val databaseManga = getFavorites.await() | ||||
|         val backup = Backup( | ||||
|             backupMangas(databaseManga, flags), | ||||
|             backupCategories(flags), | ||||
|             emptyList(), | ||||
|             prepExtensionInfoForSync(databaseManga), | ||||
|             backupAppPreferences(flags), | ||||
|             backupSourcePreferences(flags), | ||||
|         ) | ||||
|  | ||||
|         var file: UniFile? = null | ||||
|         try { | ||||
|             file = ( | ||||
|                 if (isAutoBackup) { | ||||
|                     // Get dir of file and create | ||||
|                     var dir = UniFile.fromUri(context, uri) | ||||
|                     dir = dir.createDirectory("automatic") | ||||
|  | ||||
|                     // Delete older backups | ||||
|                     val numberOfBackups = backupPreferences.numberOfBackups().get() | ||||
|                     dir.listFiles { _, filename -> Backup.filenameRegex.matches(filename) } | ||||
|                         .orEmpty() | ||||
|                         .sortedByDescending { it.name } | ||||
|                         .drop(numberOfBackups - 1) | ||||
|                         .forEach { it.delete() } | ||||
|  | ||||
|                     // Create new file to place backup | ||||
|                     dir.createFile(Backup.getFilename()) | ||||
|                 } else { | ||||
|                     UniFile.fromUri(context, uri) | ||||
|                 } | ||||
|                 ) | ||||
|                 ?: throw Exception(context.getString(R.string.create_backup_file_error)) | ||||
|  | ||||
|             if (!file.isFile) { | ||||
|                 throw IllegalStateException("Failed to get handle on a backup file") | ||||
|             } | ||||
|  | ||||
|             val byteArray = parser.encodeToByteArray(BackupSerializer, backup) | ||||
|             if (byteArray.isEmpty()) { | ||||
|                 throw IllegalStateException(context.getString(R.string.empty_backup_error)) | ||||
|             } | ||||
|  | ||||
|             file.openOutputStream().also { | ||||
|                 // Force overwrite old file | ||||
|                 (it as? FileOutputStream)?.channel?.truncate(0) | ||||
|             }.sink().gzip().buffer().use { it.write(byteArray) } | ||||
|             val fileUri = file.uri | ||||
|  | ||||
|             // Make sure it's a valid backup file | ||||
|             BackupFileValidator().validate(context, fileUri) | ||||
|  | ||||
|             return fileUri.toString() | ||||
|         } catch (e: Exception) { | ||||
|             logcat(LogPriority.ERROR, e) | ||||
|             file?.delete() | ||||
|             throw e | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private fun prepExtensionInfoForSync(mangas: List<Manga>): List<BackupSource> { | ||||
|         return mangas | ||||
|             .asSequence() | ||||
|             .map(Manga::source) | ||||
|             .distinct() | ||||
|             .map(sourceManager::getOrStub) | ||||
|             .map(BackupSource::copyFrom) | ||||
|             .toList() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Backup the categories of library | ||||
|      * | ||||
|      * @return list of [BackupCategory] to be backed up | ||||
|      */ | ||||
|     private suspend fun backupCategories(options: Int): List<BackupCategory> { | ||||
|         // Check if user wants category information in backup | ||||
|         return if (options and BACKUP_CATEGORY_MASK == BACKUP_CATEGORY) { | ||||
|             getCategories.await() | ||||
|                 .filterNot(Category::isSystemCategory) | ||||
|                 .map(backupCategoryMapper) | ||||
|         } else { | ||||
|             emptyList() | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private suspend fun backupMangas(mangas: List<Manga>, flags: Int): List<BackupManga> { | ||||
|         return mangas.map { | ||||
|             backupManga(it, flags) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Convert a manga to Json | ||||
|      * | ||||
|      * @param manga manga that gets converted | ||||
|      * @param options options for the backup | ||||
|      * @return [BackupManga] containing manga in a serializable form | ||||
|      */ | ||||
|     private suspend fun backupManga(manga: Manga, options: Int): BackupManga { | ||||
|         // Entry for this manga | ||||
|         val mangaObject = BackupManga.copyFrom(manga) | ||||
|  | ||||
|         // Check if user wants chapter information in backup | ||||
|         if (options and BACKUP_CHAPTER_MASK == BACKUP_CHAPTER) { | ||||
|             // Backup all the chapters | ||||
|             val chapters = handler.awaitList { chaptersQueries.getChaptersByMangaId(manga.id, backupChapterMapper) } | ||||
|             if (chapters.isNotEmpty()) { | ||||
|                 mangaObject.chapters = chapters | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         // Check if user wants category information in backup | ||||
|         if (options and BACKUP_CATEGORY_MASK == BACKUP_CATEGORY) { | ||||
|             // Backup categories for this manga | ||||
|             val categoriesForManga = getCategories.await(manga.id) | ||||
|             if (categoriesForManga.isNotEmpty()) { | ||||
|                 mangaObject.categories = categoriesForManga.map { it.order } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         // Check if user wants track information in backup | ||||
|         if (options and BACKUP_TRACK_MASK == BACKUP_TRACK) { | ||||
|             val tracks = handler.awaitList { manga_syncQueries.getTracksByMangaId(manga.id, backupTrackMapper) } | ||||
|             if (tracks.isNotEmpty()) { | ||||
|                 mangaObject.tracking = tracks | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         // Check if user wants history information in backup | ||||
|         if (options and BACKUP_HISTORY_MASK == BACKUP_HISTORY) { | ||||
|             val historyByMangaId = getHistory.await(manga.id) | ||||
|             if (historyByMangaId.isNotEmpty()) { | ||||
|                 val history = historyByMangaId.map { history -> | ||||
|                     val chapter = handler.awaitOne { chaptersQueries.getChapterById(history.chapterId) } | ||||
|                     BackupHistory(chapter.url, history.readAt?.time ?: 0L, history.readDuration) | ||||
|                 } | ||||
|                 if (history.isNotEmpty()) { | ||||
|                     mangaObject.history = history | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return mangaObject | ||||
|     } | ||||
|  | ||||
|     private fun backupAppPreferences(flags: Int): List<BackupPreference> { | ||||
|         if (flags and BACKUP_APP_PREFS_MASK != BACKUP_APP_PREFS) return emptyList() | ||||
|  | ||||
|         return preferenceStore.getAll().toBackupPreferences() | ||||
|     } | ||||
|  | ||||
|     private fun backupSourcePreferences(flags: Int): List<BackupSourcePreferences> { | ||||
|         if (flags and BACKUP_SOURCE_PREFS_MASK != BACKUP_SOURCE_PREFS) return emptyList() | ||||
|  | ||||
|         return sourceManager.getOnlineSources() | ||||
|             .filterIsInstance<ConfigurableSource>() | ||||
|             .map { | ||||
|                 BackupSourcePreferences( | ||||
|                     it.preferenceKey(), | ||||
|                     it.sourcePreferences().all.toBackupPreferences(), | ||||
|                 ) | ||||
|             } | ||||
|     } | ||||
|  | ||||
|     @Suppress("UNCHECKED_CAST") | ||||
|     private fun Map<String, *>.toBackupPreferences(): List<BackupPreference> { | ||||
|         return this.filterKeys { !Preference.isPrivate(it) } | ||||
|             .mapNotNull { (key, value) -> | ||||
|                 when (value) { | ||||
|                     is Int -> BackupPreference(key, IntPreferenceValue(value)) | ||||
|                     is Long -> BackupPreference(key, LongPreferenceValue(value)) | ||||
|                     is Float -> BackupPreference(key, FloatPreferenceValue(value)) | ||||
|                     is String -> BackupPreference(key, StringPreferenceValue(value)) | ||||
|                     is Boolean -> BackupPreference(key, BooleanPreferenceValue(value)) | ||||
|                     is Set<*> -> (value as? Set<String>)?.let { | ||||
|                         BackupPreference(key, StringSetPreferenceValue(it)) | ||||
|                     } | ||||
|                     else -> null | ||||
|                 } | ||||
|             } | ||||
|     } | ||||
| } | ||||
| @@ -3,7 +3,7 @@ package eu.kanade.tachiyomi.data.backup | ||||
| import android.content.Context | ||||
| import android.net.Uri | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.track.TrackManager | ||||
| import eu.kanade.tachiyomi.data.track.TrackerManager | ||||
| import eu.kanade.tachiyomi.util.BackupUtil | ||||
| import tachiyomi.domain.source.service.SourceManager | ||||
| import uy.kohesive.injekt.Injekt | ||||
| @@ -11,7 +11,7 @@ import uy.kohesive.injekt.api.get | ||||
|  | ||||
| class BackupFileValidator( | ||||
|     private val sourceManager: SourceManager = Injekt.get(), | ||||
|     private val trackManager: TrackManager = Injekt.get(), | ||||
|     private val trackerManager: TrackerManager = Injekt.get(), | ||||
| ) { | ||||
|  | ||||
|     /** | ||||
| @@ -50,7 +50,7 @@ class BackupFileValidator( | ||||
|             .map { it.syncId } | ||||
|             .distinct() | ||||
|         val missingTrackers = trackers | ||||
|             .mapNotNull { trackManager.getService(it.toLong()) } | ||||
|             .mapNotNull { trackerManager.get(it.toLong()) } | ||||
|             .filter { !it.isLoggedIn } | ||||
|             .map { it.name } | ||||
|             .sorted() | ||||
| @@ -58,5 +58,8 @@ class BackupFileValidator( | ||||
|         return Results(missingSources, missingTrackers) | ||||
|     } | ||||
|  | ||||
|     data class Results(val missingSources: List<String>, val missingTrackers: List<String>) | ||||
|     data class Results( | ||||
|         val missingSources: List<String>, | ||||
|         val missingTrackers: List<String>, | ||||
|     ) | ||||
| } | ||||
|   | ||||
| @@ -1,590 +0,0 @@ | ||||
| package eu.kanade.tachiyomi.data.backup | ||||
|  | ||||
| import android.Manifest | ||||
| import android.content.Context | ||||
| import android.net.Uri | ||||
| import com.hippo.unifile.UniFile | ||||
| import eu.kanade.domain.chapter.model.copyFrom | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_CATEGORY | ||||
| import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_CATEGORY_MASK | ||||
| import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_CHAPTER | ||||
| import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_CHAPTER_MASK | ||||
| import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_HISTORY | ||||
| import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_HISTORY_MASK | ||||
| import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_TRACK | ||||
| import eu.kanade.tachiyomi.data.backup.BackupConst.BACKUP_TRACK_MASK | ||||
| import eu.kanade.tachiyomi.data.backup.models.Backup | ||||
| import eu.kanade.tachiyomi.data.backup.models.BackupCategory | ||||
| import eu.kanade.tachiyomi.data.backup.models.BackupHistory | ||||
| import eu.kanade.tachiyomi.data.backup.models.BackupManga | ||||
| import eu.kanade.tachiyomi.data.backup.models.BackupSerializer | ||||
| import eu.kanade.tachiyomi.data.backup.models.BackupSource | ||||
| import eu.kanade.tachiyomi.data.backup.models.backupCategoryMapper | ||||
| import eu.kanade.tachiyomi.data.backup.models.backupChapterMapper | ||||
| import eu.kanade.tachiyomi.data.backup.models.backupTrackMapper | ||||
| import eu.kanade.tachiyomi.source.model.copyFrom | ||||
| import eu.kanade.tachiyomi.util.system.hasPermission | ||||
| import kotlinx.serialization.protobuf.ProtoBuf | ||||
| import logcat.LogPriority | ||||
| import okio.buffer | ||||
| import okio.gzip | ||||
| import okio.sink | ||||
| import tachiyomi.core.util.system.logcat | ||||
| import tachiyomi.data.DatabaseHandler | ||||
| import tachiyomi.data.Manga_sync | ||||
| import tachiyomi.data.Mangas | ||||
| import tachiyomi.data.UpdateStrategyColumnAdapter | ||||
| import tachiyomi.domain.backup.service.BackupPreferences | ||||
| import tachiyomi.domain.category.interactor.GetCategories | ||||
| import tachiyomi.domain.category.model.Category | ||||
| import tachiyomi.domain.history.interactor.GetHistory | ||||
| import tachiyomi.domain.history.model.HistoryUpdate | ||||
| import tachiyomi.domain.library.service.LibraryPreferences | ||||
| import tachiyomi.domain.manga.interactor.GetFavorites | ||||
| import tachiyomi.domain.manga.model.Manga | ||||
| import tachiyomi.domain.source.service.SourceManager | ||||
| import uy.kohesive.injekt.Injekt | ||||
| import uy.kohesive.injekt.api.get | ||||
| import java.io.FileOutputStream | ||||
| import java.util.Date | ||||
| import kotlin.math.max | ||||
|  | ||||
| class BackupManager( | ||||
|     private val context: Context, | ||||
| ) { | ||||
|  | ||||
|     private val handler: DatabaseHandler = Injekt.get() | ||||
|     private val sourceManager: SourceManager = Injekt.get() | ||||
|     private val backupPreferences: BackupPreferences = Injekt.get() | ||||
|     private val libraryPreferences: LibraryPreferences = Injekt.get() | ||||
|     private val getCategories: GetCategories = Injekt.get() | ||||
|     private val getFavorites: GetFavorites = Injekt.get() | ||||
|     private val getHistory: GetHistory = Injekt.get() | ||||
|  | ||||
|     internal val parser = ProtoBuf | ||||
|  | ||||
|     /** | ||||
|      * Create backup file from database | ||||
|      * | ||||
|      * @param uri path of Uri | ||||
|      * @param isAutoBackup backup called from scheduled backup job | ||||
|      */ | ||||
|     suspend fun createBackup(uri: Uri, flags: Int, isAutoBackup: Boolean): String { | ||||
|         if (!context.hasPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE)) { | ||||
|             throw IllegalStateException(context.getString(R.string.missing_storage_permission)) | ||||
|         } | ||||
|  | ||||
|         val databaseManga = getFavorites.await() | ||||
|         val backup = Backup( | ||||
|             backupMangas(databaseManga, flags), | ||||
|             backupCategories(flags), | ||||
|             emptyList(), | ||||
|             prepExtensionInfoForSync(databaseManga), | ||||
|         ) | ||||
|  | ||||
|         var file: UniFile? = null | ||||
|         try { | ||||
|             file = ( | ||||
|                 if (isAutoBackup) { | ||||
|                     // Get dir of file and create | ||||
|                     var dir = UniFile.fromUri(context, uri) | ||||
|                     dir = dir.createDirectory("automatic") | ||||
|  | ||||
|                     // Delete older backups | ||||
|                     val numberOfBackups = backupPreferences.numberOfBackups().get() | ||||
|                     val backupRegex = Regex("""tachiyomi_\d+-\d+-\d+_\d+-\d+.proto.gz""") | ||||
|                     dir.listFiles { _, filename -> backupRegex.matches(filename) } | ||||
|                         .orEmpty() | ||||
|                         .sortedByDescending { it.name } | ||||
|                         .drop(numberOfBackups - 1) | ||||
|                         .forEach { it.delete() } | ||||
|  | ||||
|                     // Create new file to place backup | ||||
|                     dir.createFile(Backup.getBackupFilename()) | ||||
|                 } else { | ||||
|                     UniFile.fromUri(context, uri) | ||||
|                 } | ||||
|                 ) | ||||
|                 ?: throw Exception(context.getString(R.string.create_backup_file_error)) | ||||
|  | ||||
|             if (!file.isFile) { | ||||
|                 throw IllegalStateException("Failed to get handle on a backup file") | ||||
|             } | ||||
|  | ||||
|             val byteArray = parser.encodeToByteArray(BackupSerializer, backup) | ||||
|             if (byteArray.isEmpty()) { | ||||
|                 throw IllegalStateException(context.getString(R.string.empty_backup_error)) | ||||
|             } | ||||
|  | ||||
|             file.openOutputStream().also { | ||||
|                 // Force overwrite old file | ||||
|                 (it as? FileOutputStream)?.channel?.truncate(0) | ||||
|             }.sink().gzip().buffer().use { it.write(byteArray) } | ||||
|             val fileUri = file.uri | ||||
|  | ||||
|             // Make sure it's a valid backup file | ||||
|             BackupFileValidator().validate(context, fileUri) | ||||
|  | ||||
|             return fileUri.toString() | ||||
|         } catch (e: Exception) { | ||||
|             logcat(LogPriority.ERROR, e) | ||||
|             file?.delete() | ||||
|             throw e | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fun prepExtensionInfoForSync(mangas: List<Manga>): List<BackupSource> { | ||||
|         return mangas | ||||
|             .asSequence() | ||||
|             .map(Manga::source) | ||||
|             .distinct() | ||||
|             .map(sourceManager::getOrStub) | ||||
|             .map(BackupSource::copyFrom) | ||||
|             .toList() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Backup the categories of library | ||||
|      * | ||||
|      * @return list of [BackupCategory] to be backed up | ||||
|      */ | ||||
|     suspend fun backupCategories(options: Int): List<BackupCategory> { | ||||
|         // Check if user wants category information in backup | ||||
|         return if (options and BACKUP_CATEGORY_MASK == BACKUP_CATEGORY) { | ||||
|             getCategories.await() | ||||
|                 .filterNot(Category::isSystemCategory) | ||||
|                 .map(backupCategoryMapper) | ||||
|         } else { | ||||
|             emptyList() | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     suspend fun backupMangas(mangas: List<Manga>, flags: Int): List<BackupManga> { | ||||
|         return mangas.map { | ||||
|             backupManga(it, flags) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Convert a manga to Json | ||||
|      * | ||||
|      * @param manga manga that gets converted | ||||
|      * @param options options for the backup | ||||
|      * @return [BackupManga] containing manga in a serializable form | ||||
|      */ | ||||
|     private suspend fun backupManga(manga: Manga, options: Int): BackupManga { | ||||
|         // Entry for this manga | ||||
|         val mangaObject = BackupManga.copyFrom(manga) | ||||
|  | ||||
|         // Check if user wants chapter information in backup | ||||
|         if (options and BACKUP_CHAPTER_MASK == BACKUP_CHAPTER) { | ||||
|             // Backup all the chapters | ||||
|             val chapters = handler.awaitList { chaptersQueries.getChaptersByMangaId(manga.id, backupChapterMapper) } | ||||
|             if (chapters.isNotEmpty()) { | ||||
|                 mangaObject.chapters = chapters | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         // Check if user wants category information in backup | ||||
|         if (options and BACKUP_CATEGORY_MASK == BACKUP_CATEGORY) { | ||||
|             // Backup categories for this manga | ||||
|             val categoriesForManga = getCategories.await(manga.id) | ||||
|             if (categoriesForManga.isNotEmpty()) { | ||||
|                 mangaObject.categories = categoriesForManga.map { it.order } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         // Check if user wants track information in backup | ||||
|         if (options and BACKUP_TRACK_MASK == BACKUP_TRACK) { | ||||
|             val tracks = handler.awaitList { manga_syncQueries.getTracksByMangaId(manga.id, backupTrackMapper) } | ||||
|             if (tracks.isNotEmpty()) { | ||||
|                 mangaObject.tracking = tracks | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         // Check if user wants history information in backup | ||||
|         if (options and BACKUP_HISTORY_MASK == BACKUP_HISTORY) { | ||||
|             val historyByMangaId = getHistory.await(manga.id) | ||||
|             if (historyByMangaId.isNotEmpty()) { | ||||
|                 val history = historyByMangaId.map { history -> | ||||
|                     val chapter = handler.awaitOne { chaptersQueries.getChapterById(history.chapterId) } | ||||
|                     BackupHistory(chapter.url, history.readAt?.time ?: 0L, history.readDuration) | ||||
|                 } | ||||
|                 if (history.isNotEmpty()) { | ||||
|                     mangaObject.history = history | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return mangaObject | ||||
|     } | ||||
|  | ||||
|     internal suspend fun restoreExistingManga(manga: Manga, dbManga: Mangas): Manga { | ||||
|         var updatedManga = manga.copy(id = dbManga._id) | ||||
|         updatedManga = updatedManga.copyFrom(dbManga) | ||||
|         updateManga(updatedManga) | ||||
|         return updatedManga | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Fetches manga information | ||||
|      * | ||||
|      * @param manga manga that needs updating | ||||
|      * @return Updated manga info. | ||||
|      */ | ||||
|     internal suspend fun restoreNewManga(manga: Manga): Manga { | ||||
|         return manga.copy( | ||||
|             initialized = manga.description != null, | ||||
|             id = insertManga(manga), | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Restore the categories from Json | ||||
|      * | ||||
|      * @param backupCategories list containing categories | ||||
|      */ | ||||
|     internal suspend fun restoreCategories(backupCategories: List<BackupCategory>) { | ||||
|         // Get categories from file and from db | ||||
|         val dbCategories = getCategories.await() | ||||
|  | ||||
|         val categories = backupCategories.map { | ||||
|             var category = it.getCategory() | ||||
|             var found = false | ||||
|             for (dbCategory in dbCategories) { | ||||
|                 // If the category is already in the db, assign the id to the file's category | ||||
|                 // and do nothing | ||||
|                 if (category.name == dbCategory.name) { | ||||
|                     category = category.copy(id = dbCategory.id) | ||||
|                     found = true | ||||
|                     break | ||||
|                 } | ||||
|             } | ||||
|             if (!found) { | ||||
|                 // Let the db assign the id | ||||
|                 val id = handler.awaitOneExecutable { | ||||
|                     categoriesQueries.insert(category.name, category.order, category.flags) | ||||
|                     categoriesQueries.selectLastInsertedRowId() | ||||
|                 } | ||||
|                 category = category.copy(id = id) | ||||
|             } | ||||
|  | ||||
|             category | ||||
|         } | ||||
|  | ||||
|         libraryPreferences.categorizedDisplaySettings().set( | ||||
|             (dbCategories + categories) | ||||
|                 .distinctBy { it.flags } | ||||
|                 .size > 1, | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Restores the categories a manga is in. | ||||
|      * | ||||
|      * @param manga the manga whose categories have to be restored. | ||||
|      * @param categories the categories to restore. | ||||
|      */ | ||||
|     internal suspend fun restoreCategories(manga: Manga, categories: List<Int>, backupCategories: List<BackupCategory>) { | ||||
|         val dbCategories = getCategories.await() | ||||
|         val mangaCategoriesToUpdate = mutableListOf<Pair<Long, Long>>() | ||||
|  | ||||
|         categories.forEach { backupCategoryOrder -> | ||||
|             backupCategories.firstOrNull { | ||||
|                 it.order == backupCategoryOrder.toLong() | ||||
|             }?.let { backupCategory -> | ||||
|                 dbCategories.firstOrNull { dbCategory -> | ||||
|                     dbCategory.name == backupCategory.name | ||||
|                 }?.let { dbCategory -> | ||||
|                     mangaCategoriesToUpdate.add(Pair(manga.id, dbCategory.id)) | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         // Update database | ||||
|         if (mangaCategoriesToUpdate.isNotEmpty()) { | ||||
|             handler.await(true) { | ||||
|                 mangas_categoriesQueries.deleteMangaCategoryByMangaId(manga.id) | ||||
|                 mangaCategoriesToUpdate.forEach { (mangaId, categoryId) -> | ||||
|                     mangas_categoriesQueries.insert(mangaId, categoryId) | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Restore history from Json | ||||
|      * | ||||
|      * @param history list containing history to be restored | ||||
|      */ | ||||
|     internal suspend fun restoreHistory(history: List<BackupHistory>) { | ||||
|         // List containing history to be updated | ||||
|         val toUpdate = mutableListOf<HistoryUpdate>() | ||||
|         for ((url, lastRead, readDuration) in history) { | ||||
|             var dbHistory = handler.awaitOneOrNull { historyQueries.getHistoryByChapterUrl(url) } | ||||
|             // Check if history already in database and update | ||||
|             if (dbHistory != null) { | ||||
|                 dbHistory = dbHistory.copy( | ||||
|                     last_read = Date(max(lastRead, dbHistory.last_read?.time ?: 0L)), | ||||
|                     time_read = max(readDuration, dbHistory.time_read) - dbHistory.time_read, | ||||
|                 ) | ||||
|                 toUpdate.add( | ||||
|                     HistoryUpdate( | ||||
|                         chapterId = dbHistory.chapter_id, | ||||
|                         readAt = dbHistory.last_read!!, | ||||
|                         sessionReadDuration = dbHistory.time_read, | ||||
|                     ), | ||||
|                 ) | ||||
|             } else { | ||||
|                 // If not in database create | ||||
|                 handler | ||||
|                     .awaitOneOrNull { chaptersQueries.getChapterByUrl(url) } | ||||
|                     ?.let { | ||||
|                         toUpdate.add( | ||||
|                             HistoryUpdate( | ||||
|                                 chapterId = it._id, | ||||
|                                 readAt = Date(lastRead), | ||||
|                                 sessionReadDuration = readDuration, | ||||
|                             ), | ||||
|                         ) | ||||
|                     } | ||||
|             } | ||||
|         } | ||||
|         handler.await(true) { | ||||
|             toUpdate.forEach { payload -> | ||||
|                 historyQueries.upsert( | ||||
|                     payload.chapterId, | ||||
|                     payload.readAt, | ||||
|                     payload.sessionReadDuration, | ||||
|                 ) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Restores the sync of a manga. | ||||
|      * | ||||
|      * @param manga the manga whose sync have to be restored. | ||||
|      * @param tracks the track list to restore. | ||||
|      */ | ||||
|     internal suspend fun restoreTracking(manga: Manga, tracks: List<tachiyomi.domain.track.model.Track>) { | ||||
|         // Get tracks from database | ||||
|         val dbTracks = handler.awaitList { manga_syncQueries.getTracksByMangaId(manga.id) } | ||||
|         val toUpdate = mutableListOf<Manga_sync>() | ||||
|         val toInsert = mutableListOf<tachiyomi.domain.track.model.Track>() | ||||
|  | ||||
|         tracks | ||||
|             // Fix foreign keys with the current manga id | ||||
|             .map { it.copy(mangaId = manga.id) } | ||||
|             .forEach { track -> | ||||
|                 var isInDatabase = false | ||||
|                 for (dbTrack in dbTracks) { | ||||
|                     if (track.syncId == dbTrack.sync_id) { | ||||
|                         // The sync is already in the db, only update its fields | ||||
|                         var temp = dbTrack | ||||
|                         if (track.remoteId != dbTrack.remote_id) { | ||||
|                             temp = temp.copy(remote_id = track.remoteId) | ||||
|                         } | ||||
|                         if (track.libraryId != dbTrack.library_id) { | ||||
|                             temp = temp.copy(library_id = track.libraryId) | ||||
|                         } | ||||
|                         temp = temp.copy(last_chapter_read = max(dbTrack.last_chapter_read, track.lastChapterRead)) | ||||
|                         isInDatabase = true | ||||
|                         toUpdate.add(temp) | ||||
|                         break | ||||
|                     } | ||||
|                 } | ||||
|                 if (!isInDatabase) { | ||||
|                     // Insert new sync. Let the db assign the id | ||||
|                     toInsert.add(track.copy(id = 0)) | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|         // Update database | ||||
|         if (toUpdate.isNotEmpty()) { | ||||
|             handler.await(true) { | ||||
|                 toUpdate.forEach { track -> | ||||
|                     manga_syncQueries.update( | ||||
|                         track.manga_id, | ||||
|                         track.sync_id, | ||||
|                         track.remote_id, | ||||
|                         track.library_id, | ||||
|                         track.title, | ||||
|                         track.last_chapter_read, | ||||
|                         track.total_chapters, | ||||
|                         track.status, | ||||
|                         track.score, | ||||
|                         track.remote_url, | ||||
|                         track.start_date, | ||||
|                         track.finish_date, | ||||
|                         track._id, | ||||
|                     ) | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|         if (toInsert.isNotEmpty()) { | ||||
|             handler.await(true) { | ||||
|                 toInsert.forEach { track -> | ||||
|                     manga_syncQueries.insert( | ||||
|                         track.mangaId, | ||||
|                         track.syncId, | ||||
|                         track.remoteId, | ||||
|                         track.libraryId, | ||||
|                         track.title, | ||||
|                         track.lastChapterRead, | ||||
|                         track.totalChapters, | ||||
|                         track.status, | ||||
|                         track.score, | ||||
|                         track.remoteUrl, | ||||
|                         track.startDate, | ||||
|                         track.finishDate, | ||||
|                     ) | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     internal suspend fun restoreChapters(manga: Manga, chapters: List<tachiyomi.domain.chapter.model.Chapter>) { | ||||
|         val dbChapters = handler.awaitList { chaptersQueries.getChaptersByMangaId(manga.id) } | ||||
|  | ||||
|         val processed = chapters.map { chapter -> | ||||
|             var updatedChapter = chapter | ||||
|             val dbChapter = dbChapters.find { it.url == updatedChapter.url } | ||||
|             if (dbChapter != null) { | ||||
|                 updatedChapter = updatedChapter.copy(id = dbChapter._id) | ||||
|                 updatedChapter = updatedChapter.copyFrom(dbChapter) | ||||
|                 if (dbChapter.read != chapter.read) { | ||||
|                     updatedChapter = updatedChapter.copy(read = chapter.read, lastPageRead = chapter.lastPageRead) | ||||
|                 } else if (updatedChapter.lastPageRead == 0L && dbChapter.last_page_read != 0L) { | ||||
|                     updatedChapter = updatedChapter.copy(lastPageRead = dbChapter.last_page_read) | ||||
|                 } | ||||
|                 if (!updatedChapter.bookmark && dbChapter.bookmark) { | ||||
|                     updatedChapter = updatedChapter.copy(bookmark = true) | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             updatedChapter.copy(mangaId = manga.id) | ||||
|         } | ||||
|  | ||||
|         val newChapters = processed.groupBy { it.id > 0 } | ||||
|         newChapters[true]?.let { updateKnownChapters(it) } | ||||
|         newChapters[false]?.let { insertChapters(it) } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns manga | ||||
|      * | ||||
|      * @return [Manga], null if not found | ||||
|      */ | ||||
|     internal suspend fun getMangaFromDatabase(url: String, source: Long): Mangas? { | ||||
|         return handler.awaitOneOrNull { mangasQueries.getMangaByUrlAndSource(url, source) } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Inserts manga and returns id | ||||
|      * | ||||
|      * @return id of [Manga], null if not found | ||||
|      */ | ||||
|     private suspend fun insertManga(manga: Manga): Long { | ||||
|         return handler.awaitOneExecutable(true) { | ||||
|             mangasQueries.insert( | ||||
|                 source = manga.source, | ||||
|                 url = manga.url, | ||||
|                 artist = manga.artist, | ||||
|                 author = manga.author, | ||||
|                 description = manga.description, | ||||
|                 genre = manga.genre, | ||||
|                 title = manga.title, | ||||
|                 status = manga.status, | ||||
|                 thumbnailUrl = manga.thumbnailUrl, | ||||
|                 favorite = manga.favorite, | ||||
|                 lastUpdate = manga.lastUpdate, | ||||
|                 nextUpdate = 0L, | ||||
|                 calculateInterval = 0L, | ||||
|                 initialized = manga.initialized, | ||||
|                 viewerFlags = manga.viewerFlags, | ||||
|                 chapterFlags = manga.chapterFlags, | ||||
|                 coverLastModified = manga.coverLastModified, | ||||
|                 dateAdded = manga.dateAdded, | ||||
|                 updateStrategy = manga.updateStrategy, | ||||
|             ) | ||||
|             mangasQueries.selectLastInsertedRowId() | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     suspend fun updateManga(manga: Manga): Long { | ||||
|         handler.await(true) { | ||||
|             mangasQueries.update( | ||||
|                 source = manga.source, | ||||
|                 url = manga.url, | ||||
|                 artist = manga.artist, | ||||
|                 author = manga.author, | ||||
|                 description = manga.description, | ||||
|                 genre = manga.genre?.joinToString(separator = ", "), | ||||
|                 title = manga.title, | ||||
|                 status = manga.status, | ||||
|                 thumbnailUrl = manga.thumbnailUrl, | ||||
|                 favorite = manga.favorite, | ||||
|                 lastUpdate = manga.lastUpdate, | ||||
|                 nextUpdate = null, | ||||
|                 calculateInterval = null, | ||||
|                 initialized = manga.initialized, | ||||
|                 viewer = manga.viewerFlags, | ||||
|                 chapterFlags = manga.chapterFlags, | ||||
|                 coverLastModified = manga.coverLastModified, | ||||
|                 dateAdded = manga.dateAdded, | ||||
|                 mangaId = manga.id, | ||||
|                 updateStrategy = manga.updateStrategy.let(UpdateStrategyColumnAdapter::encode), | ||||
|             ) | ||||
|         } | ||||
|         return manga.id | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Inserts list of chapters | ||||
|      */ | ||||
|     private suspend fun insertChapters(chapters: List<tachiyomi.domain.chapter.model.Chapter>) { | ||||
|         handler.await(true) { | ||||
|             chapters.forEach { chapter -> | ||||
|                 chaptersQueries.insert( | ||||
|                     chapter.mangaId, | ||||
|                     chapter.url, | ||||
|                     chapter.name, | ||||
|                     chapter.scanlator, | ||||
|                     chapter.read, | ||||
|                     chapter.bookmark, | ||||
|                     chapter.lastPageRead, | ||||
|                     chapter.chapterNumber, | ||||
|                     chapter.sourceOrder, | ||||
|                     chapter.dateFetch, | ||||
|                     chapter.dateUpload, | ||||
|                 ) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Updates a list of chapters with known database ids | ||||
|      */ | ||||
|     private suspend fun updateKnownChapters(chapters: List<tachiyomi.domain.chapter.model.Chapter>) { | ||||
|         handler.await(true) { | ||||
|             chapters.forEach { chapter -> | ||||
|                 chaptersQueries.update( | ||||
|                     mangaId = null, | ||||
|                     url = null, | ||||
|                     name = null, | ||||
|                     scanlator = null, | ||||
|                     read = chapter.read, | ||||
|                     bookmark = chapter.bookmark, | ||||
|                     lastPageRead = chapter.lastPageRead, | ||||
|                     chapterNumber = null, | ||||
|                     sourceOrder = null, | ||||
|                     dateFetch = null, | ||||
|                     dateUpload = null, | ||||
|                     chapterId = chapter.id, | ||||
|                 ) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -26,7 +26,7 @@ class BackupRestoreJob(private val context: Context, workerParams: WorkerParamet | ||||
|     override suspend fun doWork(): Result { | ||||
|         val uri = inputData.getString(LOCATION_URI_KEY)?.toUri() | ||||
|             ?: return Result.failure() | ||||
|         val sync = inputData.getBoolean(SYNC, false) | ||||
|         val sync = inputData.getBoolean(SYNC_KEY, false) | ||||
|  | ||||
|         try { | ||||
|             setForeground(getForegroundInfo()) | ||||
| @@ -67,7 +67,7 @@ class BackupRestoreJob(private val context: Context, workerParams: WorkerParamet | ||||
|         fun start(context: Context, uri: Uri, sync: Boolean = false) { | ||||
|             val inputData = workDataOf( | ||||
|                 LOCATION_URI_KEY to uri.toString(), | ||||
|                 SYNC to sync, | ||||
|                 SYNC_KEY to sync, | ||||
|             ) | ||||
|             val request = OneTimeWorkRequestBuilder<BackupRestoreJob>() | ||||
|                 .addTag(TAG) | ||||
| @@ -85,5 +85,4 @@ class BackupRestoreJob(private val context: Context, workerParams: WorkerParamet | ||||
| private const val TAG = "BackupRestore" | ||||
|  | ||||
| private const val LOCATION_URI_KEY = "location_uri" // String | ||||
|  | ||||
| private const val SYNC = "sync" // Boolean | ||||
| private const val SYNC_KEY = "sync" // Boolean | ||||
|   | ||||
| @@ -2,19 +2,38 @@ package eu.kanade.tachiyomi.data.backup | ||||
|  | ||||
| import android.content.Context | ||||
| import android.net.Uri | ||||
| import eu.kanade.domain.chapter.model.copyFrom | ||||
| import eu.kanade.domain.manga.interactor.UpdateManga | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.backup.models.BackupCategory | ||||
| import eu.kanade.tachiyomi.data.backup.models.BackupHistory | ||||
| import eu.kanade.tachiyomi.data.backup.models.BackupManga | ||||
| import eu.kanade.tachiyomi.data.backup.models.BackupPreference | ||||
| import eu.kanade.tachiyomi.data.backup.models.BackupSource | ||||
| import eu.kanade.tachiyomi.data.backup.models.BackupSourcePreferences | ||||
| import eu.kanade.tachiyomi.data.backup.models.BooleanPreferenceValue | ||||
| import eu.kanade.tachiyomi.data.backup.models.FloatPreferenceValue | ||||
| import eu.kanade.tachiyomi.data.backup.models.IntPreferenceValue | ||||
| import eu.kanade.tachiyomi.data.backup.models.LongPreferenceValue | ||||
| import eu.kanade.tachiyomi.data.backup.models.StringPreferenceValue | ||||
| import eu.kanade.tachiyomi.data.backup.models.StringSetPreferenceValue | ||||
| import eu.kanade.tachiyomi.source.model.copyFrom | ||||
| import eu.kanade.tachiyomi.source.sourcePreferences | ||||
| import eu.kanade.tachiyomi.util.BackupUtil | ||||
| import eu.kanade.tachiyomi.util.system.createFileInCacheDir | ||||
| import kotlinx.coroutines.coroutineScope | ||||
| import kotlinx.coroutines.isActive | ||||
| import tachiyomi.core.preference.AndroidPreferenceStore | ||||
| import tachiyomi.core.preference.PreferenceStore | ||||
| import tachiyomi.data.DatabaseHandler | ||||
| import tachiyomi.data.Manga_sync | ||||
| import tachiyomi.data.Mangas | ||||
| import tachiyomi.data.UpdateStrategyColumnAdapter | ||||
| import tachiyomi.domain.category.interactor.GetCategories | ||||
| import tachiyomi.domain.chapter.model.Chapter | ||||
| import tachiyomi.domain.chapter.repository.ChapterRepository | ||||
| import tachiyomi.domain.manga.interactor.SetFetchInterval | ||||
| import tachiyomi.domain.history.model.HistoryUpdate | ||||
| import tachiyomi.domain.library.service.LibraryPreferences | ||||
| import tachiyomi.domain.manga.interactor.FetchInterval | ||||
| import tachiyomi.domain.manga.model.Manga | ||||
| import tachiyomi.domain.track.model.Track | ||||
| import uy.kohesive.injekt.Injekt | ||||
| @@ -24,19 +43,23 @@ import java.text.SimpleDateFormat | ||||
| import java.time.ZonedDateTime | ||||
| import java.util.Date | ||||
| import java.util.Locale | ||||
| import kotlin.math.max | ||||
|  | ||||
| class BackupRestorer( | ||||
|     private val context: Context, | ||||
|     private val notifier: BackupNotifier, | ||||
| ) { | ||||
|  | ||||
|     private val handler: DatabaseHandler = Injekt.get() | ||||
|     private val updateManga: UpdateManga = Injekt.get() | ||||
|     private val chapterRepository: ChapterRepository = Injekt.get() | ||||
|     private val setFetchInterval: SetFetchInterval = Injekt.get() | ||||
|     private val getCategories: GetCategories = Injekt.get() | ||||
|     private val fetchInterval: FetchInterval = Injekt.get() | ||||
|  | ||||
|     private val preferenceStore: PreferenceStore = Injekt.get() | ||||
|     private val libraryPreferences: LibraryPreferences = Injekt.get() | ||||
|  | ||||
|     private var now = ZonedDateTime.now() | ||||
|     private var currentFetchWindow = setFetchInterval.getWindow(now) | ||||
|  | ||||
|     private var backupManager = BackupManager(context) | ||||
|     private var currentFetchWindow = fetchInterval.getWindow(now) | ||||
|  | ||||
|     private var restoreAmount = 0 | ||||
|     private var restoreProgress = 0 | ||||
| @@ -92,7 +115,7 @@ class BackupRestorer( | ||||
|     private suspend fun performRestore(uri: Uri, sync: Boolean): Boolean { | ||||
|         val backup = BackupUtil.decodeBackup(context, uri) | ||||
|  | ||||
|         restoreAmount = backup.backupManga.size + 1 // +1 for categories | ||||
|         restoreAmount = backup.backupManga.size + 3 // +3 for categories, app prefs, source prefs | ||||
|  | ||||
|         // Restore categories | ||||
|         if (backup.backupCategories.isNotEmpty()) { | ||||
| @@ -103,9 +126,12 @@ class BackupRestorer( | ||||
|         val backupMaps = backup.backupBrokenSources.map { BackupSource(it.name, it.sourceId) } + backup.backupSources | ||||
|         sourceMapping = backupMaps.associate { it.sourceId to it.name } | ||||
|         now = ZonedDateTime.now() | ||||
|         currentFetchWindow = setFetchInterval.getWindow(now) | ||||
|         currentFetchWindow = fetchInterval.getWindow(now) | ||||
|  | ||||
|         return coroutineScope { | ||||
|             restoreAppPreferences(backup.backupPreferences) | ||||
|             restoreSourcePreferences(backup.backupSourcePreferences) | ||||
|  | ||||
|             // Restore individual manga | ||||
|             backup.backupManga.forEach { | ||||
|                 if (!isActive) { | ||||
| @@ -115,12 +141,44 @@ class BackupRestorer( | ||||
|                 restoreManga(it, backup.backupCategories, sync) | ||||
|             } | ||||
|             // TODO: optionally trigger online library + tracker update | ||||
|  | ||||
|             true | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private suspend fun restoreCategories(backupCategories: List<BackupCategory>) { | ||||
|         backupManager.restoreCategories(backupCategories) | ||||
|         // Get categories from file and from db | ||||
|         val dbCategories = getCategories.await() | ||||
|  | ||||
|         val categories = backupCategories.map { | ||||
|             var category = it.getCategory() | ||||
|             var found = false | ||||
|             for (dbCategory in dbCategories) { | ||||
|                 // If the category is already in the db, assign the id to the file's category | ||||
|                 // and do nothing | ||||
|                 if (category.name == dbCategory.name) { | ||||
|                     category = category.copy(id = dbCategory.id) | ||||
|                     found = true | ||||
|                     break | ||||
|                 } | ||||
|             } | ||||
|             if (!found) { | ||||
|                 // Let the db assign the id | ||||
|                 val id = handler.awaitOneExecutable { | ||||
|                     categoriesQueries.insert(category.name, category.order, category.flags) | ||||
|                     categoriesQueries.selectLastInsertedRowId() | ||||
|                 } | ||||
|                 category = category.copy(id = id) | ||||
|             } | ||||
|  | ||||
|             category | ||||
|         } | ||||
|  | ||||
|         libraryPreferences.categorizedDisplaySettings().set( | ||||
|             (dbCategories + categories) | ||||
|                 .distinctBy { it.flags } | ||||
|                 .size > 1, | ||||
|         ) | ||||
|  | ||||
|         restoreProgress += 1 | ||||
|         showRestoreProgress(restoreProgress, restoreAmount, context.getString(R.string.categories), context.getString(R.string.restoring_backup)) | ||||
| @@ -135,14 +193,14 @@ class BackupRestorer( | ||||
|         val tracks = backupManga.getTrackingImpl() | ||||
|  | ||||
|         try { | ||||
|             val dbManga = backupManager.getMangaFromDatabase(manga.url, manga.source) | ||||
|             val dbManga = getMangaFromDatabase(manga.url, manga.source) | ||||
|             val restoredManga = if (dbManga == null) { | ||||
|                 // Manga not in database | ||||
|                 restoreExistingManga(manga, chapters, categories, history, tracks, backupCategories) | ||||
|             } else { | ||||
|                 // Manga in database | ||||
|                 // Copy information from manga already in database | ||||
|                 val updatedManga = backupManager.restoreExistingManga(manga, dbManga) | ||||
|                 val updatedManga = restoreExistingManga(manga, dbManga) | ||||
|                 // Fetch rest of manga information | ||||
|                 restoreNewManga(updatedManga, chapters, categories, history, tracks, backupCategories) | ||||
|             } | ||||
| @@ -160,6 +218,50 @@ class BackupRestorer( | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns manga | ||||
|      * | ||||
|      * @return [Manga], null if not found | ||||
|      */ | ||||
|     private suspend fun getMangaFromDatabase(url: String, source: Long): Mangas? { | ||||
|         return handler.awaitOneOrNull { mangasQueries.getMangaByUrlAndSource(url, source) } | ||||
|     } | ||||
|  | ||||
|     private suspend fun restoreExistingManga(manga: Manga, dbManga: Mangas): Manga { | ||||
|         var updatedManga = manga.copy(id = dbManga._id) | ||||
|         updatedManga = updatedManga.copyFrom(dbManga) | ||||
|         updateManga(updatedManga) | ||||
|         return updatedManga | ||||
|     } | ||||
|  | ||||
|     private suspend fun updateManga(manga: Manga): Long { | ||||
|         handler.await(true) { | ||||
|             mangasQueries.update( | ||||
|                 source = manga.source, | ||||
|                 url = manga.url, | ||||
|                 artist = manga.artist, | ||||
|                 author = manga.author, | ||||
|                 description = manga.description, | ||||
|                 genre = manga.genre?.joinToString(separator = ", "), | ||||
|                 title = manga.title, | ||||
|                 status = manga.status, | ||||
|                 thumbnailUrl = manga.thumbnailUrl, | ||||
|                 favorite = manga.favorite, | ||||
|                 lastUpdate = manga.lastUpdate, | ||||
|                 nextUpdate = null, | ||||
|                 calculateInterval = null, | ||||
|                 initialized = manga.initialized, | ||||
|                 viewer = manga.viewerFlags, | ||||
|                 chapterFlags = manga.chapterFlags, | ||||
|                 coverLastModified = manga.coverLastModified, | ||||
|                 dateAdded = manga.dateAdded, | ||||
|                 mangaId = manga.id, | ||||
|                 updateStrategy = manga.updateStrategy.let(UpdateStrategyColumnAdapter::encode), | ||||
|             ) | ||||
|         } | ||||
|         return manga.id | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Fetches manga information | ||||
|      * | ||||
| @@ -175,12 +277,131 @@ class BackupRestorer( | ||||
|         tracks: List<Track>, | ||||
|         backupCategories: List<BackupCategory>, | ||||
|     ): Manga { | ||||
|         val fetchedManga = backupManager.restoreNewManga(manga) | ||||
|         backupManager.restoreChapters(fetchedManga, chapters) | ||||
|         val fetchedManga = restoreNewManga(manga) | ||||
|         restoreChapters(fetchedManga, chapters) | ||||
|         restoreExtras(fetchedManga, categories, history, tracks, backupCategories) | ||||
|         return fetchedManga | ||||
|     } | ||||
|  | ||||
|     private suspend fun restoreChapters(manga: Manga, chapters: List<Chapter>) { | ||||
|         val dbChapters = handler.awaitList { chaptersQueries.getChaptersByMangaId(manga.id) } | ||||
|  | ||||
|         val processed = chapters.map { chapter -> | ||||
|             var updatedChapter = chapter | ||||
|             val dbChapter = dbChapters.find { it.url == updatedChapter.url } | ||||
|             if (dbChapter != null) { | ||||
|                 updatedChapter = updatedChapter.copy(id = dbChapter._id) | ||||
|                 updatedChapter = updatedChapter.copyFrom(dbChapter) | ||||
|                 if (dbChapter.read && !updatedChapter.read) { | ||||
|                     updatedChapter = updatedChapter.copy(read = true, lastPageRead = dbChapter.last_page_read) | ||||
|                 } else if (updatedChapter.lastPageRead == 0L && dbChapter.last_page_read != 0L) { | ||||
|                     updatedChapter = updatedChapter.copy(lastPageRead = dbChapter.last_page_read) | ||||
|                 } | ||||
|                 if (!updatedChapter.bookmark && dbChapter.bookmark) { | ||||
|                     updatedChapter = updatedChapter.copy(bookmark = true) | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             updatedChapter.copy(mangaId = manga.id) | ||||
|         } | ||||
|  | ||||
|         val newChapters = processed.groupBy { it.id > 0 } | ||||
|         newChapters[true]?.let { updateKnownChapters(it) } | ||||
|         newChapters[false]?.let { insertChapters(it) } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Inserts list of chapters | ||||
|      */ | ||||
|     private suspend fun insertChapters(chapters: List<Chapter>) { | ||||
|         handler.await(true) { | ||||
|             chapters.forEach { chapter -> | ||||
|                 chaptersQueries.insert( | ||||
|                     chapter.mangaId, | ||||
|                     chapter.url, | ||||
|                     chapter.name, | ||||
|                     chapter.scanlator, | ||||
|                     chapter.read, | ||||
|                     chapter.bookmark, | ||||
|                     chapter.lastPageRead, | ||||
|                     chapter.chapterNumber, | ||||
|                     chapter.sourceOrder, | ||||
|                     chapter.dateFetch, | ||||
|                     chapter.dateUpload, | ||||
|                 ) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Updates a list of chapters with known database ids | ||||
|      */ | ||||
|     private suspend fun updateKnownChapters(chapters: List<Chapter>) { | ||||
|         handler.await(true) { | ||||
|             chapters.forEach { chapter -> | ||||
|                 chaptersQueries.update( | ||||
|                     mangaId = null, | ||||
|                     url = null, | ||||
|                     name = null, | ||||
|                     scanlator = null, | ||||
|                     read = chapter.read, | ||||
|                     bookmark = chapter.bookmark, | ||||
|                     lastPageRead = chapter.lastPageRead, | ||||
|                     chapterNumber = null, | ||||
|                     sourceOrder = null, | ||||
|                     dateFetch = null, | ||||
|                     dateUpload = null, | ||||
|                     chapterId = chapter.id, | ||||
|                 ) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Fetches manga information | ||||
|      * | ||||
|      * @param manga manga that needs updating | ||||
|      * @return Updated manga info. | ||||
|      */ | ||||
|     private suspend fun restoreNewManga(manga: Manga): Manga { | ||||
|         return manga.copy( | ||||
|             initialized = manga.description != null, | ||||
|             id = insertManga(manga), | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Inserts manga and returns id | ||||
|      * | ||||
|      * @return id of [Manga], null if not found | ||||
|      */ | ||||
|     private suspend fun insertManga(manga: Manga): Long { | ||||
|         return handler.awaitOneExecutable(true) { | ||||
|             mangasQueries.insert( | ||||
|                 source = manga.source, | ||||
|                 url = manga.url, | ||||
|                 artist = manga.artist, | ||||
|                 author = manga.author, | ||||
|                 description = manga.description, | ||||
|                 genre = manga.genre, | ||||
|                 title = manga.title, | ||||
|                 status = manga.status, | ||||
|                 thumbnailUrl = manga.thumbnailUrl, | ||||
|                 favorite = manga.favorite, | ||||
|                 lastUpdate = manga.lastUpdate, | ||||
|                 nextUpdate = 0L, | ||||
|                 calculateInterval = 0L, | ||||
|                 initialized = manga.initialized, | ||||
|                 viewerFlags = manga.viewerFlags, | ||||
|                 chapterFlags = manga.chapterFlags, | ||||
|                 coverLastModified = manga.coverLastModified, | ||||
|                 dateAdded = manga.dateAdded, | ||||
|                 updateStrategy = manga.updateStrategy, | ||||
|             ) | ||||
|             mangasQueries.selectLastInsertedRowId() | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private suspend fun restoreNewManga( | ||||
|         backupManga: Manga, | ||||
|         chapters: List<Chapter>, | ||||
| @@ -189,24 +410,240 @@ class BackupRestorer( | ||||
|         tracks: List<Track>, | ||||
|         backupCategories: List<BackupCategory>, | ||||
|     ): Manga { | ||||
|         backupManager.restoreChapters(backupManga, chapters) | ||||
|         restoreChapters(backupManga, chapters) | ||||
|         restoreExtras(backupManga, categories, history, tracks, backupCategories) | ||||
|         return backupManga | ||||
|     } | ||||
|  | ||||
|     private suspend fun restoreExtras(manga: Manga, categories: List<Int>, history: List<BackupHistory>, tracks: List<Track>, backupCategories: List<BackupCategory>) { | ||||
|         backupManager.restoreCategories(manga, categories, backupCategories) | ||||
|         backupManager.restoreHistory(history) | ||||
|         backupManager.restoreTracking(manga, tracks) | ||||
|         restoreCategories(manga, categories, backupCategories) | ||||
|         restoreHistory(history) | ||||
|         restoreTracking(manga, tracks) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Called to update dialog in [BackupConst] | ||||
|      * Restores the categories a manga is in. | ||||
|      * | ||||
|      * @param progress restore progress | ||||
|      * @param amount total restoreAmount of manga | ||||
|      * @param title title of restored manga | ||||
|      * @param manga the manga whose categories have to be restored. | ||||
|      * @param categories the categories to restore. | ||||
|      */ | ||||
|     private suspend fun restoreCategories(manga: Manga, categories: List<Int>, backupCategories: List<BackupCategory>) { | ||||
|         val dbCategories = getCategories.await() | ||||
|         val mangaCategoriesToUpdate = mutableListOf<Pair<Long, Long>>() | ||||
|  | ||||
|         categories.forEach { backupCategoryOrder -> | ||||
|             backupCategories.firstOrNull { | ||||
|                 it.order == backupCategoryOrder.toLong() | ||||
|             }?.let { backupCategory -> | ||||
|                 dbCategories.firstOrNull { dbCategory -> | ||||
|                     dbCategory.name == backupCategory.name | ||||
|                 }?.let { dbCategory -> | ||||
|                     mangaCategoriesToUpdate.add(Pair(manga.id, dbCategory.id)) | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         // Update database | ||||
|         if (mangaCategoriesToUpdate.isNotEmpty()) { | ||||
|             handler.await(true) { | ||||
|                 mangas_categoriesQueries.deleteMangaCategoryByMangaId(manga.id) | ||||
|                 mangaCategoriesToUpdate.forEach { (mangaId, categoryId) -> | ||||
|                     mangas_categoriesQueries.insert(mangaId, categoryId) | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Restore history from Json | ||||
|      * | ||||
|      * @param history list containing history to be restored | ||||
|      */ | ||||
|     private suspend fun restoreHistory(history: List<BackupHistory>) { | ||||
|         // List containing history to be updated | ||||
|         val toUpdate = mutableListOf<HistoryUpdate>() | ||||
|         for ((url, lastRead, readDuration) in history) { | ||||
|             var dbHistory = handler.awaitOneOrNull { historyQueries.getHistoryByChapterUrl(url) } | ||||
|             // Check if history already in database and update | ||||
|             if (dbHistory != null) { | ||||
|                 dbHistory = dbHistory.copy( | ||||
|                     last_read = Date(max(lastRead, dbHistory.last_read?.time ?: 0L)), | ||||
|                     time_read = max(readDuration, dbHistory.time_read) - dbHistory.time_read, | ||||
|                 ) | ||||
|                 toUpdate.add( | ||||
|                     HistoryUpdate( | ||||
|                         chapterId = dbHistory.chapter_id, | ||||
|                         readAt = dbHistory.last_read!!, | ||||
|                         sessionReadDuration = dbHistory.time_read, | ||||
|                     ), | ||||
|                 ) | ||||
|             } else { | ||||
|                 // If not in database create | ||||
|                 handler | ||||
|                     .awaitOneOrNull { chaptersQueries.getChapterByUrl(url) } | ||||
|                     ?.let { | ||||
|                         toUpdate.add( | ||||
|                             HistoryUpdate( | ||||
|                                 chapterId = it._id, | ||||
|                                 readAt = Date(lastRead), | ||||
|                                 sessionReadDuration = readDuration, | ||||
|                             ), | ||||
|                         ) | ||||
|                     } | ||||
|             } | ||||
|         } | ||||
|         handler.await(true) { | ||||
|             toUpdate.forEach { payload -> | ||||
|                 historyQueries.upsert( | ||||
|                     payload.chapterId, | ||||
|                     payload.readAt, | ||||
|                     payload.sessionReadDuration, | ||||
|                 ) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Restores the sync of a manga. | ||||
|      * | ||||
|      * @param manga the manga whose sync have to be restored. | ||||
|      * @param tracks the track list to restore. | ||||
|      */ | ||||
|     private suspend fun restoreTracking(manga: Manga, tracks: List<Track>) { | ||||
|         // Get tracks from database | ||||
|         val dbTracks = handler.awaitList { manga_syncQueries.getTracksByMangaId(manga.id) } | ||||
|         val toUpdate = mutableListOf<Manga_sync>() | ||||
|         val toInsert = mutableListOf<Track>() | ||||
|  | ||||
|         tracks | ||||
|             // Fix foreign keys with the current manga id | ||||
|             .map { it.copy(mangaId = manga.id) } | ||||
|             .forEach { track -> | ||||
|                 var isInDatabase = false | ||||
|                 for (dbTrack in dbTracks) { | ||||
|                     if (track.syncId == dbTrack.sync_id) { | ||||
|                         // The sync is already in the db, only update its fields | ||||
|                         var temp = dbTrack | ||||
|                         if (track.remoteId != dbTrack.remote_id) { | ||||
|                             temp = temp.copy(remote_id = track.remoteId) | ||||
|                         } | ||||
|                         if (track.libraryId != dbTrack.library_id) { | ||||
|                             temp = temp.copy(library_id = track.libraryId) | ||||
|                         } | ||||
|                         temp = temp.copy(last_chapter_read = max(dbTrack.last_chapter_read, track.lastChapterRead)) | ||||
|                         isInDatabase = true | ||||
|                         toUpdate.add(temp) | ||||
|                         break | ||||
|                     } | ||||
|                 } | ||||
|                 if (!isInDatabase) { | ||||
|                     // Insert new sync. Let the db assign the id | ||||
|                     toInsert.add(track.copy(id = 0)) | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|         // Update database | ||||
|         if (toUpdate.isNotEmpty()) { | ||||
|             handler.await(true) { | ||||
|                 toUpdate.forEach { track -> | ||||
|                     manga_syncQueries.update( | ||||
|                         track.manga_id, | ||||
|                         track.sync_id, | ||||
|                         track.remote_id, | ||||
|                         track.library_id, | ||||
|                         track.title, | ||||
|                         track.last_chapter_read, | ||||
|                         track.total_chapters, | ||||
|                         track.status, | ||||
|                         track.score, | ||||
|                         track.remote_url, | ||||
|                         track.start_date, | ||||
|                         track.finish_date, | ||||
|                         track._id, | ||||
|                     ) | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|         if (toInsert.isNotEmpty()) { | ||||
|             handler.await(true) { | ||||
|                 toInsert.forEach { track -> | ||||
|                     manga_syncQueries.insert( | ||||
|                         track.mangaId, | ||||
|                         track.syncId, | ||||
|                         track.remoteId, | ||||
|                         track.libraryId, | ||||
|                         track.title, | ||||
|                         track.lastChapterRead, | ||||
|                         track.totalChapters, | ||||
|                         track.status, | ||||
|                         track.score, | ||||
|                         track.remoteUrl, | ||||
|                         track.startDate, | ||||
|                         track.finishDate, | ||||
|                     ) | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private fun restoreAppPreferences(preferences: List<BackupPreference>) { | ||||
|         restorePreferences(preferences, preferenceStore) | ||||
|  | ||||
|         restoreProgress += 1 | ||||
|         showRestoreProgress(restoreProgress, restoreAmount, context.getString(R.string.app_settings), context.getString(R.string.restoring_backup)) | ||||
|     } | ||||
|  | ||||
|     private fun restoreSourcePreferences(preferences: List<BackupSourcePreferences>) { | ||||
|         preferences.forEach { | ||||
|             val sourcePrefs = AndroidPreferenceStore(context, sourcePreferences(it.sourceKey)) | ||||
|             restorePreferences(it.prefs, sourcePrefs) | ||||
|         } | ||||
|  | ||||
|         restoreProgress += 1 | ||||
|         showRestoreProgress(restoreProgress, restoreAmount, context.getString(R.string.source_settings), context.getString(R.string.restoring_backup)) | ||||
|     } | ||||
|  | ||||
|     private fun restorePreferences( | ||||
|         toRestore: List<BackupPreference>, | ||||
|         preferenceStore: PreferenceStore, | ||||
|     ) { | ||||
|         val prefs = preferenceStore.getAll() | ||||
|         toRestore.forEach { (key, value) -> | ||||
|             when (value) { | ||||
|                 is IntPreferenceValue -> { | ||||
|                     if (prefs[key] is Int?) { | ||||
|                         preferenceStore.getInt(key).set(value.value) | ||||
|                     } | ||||
|                 } | ||||
|                 is LongPreferenceValue -> { | ||||
|                     if (prefs[key] is Long?) { | ||||
|                         preferenceStore.getLong(key).set(value.value) | ||||
|                     } | ||||
|                 } | ||||
|                 is FloatPreferenceValue -> { | ||||
|                     if (prefs[key] is Float?) { | ||||
|                         preferenceStore.getFloat(key).set(value.value) | ||||
|                     } | ||||
|                 } | ||||
|                 is StringPreferenceValue -> { | ||||
|                     if (prefs[key] is String?) { | ||||
|                         preferenceStore.getString(key).set(value.value) | ||||
|                     } | ||||
|                 } | ||||
|                 is BooleanPreferenceValue -> { | ||||
|                     if (prefs[key] is Boolean?) { | ||||
|                         preferenceStore.getBoolean(key).set(value.value) | ||||
|                     } | ||||
|                 } | ||||
|                 is StringSetPreferenceValue -> { | ||||
|                     if (prefs[key] is Set<*>?) { | ||||
|                         preferenceStore.getStringSet(key).set(value.value) | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private fun showRestoreProgress(progress: Int, amount: Int, title: String, contentTitle: String) { | ||||
|         notifier.showRestoreProgress(title, contentTitle, progress, amount) | ||||
|     } | ||||
|   | ||||
| @@ -1,5 +1,6 @@ | ||||
| package eu.kanade.tachiyomi.data.backup.models | ||||
|  | ||||
| import eu.kanade.tachiyomi.BuildConfig | ||||
| import kotlinx.serialization.Serializable | ||||
| import kotlinx.serialization.protobuf.ProtoNumber | ||||
| import java.text.SimpleDateFormat | ||||
| @@ -10,15 +11,18 @@ import java.util.Locale | ||||
| data class Backup( | ||||
|     @ProtoNumber(1) val backupManga: List<BackupManga>, | ||||
|     @ProtoNumber(2) var backupCategories: List<BackupCategory> = emptyList(), | ||||
|     // Bump by 100 to specify this is a 0.x value | ||||
|     @ProtoNumber(100) var backupBrokenSources: List<BrokenBackupSource> = emptyList(), | ||||
|     @ProtoNumber(101) var backupSources: List<BackupSource> = emptyList(), | ||||
|     @ProtoNumber(104) var backupPreferences: List<BackupPreference> = emptyList(), | ||||
|     @ProtoNumber(105) var backupSourcePreferences: List<BackupSourcePreferences> = emptyList(), | ||||
| ) { | ||||
|  | ||||
|     companion object { | ||||
|         fun getBackupFilename(): String { | ||||
|         val filenameRegex = """${BuildConfig.APPLICATION_ID}_\d+-\d+-\d+_\d+-\d+.tachibk""".toRegex() | ||||
|  | ||||
|         fun getFilename(): String { | ||||
|             val date = SimpleDateFormat("yyyy-MM-dd_HH-mm", Locale.getDefault()).format(Date()) | ||||
|             return "tachiyomi_$date.proto.gz" | ||||
|             return "${BuildConfig.APPLICATION_ID}_$date.tachibk" | ||||
|         } | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -9,7 +9,6 @@ class BackupCategory( | ||||
|     @ProtoNumber(1) var name: String, | ||||
|     @ProtoNumber(2) var order: Long = 0, | ||||
|     // @ProtoNumber(3) val updateInterval: Int = 0, 1.x value not used in 0.x | ||||
|     // Bump by 100 to specify this is a 0.x value | ||||
|     @ProtoNumber(100) var flags: Long = 0, | ||||
| ) { | ||||
|     fun getCategory(): Category { | ||||
|   | ||||
| @@ -0,0 +1,37 @@ | ||||
| package eu.kanade.tachiyomi.data.backup.models | ||||
|  | ||||
| import kotlinx.serialization.Serializable | ||||
| import kotlinx.serialization.protobuf.ProtoNumber | ||||
|  | ||||
| @Serializable | ||||
| data class BackupPreference( | ||||
|     @ProtoNumber(1) val key: String, | ||||
|     @ProtoNumber(2) val value: PreferenceValue, | ||||
| ) | ||||
|  | ||||
| @Serializable | ||||
| data class BackupSourcePreferences( | ||||
|     @ProtoNumber(1) val sourceKey: String, | ||||
|     @ProtoNumber(2) val prefs: List<BackupPreference>, | ||||
| ) | ||||
|  | ||||
| @Serializable | ||||
| sealed class PreferenceValue | ||||
|  | ||||
| @Serializable | ||||
| data class IntPreferenceValue(val value: Int) : PreferenceValue() | ||||
|  | ||||
| @Serializable | ||||
| data class LongPreferenceValue(val value: Long) : PreferenceValue() | ||||
|  | ||||
| @Serializable | ||||
| data class FloatPreferenceValue(val value: Float) : PreferenceValue() | ||||
|  | ||||
| @Serializable | ||||
| data class StringPreferenceValue(val value: String) : PreferenceValue() | ||||
|  | ||||
| @Serializable | ||||
| data class BooleanPreferenceValue(val value: Boolean) : PreferenceValue() | ||||
|  | ||||
| @Serializable | ||||
| data class StringSetPreferenceValue(val value: Set<String>) : PreferenceValue() | ||||
| @@ -8,9 +8,11 @@ import eu.kanade.tachiyomi.util.storage.DiskUtil | ||||
| import eu.kanade.tachiyomi.util.storage.saveTo | ||||
| import kotlinx.serialization.encodeToString | ||||
| import kotlinx.serialization.json.Json | ||||
| import logcat.LogPriority | ||||
| import okhttp3.Response | ||||
| import okio.buffer | ||||
| import okio.sink | ||||
| import tachiyomi.core.util.system.logcat | ||||
| import tachiyomi.domain.chapter.model.Chapter | ||||
| import uy.kohesive.injekt.injectLazy | ||||
| import java.io.File | ||||
| @@ -97,6 +99,7 @@ class ChapterCache(private val context: Context) { | ||||
|             editor.commit() | ||||
|             editor.abortUnlessCommitted() | ||||
|         } catch (e: Exception) { | ||||
|             logcat(LogPriority.WARN, e) { "Failed to put page list to cache" } | ||||
|             // Ignore. | ||||
|         } finally { | ||||
|             editor?.abortUnlessCommitted() | ||||
| @@ -174,7 +177,7 @@ class ChapterCache(private val context: Context) { | ||||
|      * @return status of deletion for the file. | ||||
|      */ | ||||
|     private fun removeFileFromCache(file: String): Boolean { | ||||
|         // Make sure we don't delete the journal file (keeps track of cache). | ||||
|         // Make sure we don't delete the journal file (keeps track of cache) | ||||
|         if (file == "journal" || file.startsWith("journal.")) { | ||||
|             return false | ||||
|         } | ||||
| @@ -182,9 +185,10 @@ class ChapterCache(private val context: Context) { | ||||
|         return try { | ||||
|             // Remove the extension from the file to get the key of the cache | ||||
|             val key = file.substringBeforeLast(".") | ||||
|             // Remove file from cache. | ||||
|             // Remove file from cache | ||||
|             diskCache.remove(key) | ||||
|         } catch (e: Exception) { | ||||
|             logcat(LogPriority.WARN, e) { "Failed to remove file from cache" } | ||||
|             false | ||||
|         } | ||||
|     } | ||||
|   | ||||
| @@ -43,7 +43,6 @@ import nl.adaptivity.xmlutil.serialization.XML | ||||
| import okhttp3.Response | ||||
| import tachiyomi.core.metadata.comicinfo.COMIC_INFO_FILE | ||||
| import tachiyomi.core.metadata.comicinfo.ComicInfo | ||||
| import tachiyomi.core.util.lang.awaitSingle | ||||
| import tachiyomi.core.util.lang.launchIO | ||||
| import tachiyomi.core.util.lang.launchNow | ||||
| import tachiyomi.core.util.lang.withIOContext | ||||
| @@ -363,7 +362,7 @@ class Downloader( | ||||
|                         if (page.imageUrl.isNullOrEmpty()) { | ||||
|                             page.status = Page.State.LOAD_PAGE | ||||
|                             try { | ||||
|                                 page.imageUrl = download.source.fetchImageUrl(page).awaitSingle() | ||||
|                                 page.imageUrl = download.source.getImageUrl(page) | ||||
|                             } catch (e: Throwable) { | ||||
|                                 page.status = Page.State.ERROR | ||||
|                             } | ||||
|   | ||||
| @@ -59,9 +59,9 @@ import tachiyomi.domain.library.service.LibraryPreferences.Companion.MANGA_HAS_U | ||||
| import tachiyomi.domain.library.service.LibraryPreferences.Companion.MANGA_NON_COMPLETED | ||||
| import tachiyomi.domain.library.service.LibraryPreferences.Companion.MANGA_NON_READ | ||||
| import tachiyomi.domain.library.service.LibraryPreferences.Companion.MANGA_OUTSIDE_RELEASE_PERIOD | ||||
| import tachiyomi.domain.manga.interactor.FetchInterval | ||||
| import tachiyomi.domain.manga.interactor.GetLibraryManga | ||||
| import tachiyomi.domain.manga.interactor.GetManga | ||||
| import tachiyomi.domain.manga.interactor.SetFetchInterval | ||||
| import tachiyomi.domain.manga.model.Manga | ||||
| import tachiyomi.domain.manga.model.toMangaUpdate | ||||
| import tachiyomi.domain.source.model.SourceNotInstalledException | ||||
| @@ -90,7 +90,7 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet | ||||
|     private val getCategories: GetCategories = Injekt.get() | ||||
|     private val syncChaptersWithSource: SyncChaptersWithSource = Injekt.get() | ||||
|     private val refreshTracks: RefreshTracks = Injekt.get() | ||||
|     private val setFetchInterval: SetFetchInterval = Injekt.get() | ||||
|     private val fetchInterval: FetchInterval = Injekt.get() | ||||
|  | ||||
|     private val notifier = LibraryUpdateNotifier(context) | ||||
|  | ||||
| @@ -186,7 +186,40 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet | ||||
|                 .distinctBy { it.manga.id } | ||||
|         } | ||||
|  | ||||
|         val restrictions = libraryPreferences.autoUpdateMangaRestrictions().get() | ||||
|         val skippedUpdates = mutableListOf<Pair<Manga, String?>>() | ||||
|         val fetchWindow = fetchInterval.getWindow(ZonedDateTime.now()) | ||||
|  | ||||
|         mangaToUpdate = listToUpdate | ||||
|             .filter { | ||||
|                 when { | ||||
|                     it.manga.updateStrategy != UpdateStrategy.ALWAYS_UPDATE -> { | ||||
|                         skippedUpdates.add(it.manga to context.getString(R.string.skipped_reason_not_always_update)) | ||||
|                         false | ||||
|                     } | ||||
|  | ||||
|                     MANGA_NON_COMPLETED in restrictions && it.manga.status.toInt() == SManga.COMPLETED -> { | ||||
|                         skippedUpdates.add(it.manga to context.getString(R.string.skipped_reason_completed)) | ||||
|                         false | ||||
|                     } | ||||
|  | ||||
|                     MANGA_HAS_UNREAD in restrictions && it.unreadCount != 0L -> { | ||||
|                         skippedUpdates.add(it.manga to context.getString(R.string.skipped_reason_not_caught_up)) | ||||
|                         false | ||||
|                     } | ||||
|  | ||||
|                     MANGA_NON_READ in restrictions && it.totalChapters > 0L && !it.hasStarted -> { | ||||
|                         skippedUpdates.add(it.manga to context.getString(R.string.skipped_reason_not_started)) | ||||
|                         false | ||||
|                     } | ||||
|  | ||||
|                     MANGA_OUTSIDE_RELEASE_PERIOD in restrictions && it.manga.nextUpdate > fetchWindow.second -> { | ||||
|                         skippedUpdates.add(it.manga to context.getString(R.string.skipped_reason_not_in_release_period)) | ||||
|                         false | ||||
|                     } | ||||
|                     else -> true | ||||
|                 } | ||||
|             } | ||||
|             .sortedBy { it.manga.title } | ||||
|  | ||||
|         // Warn when excessively checking a single source | ||||
| @@ -197,6 +230,17 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet | ||||
|         if (maxUpdatesFromSource > MANGA_PER_SOURCE_QUEUE_WARNING_THRESHOLD) { | ||||
|             notifier.showQueueSizeWarningNotification() | ||||
|         } | ||||
|  | ||||
|         if (skippedUpdates.isNotEmpty()) { | ||||
|             // TODO: surface skipped reasons to user? | ||||
|             logcat { | ||||
|                 skippedUpdates | ||||
|                     .groupBy { it.second } | ||||
|                     .map { (reason, entries) -> "$reason: [${entries.map { it.first.title }.sorted().joinToString()}]" } | ||||
|                     .joinToString() | ||||
|             } | ||||
|             notifier.showUpdateSkippedNotification(skippedUpdates.size) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @@ -212,11 +256,9 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet | ||||
|         val progressCount = AtomicInteger(0) | ||||
|         val currentlyUpdatingManga = CopyOnWriteArrayList<Manga>() | ||||
|         val newUpdates = CopyOnWriteArrayList<Pair<Manga, Array<Chapter>>>() | ||||
|         val skippedUpdates = CopyOnWriteArrayList<Pair<Manga, String?>>() | ||||
|         val failedUpdates = CopyOnWriteArrayList<Pair<Manga, String?>>() | ||||
|         val hasDownloads = AtomicBoolean(false) | ||||
|         val restrictions = libraryPreferences.autoUpdateMangaRestrictions().get() | ||||
|         val fetchWindow = setFetchInterval.getWindow(ZonedDateTime.now()) | ||||
|         val fetchWindow = fetchInterval.getWindow(ZonedDateTime.now()) | ||||
|  | ||||
|         coroutineScope { | ||||
|             mangaToUpdate.groupBy { it.manga.source }.values | ||||
| @@ -237,49 +279,30 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet | ||||
|                                     progressCount, | ||||
|                                     manga, | ||||
|                                 ) { | ||||
|                                     when { | ||||
|                                         manga.updateStrategy != UpdateStrategy.ALWAYS_UPDATE -> | ||||
|                                             skippedUpdates.add(manga to context.getString(R.string.skipped_reason_not_always_update)) | ||||
|                                     try { | ||||
|                                         val newChapters = updateManga(manga, fetchWindow) | ||||
|                                             .sortedByDescending { it.sourceOrder } | ||||
|  | ||||
|                                         MANGA_NON_COMPLETED in restrictions && manga.status.toInt() == SManga.COMPLETED -> | ||||
|                                             skippedUpdates.add(manga to context.getString(R.string.skipped_reason_completed)) | ||||
|  | ||||
|                                         MANGA_HAS_UNREAD in restrictions && libraryManga.unreadCount != 0L -> | ||||
|                                             skippedUpdates.add(manga to context.getString(R.string.skipped_reason_not_caught_up)) | ||||
|  | ||||
|                                         MANGA_NON_READ in restrictions && libraryManga.totalChapters > 0L && !libraryManga.hasStarted -> | ||||
|                                             skippedUpdates.add(manga to context.getString(R.string.skipped_reason_not_started)) | ||||
|  | ||||
|                                         MANGA_OUTSIDE_RELEASE_PERIOD in restrictions && manga.nextUpdate > fetchWindow.second -> | ||||
|                                             skippedUpdates.add(manga to context.getString(R.string.skipped_reason_not_in_release_period)) | ||||
|  | ||||
|                                         else -> { | ||||
|                                             try { | ||||
|                                                 val newChapters = updateManga(manga, fetchWindow) | ||||
|                                                     .sortedByDescending { it.sourceOrder } | ||||
|  | ||||
|                                                 if (newChapters.isNotEmpty()) { | ||||
|                                                     val categoryIds = getCategories.await(manga.id).map { it.id } | ||||
|                                                     if (manga.shouldDownloadNewChapters(categoryIds, downloadPreferences)) { | ||||
|                                                         downloadChapters(manga, newChapters) | ||||
|                                                         hasDownloads.set(true) | ||||
|                                                     } | ||||
|  | ||||
|                                                     libraryPreferences.newUpdatesCount().getAndSet { it + newChapters.size } | ||||
|  | ||||
|                                                     // Convert to the manga that contains new chapters | ||||
|                                                     newUpdates.add(manga to newChapters.toTypedArray()) | ||||
|                                                 } | ||||
|                                             } catch (e: Throwable) { | ||||
|                                                 val errorMessage = when (e) { | ||||
|                                                     is NoChaptersException -> context.getString(R.string.no_chapters_error) | ||||
|                                                     // failedUpdates will already have the source, don't need to copy it into the message | ||||
|                                                     is SourceNotInstalledException -> context.getString(R.string.loader_not_implemented_error) | ||||
|                                                     else -> e.message | ||||
|                                                 } | ||||
|                                                 failedUpdates.add(manga to errorMessage) | ||||
|                                         if (newChapters.isNotEmpty()) { | ||||
|                                             val categoryIds = getCategories.await(manga.id).map { it.id } | ||||
|                                             if (manga.shouldDownloadNewChapters(categoryIds, downloadPreferences)) { | ||||
|                                                 downloadChapters(manga, newChapters) | ||||
|                                                 hasDownloads.set(true) | ||||
|                                             } | ||||
|  | ||||
|                                             libraryPreferences.newUpdatesCount().getAndSet { it + newChapters.size } | ||||
|  | ||||
|                                             // Convert to the manga that contains new chapters | ||||
|                                             newUpdates.add(manga to newChapters.toTypedArray()) | ||||
|                                         } | ||||
|                                     } catch (e: Throwable) { | ||||
|                                         val errorMessage = when (e) { | ||||
|                                             is NoChaptersException -> context.getString(R.string.no_chapters_error) | ||||
|                                             // failedUpdates will already have the source, don't need to copy it into the message | ||||
|                                             is SourceNotInstalledException -> context.getString(R.string.loader_not_implemented_error) | ||||
|                                             else -> e.message | ||||
|                                         } | ||||
|                                         failedUpdates.add(manga to errorMessage) | ||||
|                                     } | ||||
|  | ||||
|                                     if (libraryPreferences.autoUpdateTrackers().get()) { | ||||
| @@ -309,16 +332,6 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet | ||||
|                 errorFile.getUriCompat(context), | ||||
|             ) | ||||
|         } | ||||
|         if (skippedUpdates.isNotEmpty()) { | ||||
|             // TODO: surface skipped reasons to user | ||||
|             logcat { | ||||
|                 skippedUpdates | ||||
|                     .groupBy { it.second } | ||||
|                     .map { (reason, entries) -> "$reason: [${entries.map { it.first.title }.sorted().joinToString()}]" } | ||||
|                     .joinToString() | ||||
|             } | ||||
|             notifier.showUpdateSkippedNotification(skippedUpdates.size) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private fun downloadChapters(manga: Manga, chapters: List<Chapter>) { | ||||
| @@ -428,29 +441,27 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet | ||||
|         completed: AtomicInteger, | ||||
|         manga: Manga, | ||||
|         block: suspend () -> Unit, | ||||
|     ) { | ||||
|         coroutineScope { | ||||
|             ensureActive() | ||||
|     ) = coroutineScope { | ||||
|         ensureActive() | ||||
|  | ||||
|             updatingManga.add(manga) | ||||
|             notifier.showProgressNotification( | ||||
|                 updatingManga, | ||||
|                 completed.get(), | ||||
|                 mangaToUpdate.size, | ||||
|             ) | ||||
|         updatingManga.add(manga) | ||||
|         notifier.showProgressNotification( | ||||
|             updatingManga, | ||||
|             completed.get(), | ||||
|             mangaToUpdate.size, | ||||
|         ) | ||||
|  | ||||
|             block() | ||||
|         block() | ||||
|  | ||||
|             ensureActive() | ||||
|         ensureActive() | ||||
|  | ||||
|             updatingManga.remove(manga) | ||||
|             completed.getAndIncrement() | ||||
|             notifier.showProgressNotification( | ||||
|                 updatingManga, | ||||
|                 completed.get(), | ||||
|                 mangaToUpdate.size, | ||||
|             ) | ||||
|         } | ||||
|         updatingManga.remove(manga) | ||||
|         completed.getAndIncrement() | ||||
|         notifier.showProgressNotification( | ||||
|             updatingManga, | ||||
|             completed.get(), | ||||
|             mangaToUpdate.size, | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @@ -497,7 +508,7 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet | ||||
|         private const val WORK_NAME_AUTO = "LibraryUpdate-auto" | ||||
|         private const val WORK_NAME_MANUAL = "LibraryUpdate-manual" | ||||
|  | ||||
|         private const val ERROR_LOG_HELP_URL = "https://tachiyomi.org/help/guides/troubleshooting" | ||||
|         private const val ERROR_LOG_HELP_URL = "https://tachiyomi.org/docs/guides/troubleshooting/" | ||||
|  | ||||
|         private const val MANGA_PER_SOURCE_QUEUE_WARNING_THRESHOLD = 60 | ||||
|  | ||||
|   | ||||
| @@ -30,10 +30,14 @@ import tachiyomi.core.util.lang.launchUI | ||||
| import tachiyomi.domain.chapter.model.Chapter | ||||
| import tachiyomi.domain.manga.model.Manga | ||||
| import uy.kohesive.injekt.injectLazy | ||||
| import java.text.NumberFormat | ||||
|  | ||||
| class LibraryUpdateNotifier(private val context: Context) { | ||||
|  | ||||
|     private val preferences: SecurityPreferences by injectLazy() | ||||
|     private val percentFormatter = NumberFormat.getPercentInstance().apply { | ||||
|         maximumFractionDigits = 0 | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Pending intent of action that cancels the library update | ||||
| @@ -78,7 +82,7 @@ class LibraryUpdateNotifier(private val context: Context) { | ||||
|         } else { | ||||
|             val updatingText = manga.joinToString("\n") { it.title.chop(40) } | ||||
|             progressNotificationBuilder | ||||
|                 .setContentTitle(context.getString(R.string.notification_updating, current, total)) | ||||
|                 .setContentTitle(context.getString(R.string.notification_updating_progress, percentFormatter.format(current.toFloat() / total))) | ||||
|                 .setStyle(NotificationCompat.BigTextStyle().bigText(updatingText)) | ||||
|         } | ||||
|  | ||||
| @@ -329,11 +333,11 @@ class LibraryUpdateNotifier(private val context: Context) { | ||||
|     } | ||||
|  | ||||
|     companion object { | ||||
|         const val HELP_WARNING_URL = "https://tachiyomi.org/help/faq/#why-does-the-app-warn-about-large-bulk-updates-and-downloads" | ||||
|         const val HELP_WARNING_URL = "https://tachiyomi.org/docs/faq/library#why-am-i-warned-about-large-bulk-updates-and-downloads" | ||||
|     } | ||||
| } | ||||
|  | ||||
| private const val NOTIF_MAX_CHAPTERS = 5 | ||||
| private const val NOTIF_TITLE_MAX_LEN = 45 | ||||
| private const val NOTIF_ICON_SIZE = 192 | ||||
| private const val HELP_SKIPPED_URL = "https://tachiyomi.org/help/faq/#why-does-global-update-skip-some-entries" | ||||
| private const val HELP_SKIPPED_URL = "https://tachiyomi.org/docs/faq/library#why-is-global-update-skipping-entries" | ||||
|   | ||||
| @@ -1,6 +1,5 @@ | ||||
| package eu.kanade.tachiyomi.data.saver | ||||
|  | ||||
| import android.annotation.SuppressLint | ||||
| import android.content.ContentUris | ||||
| import android.content.Context | ||||
| import android.graphics.Bitmap | ||||
| @@ -28,30 +27,59 @@ class ImageSaver( | ||||
|     val context: Context, | ||||
| ) { | ||||
|  | ||||
|     @SuppressLint("InlinedApi") | ||||
|     fun save(image: Image): Uri { | ||||
|         val data = image.data | ||||
|  | ||||
|         val type = ImageUtil.findImageType(data) ?: throw Exception("Not an image") | ||||
|         val type = ImageUtil.findImageType(data) ?: throw IllegalArgumentException("Not an image") | ||||
|         val filename = DiskUtil.buildValidFilename("${image.name}.${type.extension}") | ||||
|  | ||||
|         if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q || image.location !is Location.Pictures) { | ||||
|             return save(data(), image.location.directory(context), filename) | ||||
|         } | ||||
|  | ||||
|         return saveApi29(image, type, filename, data) | ||||
|     } | ||||
|  | ||||
|     private fun save(inputStream: InputStream, directory: File, filename: String): Uri { | ||||
|         directory.mkdirs() | ||||
|  | ||||
|         val destFile = File(directory, filename) | ||||
|  | ||||
|         inputStream.use { input -> | ||||
|             destFile.outputStream().use { output -> | ||||
|                 input.copyTo(output) | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         DiskUtil.scanMedia(context, destFile.toUri()) | ||||
|  | ||||
|         return destFile.getUriCompat(context) | ||||
|     } | ||||
|  | ||||
|     @RequiresApi(Build.VERSION_CODES.Q) | ||||
|     private fun saveApi29( | ||||
|         image: Image, | ||||
|         type: ImageUtil.ImageType, | ||||
|         filename: String, | ||||
|         data: () -> InputStream, | ||||
|     ): Uri { | ||||
|         val pictureDir = | ||||
|             MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) | ||||
|  | ||||
|         val folderRelativePath = "${Environment.DIRECTORY_PICTURES}/${context.getString(R.string.app_name)}/" | ||||
|         val imageLocation = (image.location as Location.Pictures).relativePath | ||||
|         val relativePath = listOf( | ||||
|             Environment.DIRECTORY_PICTURES, | ||||
|             context.getString(R.string.app_name), | ||||
|             imageLocation, | ||||
|         ).joinToString(File.separator) | ||||
|  | ||||
|         val contentValues = contentValuesOf( | ||||
|             MediaStore.Images.Media.RELATIVE_PATH to relativePath, | ||||
|             MediaStore.Images.Media.DISPLAY_NAME to image.name, | ||||
|             MediaStore.Images.Media.MIME_TYPE to type.mime, | ||||
|             MediaStore.Images.Media.RELATIVE_PATH to folderRelativePath + imageLocation, | ||||
|         ) | ||||
|  | ||||
|         val picture = findUriOrDefault(folderRelativePath, "$imageLocation$filename") { | ||||
|         val picture = findUriOrDefault(relativePath, filename) { | ||||
|             context.contentResolver.insert( | ||||
|                 pictureDir, | ||||
|                 contentValues, | ||||
| @@ -74,49 +102,34 @@ class ImageSaver( | ||||
|         return picture | ||||
|     } | ||||
|  | ||||
|     private fun save(inputStream: InputStream, directory: File, filename: String): Uri { | ||||
|         directory.mkdirs() | ||||
|  | ||||
|         val destFile = File(directory, filename) | ||||
|  | ||||
|         inputStream.use { input -> | ||||
|             destFile.outputStream().use { output -> | ||||
|                 input.copyTo(output) | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         DiskUtil.scanMedia(context, destFile.toUri()) | ||||
|  | ||||
|         return destFile.getUriCompat(context) | ||||
|     } | ||||
|  | ||||
|     @RequiresApi(Build.VERSION_CODES.Q) | ||||
|     private fun findUriOrDefault(relativePath: String, imagePath: String, default: () -> Uri): Uri { | ||||
|     private fun findUriOrDefault(path: String, filename: String, default: () -> Uri): Uri { | ||||
|         val projection = arrayOf( | ||||
|             MediaStore.MediaColumns._ID, | ||||
|             MediaStore.MediaColumns.DISPLAY_NAME, | ||||
|             MediaStore.Images.Media.MIME_TYPE, | ||||
|             MediaStore.MediaColumns.RELATIVE_PATH, | ||||
|             MediaStore.MediaColumns.DATE_MODIFIED, | ||||
|         ) | ||||
|  | ||||
|         val selection = "${MediaStore.MediaColumns.RELATIVE_PATH}=? AND ${MediaStore.MediaColumns.DISPLAY_NAME}=?" | ||||
|  | ||||
|         // Need to make sure it ends with the separator | ||||
|         val normalizedPath = "${path.removeSuffix(File.separator)}${File.separator}" | ||||
|  | ||||
|         context.contentResolver.query( | ||||
|             MediaStore.Images.Media.EXTERNAL_CONTENT_URI, | ||||
|             projection, | ||||
|             selection, | ||||
|             arrayOf(relativePath, imagePath), | ||||
|             arrayOf(normalizedPath, filename), | ||||
|             null, | ||||
|         ).use { cursor -> | ||||
|             if (cursor != null && cursor.count >= 1) { | ||||
|                 cursor.moveToFirst().let { | ||||
|                 if (cursor.moveToFirst()) { | ||||
|                     val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID)) | ||||
|  | ||||
|                     return ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id) | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return default() | ||||
|     } | ||||
| } | ||||
| @@ -153,19 +166,12 @@ sealed class Image( | ||||
| } | ||||
|  | ||||
| sealed interface Location { | ||||
|     data class Pictures private constructor(val relativePath: String) : Location { | ||||
|         companion object { | ||||
|             fun create(relativePath: String = ""): Pictures { | ||||
|                 return Pictures(relativePath) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|     data class Pictures(val relativePath: String) : Location | ||||
|  | ||||
|     data object Cache : Location | ||||
|  | ||||
|     fun directory(context: Context): File { | ||||
|         return when (this) { | ||||
|             Cache -> context.cacheImageDir | ||||
|             is Pictures -> { | ||||
|                 val file = File( | ||||
|                     Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES), | ||||
| @@ -179,6 +185,7 @@ sealed interface Location { | ||||
|                 } | ||||
|                 file | ||||
|             } | ||||
|             Cache -> context.cacheImageDir | ||||
|         } | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -3,9 +3,9 @@ package eu.kanade.tachiyomi.data.track | ||||
| import eu.kanade.tachiyomi.data.database.models.Track | ||||
| 
 | ||||
| /** | ||||
|  * For track services api that support deleting a manga entry for a user's list | ||||
|  * Tracker that support deleting am entry from a user's list. | ||||
|  */ | ||||
| interface DeletableTrackService { | ||||
| interface DeletableTracker { | ||||
| 
 | ||||
|     suspend fun delete(track: Track): Track | ||||
| } | ||||
| @@ -6,31 +6,32 @@ import tachiyomi.domain.manga.model.Manga | ||||
| import tachiyomi.domain.track.model.Track | ||||
| 
 | ||||
| /** | ||||
|  * An Enhanced Track Service will never prompt the user to match a manga with the remote. | ||||
|  * It is expected that such Track Service can only work with specific sources and unique IDs. | ||||
|  * A tracker that will never prompt the user to manually bind an entry. | ||||
|  * It is expected that such tracker can only work with specific sources and unique IDs. | ||||
|  */ | ||||
| interface EnhancedTrackService { | ||||
| interface EnhancedTracker { | ||||
| 
 | ||||
|     /** | ||||
|      * This TrackService will only work with the sources that are accepted by this filter function. | ||||
|      * This tracker will only work with the sources that are accepted by this filter function. | ||||
|      */ | ||||
|     fun accept(source: Source): Boolean { | ||||
|         return source::class.qualifiedName in getAcceptedSources() | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Fully qualified source classes that this track service is compatible with. | ||||
|      * Fully qualified source classes that this tracker is compatible with. | ||||
|      */ | ||||
|     fun getAcceptedSources(): List<String> | ||||
| 
 | ||||
|     fun loginNoop() | ||||
| 
 | ||||
|     /** | ||||
|      * match is similar to TrackService.search, but only return zero or one match. | ||||
|      * Similar to [Tracker].search, but only returns zero or one match. | ||||
|      */ | ||||
|     suspend fun match(manga: Manga): TrackSearch? | ||||
| 
 | ||||
|     /** | ||||
|      * Checks whether the provided source/track/manga triplet is from this TrackService | ||||
|      * Checks whether the provided source/track/manga triplet is from this [Tracker] | ||||
|      */ | ||||
|     fun isTrackFrom(track: Track, manga: Manga, source: Source?): Boolean | ||||
| 
 | ||||
| @@ -5,7 +5,7 @@ import androidx.annotation.CallSuper | ||||
| import androidx.annotation.ColorInt | ||||
| import androidx.annotation.DrawableRes | ||||
| import androidx.annotation.StringRes | ||||
| import eu.kanade.domain.chapter.interactor.SyncChapterProgressWithTrack | ||||
| import eu.kanade.domain.track.interactor.SyncChapterProgressWithTrack | ||||
| import eu.kanade.domain.track.model.toDbTrack | ||||
| import eu.kanade.domain.track.model.toDomainTrack | ||||
| import eu.kanade.domain.track.service.TrackPreferences | ||||
| @@ -28,7 +28,7 @@ import uy.kohesive.injekt.injectLazy | ||||
| import java.time.ZoneOffset | ||||
| import tachiyomi.domain.track.model.Track as DomainTrack | ||||
| 
 | ||||
| abstract class TrackService(val id: Long, val name: String) { | ||||
| abstract class Tracker(val id: Long, val name: String) { | ||||
| 
 | ||||
|     val trackPreferences: TrackPreferences by injectLazy() | ||||
|     val networkService: NetworkHelper by injectLazy() | ||||
| @@ -83,7 +83,7 @@ abstract class TrackService(val id: Long, val name: String) { | ||||
| 
 | ||||
|     @CallSuper | ||||
|     open fun logout() { | ||||
|         trackPreferences.setTrackCredentials(this, "", "") | ||||
|         trackPreferences.setCredentials(this, "", "") | ||||
|     } | ||||
| 
 | ||||
|     open val isLoggedIn: Boolean | ||||
| @@ -95,7 +95,7 @@ abstract class TrackService(val id: Long, val name: String) { | ||||
|     fun getPassword() = trackPreferences.trackPassword(this).get() | ||||
| 
 | ||||
|     fun saveCredentials(username: String, password: String) { | ||||
|         trackPreferences.setTrackCredentials(this, username, password) | ||||
|         trackPreferences.setCredentials(this, username, password) | ||||
|     } | ||||
| 
 | ||||
|     // TODO: move this to an interactor, and update all trackers based on common data | ||||
| @@ -111,7 +111,7 @@ abstract class TrackService(val id: Long, val name: String) { | ||||
| 
 | ||||
|                 insertTrack.await(track) | ||||
| 
 | ||||
|                 // TODO: merge into SyncChaptersWithTrackServiceTwoWay? | ||||
|                 // TODO: merge into [SyncChapterProgressWithTrack]? | ||||
|                 // Update chapter progress if newer chapters marked read locally | ||||
|                 if (hasReadChapters) { | ||||
|                     val latestLocalReadChapterNumber = allChapters | ||||
| @@ -143,7 +143,7 @@ abstract class TrackService(val id: Long, val name: String) { | ||||
|                     } | ||||
|                 } | ||||
| 
 | ||||
|                 syncChapterProgressWithTrack.await(mangaId, track, this@TrackService) | ||||
|                 syncChapterProgressWithTrack.await(mangaId, track, this@Tracker) | ||||
|             } | ||||
|         } catch (e: Throwable) { | ||||
|             withUIContext { Injekt.get<Application>().toast(e.message) } | ||||
| @@ -1,6 +1,5 @@ | ||||
| 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.kavita.Kavita | ||||
| @@ -11,33 +10,27 @@ import eu.kanade.tachiyomi.data.track.myanimelist.MyAnimeList | ||||
| import eu.kanade.tachiyomi.data.track.shikimori.Shikimori | ||||
| import eu.kanade.tachiyomi.data.track.suwayomi.Suwayomi | ||||
| 
 | ||||
| class TrackManager(context: Context) { | ||||
| class TrackerManager { | ||||
| 
 | ||||
|     companion object { | ||||
|         const val MYANIMELIST = 1L | ||||
|         const val ANILIST = 2L | ||||
|         const val KITSU = 3L | ||||
|         const val SHIKIMORI = 4L | ||||
|         const val BANGUMI = 5L | ||||
|         const val KOMGA = 6L | ||||
|         const val MANGA_UPDATES = 7L | ||||
|         const val KAVITA = 8L | ||||
|         const val SUWAYOMI = 9L | ||||
|     } | ||||
| 
 | ||||
|     val myAnimeList = MyAnimeList(MYANIMELIST) | ||||
|     val myAnimeList = MyAnimeList(1L) | ||||
|     val aniList = Anilist(ANILIST) | ||||
|     val kitsu = Kitsu(KITSU) | ||||
|     val shikimori = Shikimori(SHIKIMORI) | ||||
|     val bangumi = Bangumi(BANGUMI) | ||||
|     val komga = Komga(KOMGA) | ||||
|     val mangaUpdates = MangaUpdates(MANGA_UPDATES) | ||||
|     val kavita = Kavita(context, KAVITA) | ||||
|     val suwayomi = Suwayomi(SUWAYOMI) | ||||
|     val shikimori = Shikimori(4L) | ||||
|     val bangumi = Bangumi(5L) | ||||
|     val komga = Komga(6L) | ||||
|     val mangaUpdates = MangaUpdates(7L) | ||||
|     val kavita = Kavita(KAVITA) | ||||
|     val suwayomi = Suwayomi(9L) | ||||
| 
 | ||||
|     val services = listOf(myAnimeList, aniList, kitsu, shikimori, bangumi, komga, mangaUpdates, kavita, suwayomi) | ||||
|     val trackers = listOf(myAnimeList, aniList, kitsu, shikimori, bangumi, komga, mangaUpdates, kavita, suwayomi) | ||||
| 
 | ||||
|     fun getService(id: Long) = services.find { it.id == id } | ||||
|     fun get(id: Long) = trackers.find { it.id == id } | ||||
| 
 | ||||
|     fun hasLoggedServices() = services.any { it.isLoggedIn } | ||||
|     fun hasLoggedIn() = trackers.any { it.isLoggedIn } | ||||
| } | ||||
| @@ -4,15 +4,15 @@ import android.graphics.Color | ||||
| import androidx.annotation.StringRes | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.database.models.Track | ||||
| import eu.kanade.tachiyomi.data.track.DeletableTrackService | ||||
| import eu.kanade.tachiyomi.data.track.TrackService | ||||
| import eu.kanade.tachiyomi.data.track.DeletableTracker | ||||
| import eu.kanade.tachiyomi.data.track.Tracker | ||||
| import eu.kanade.tachiyomi.data.track.model.TrackSearch | ||||
| import kotlinx.serialization.encodeToString | ||||
| import kotlinx.serialization.json.Json | ||||
| import uy.kohesive.injekt.injectLazy | ||||
| import tachiyomi.domain.track.model.Track as DomainTrack | ||||
|  | ||||
| class Anilist(id: Long) : TrackService(id, "AniList"), DeletableTrackService { | ||||
| class Anilist(id: Long) : Tracker(id, "AniList"), DeletableTracker { | ||||
|  | ||||
|     companion object { | ||||
|         const val READING = 1 | ||||
|   | ||||
| @@ -2,7 +2,7 @@ package eu.kanade.tachiyomi.data.track.anilist | ||||
|  | ||||
| import eu.kanade.domain.track.service.TrackPreferences | ||||
| import eu.kanade.tachiyomi.data.database.models.Track | ||||
| import eu.kanade.tachiyomi.data.track.TrackManager | ||||
| import eu.kanade.tachiyomi.data.track.TrackerManager | ||||
| import eu.kanade.tachiyomi.data.track.model.TrackSearch | ||||
| import kotlinx.serialization.Serializable | ||||
| import uy.kohesive.injekt.injectLazy | ||||
| @@ -20,7 +20,7 @@ data class ALManga( | ||||
|     val total_chapters: Int, | ||||
| ) { | ||||
|  | ||||
|     fun toTrack() = TrackSearch.create(TrackManager.ANILIST).apply { | ||||
|     fun toTrack() = TrackSearch.create(TrackerManager.ANILIST).apply { | ||||
|         media_id = this@ALManga.media_id | ||||
|         title = title_user_pref | ||||
|         total_chapters = this@ALManga.total_chapters | ||||
| @@ -50,7 +50,7 @@ data class ALUserManga( | ||||
|     val manga: ALManga, | ||||
| ) { | ||||
|  | ||||
|     fun toTrack() = Track.create(TrackManager.ANILIST).apply { | ||||
|     fun toTrack() = Track.create(TrackerManager.ANILIST).apply { | ||||
|         media_id = manga.media_id | ||||
|         title = manga.title_user_pref | ||||
|         status = toTrackStatus() | ||||
| @@ -62,7 +62,7 @@ data class ALUserManga( | ||||
|         total_chapters = manga.total_chapters | ||||
|     } | ||||
|  | ||||
|     fun toTrackStatus() = when (list_status) { | ||||
|     private fun toTrackStatus() = when (list_status) { | ||||
|         "CURRENT" -> Anilist.READING | ||||
|         "COMPLETED" -> Anilist.COMPLETED | ||||
|         "PAUSED" -> Anilist.ON_HOLD | ||||
|   | ||||
| @@ -4,19 +4,19 @@ import android.graphics.Color | ||||
| import androidx.annotation.StringRes | ||||
| 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.Tracker | ||||
| import eu.kanade.tachiyomi.data.track.model.TrackSearch | ||||
| import kotlinx.serialization.encodeToString | ||||
| import kotlinx.serialization.json.Json | ||||
| import uy.kohesive.injekt.injectLazy | ||||
|  | ||||
| class Bangumi(id: Long) : TrackService(id, "Bangumi") { | ||||
| class Bangumi(id: Long) : Tracker(id, "Bangumi") { | ||||
|  | ||||
|     private val json: Json by injectLazy() | ||||
|  | ||||
|     private val interceptor by lazy { BangumiInterceptor(this) } | ||||
|  | ||||
|     private val api by lazy { BangumiApi(client, interceptor) } | ||||
|     private val api by lazy { BangumiApi(id, client, interceptor) } | ||||
|  | ||||
|     override fun getScoreList(): List<String> { | ||||
|         return IntRange(0, 10).map(Int::toString) | ||||
|   | ||||
| @@ -3,7 +3,6 @@ package eu.kanade.tachiyomi.data.track.bangumi | ||||
| import android.net.Uri | ||||
| import androidx.core.net.toUri | ||||
| import eu.kanade.tachiyomi.data.database.models.Track | ||||
| import eu.kanade.tachiyomi.data.track.TrackManager | ||||
| import eu.kanade.tachiyomi.data.track.model.TrackSearch | ||||
| import eu.kanade.tachiyomi.network.GET | ||||
| import eu.kanade.tachiyomi.network.POST | ||||
| @@ -26,7 +25,11 @@ import uy.kohesive.injekt.injectLazy | ||||
| import java.net.URLEncoder | ||||
| import java.nio.charset.StandardCharsets | ||||
|  | ||||
| class BangumiApi(private val client: OkHttpClient, interceptor: BangumiInterceptor) { | ||||
| class BangumiApi( | ||||
|     private val trackId: Long, | ||||
|     private val client: OkHttpClient, | ||||
|     interceptor: BangumiInterceptor, | ||||
| ) { | ||||
|  | ||||
|     private val json: Json by injectLazy() | ||||
|  | ||||
| @@ -105,7 +108,7 @@ class BangumiApi(private val client: OkHttpClient, interceptor: BangumiIntercept | ||||
|         } else { | ||||
|             0 | ||||
|         } | ||||
|         return TrackSearch.create(TrackManager.BANGUMI).apply { | ||||
|         return TrackSearch.create(trackId).apply { | ||||
|             media_id = obj["id"]!!.jsonPrimitive.long | ||||
|             title = obj["name_cn"]!!.jsonPrimitive.content | ||||
|             cover_url = coverUrl | ||||
|   | ||||
| @@ -1,20 +1,22 @@ | ||||
| package eu.kanade.tachiyomi.data.track.kavita | ||||
|  | ||||
| import android.content.Context | ||||
| import android.content.SharedPreferences | ||||
| import android.graphics.Color | ||||
| import androidx.annotation.StringRes | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.database.models.Track | ||||
| import eu.kanade.tachiyomi.data.track.EnhancedTrackService | ||||
| import eu.kanade.tachiyomi.data.track.TrackService | ||||
| import eu.kanade.tachiyomi.data.track.EnhancedTracker | ||||
| import eu.kanade.tachiyomi.data.track.Tracker | ||||
| import eu.kanade.tachiyomi.data.track.model.TrackSearch | ||||
| import eu.kanade.tachiyomi.source.ConfigurableSource | ||||
| import eu.kanade.tachiyomi.source.Source | ||||
| import eu.kanade.tachiyomi.source.sourcePreferences | ||||
| import tachiyomi.domain.manga.model.Manga | ||||
| import tachiyomi.domain.source.service.SourceManager | ||||
| import uy.kohesive.injekt.injectLazy | ||||
| import java.security.MessageDigest | ||||
| import tachiyomi.domain.track.model.Track as DomainTrack | ||||
|  | ||||
| class Kavita(private val context: Context, id: Long) : TrackService(id, "Kavita"), EnhancedTrackService { | ||||
| class Kavita(id: Long) : Tracker(id, "Kavita"), EnhancedTracker { | ||||
|  | ||||
|     companion object { | ||||
|         const val UNREAD = 1 | ||||
| @@ -27,6 +29,8 @@ class Kavita(private val context: Context, id: Long) : TrackService(id, "Kavita" | ||||
|     private val interceptor by lazy { KavitaInterceptor(this) } | ||||
|     val api by lazy { KavitaApi(client, interceptor) } | ||||
|  | ||||
|     private val sourceManager: SourceManager by injectLazy() | ||||
|  | ||||
|     override fun getLogo(): Int = R.drawable.ic_tracker_kavita | ||||
|  | ||||
|     override fun getLogoColor() = Color.rgb(74, 198, 148) | ||||
| @@ -83,7 +87,7 @@ class Kavita(private val context: Context, id: Long) : TrackService(id, "Kavita" | ||||
|         saveCredentials("user", "pass") | ||||
|     } | ||||
|  | ||||
|     // TrackService.isLogged works by checking that credentials are saved. | ||||
|     // [Tracker].isLogged works by checking that credentials are saved. | ||||
|     // By saving dummy, unused credentials, we can activate the tracker simply by login/logout | ||||
|     override fun loginNoop() { | ||||
|         saveCredentials("user", "pass") | ||||
| @@ -110,28 +114,29 @@ class Kavita(private val context: Context, id: Long) : TrackService(id, "Kavita" | ||||
|  | ||||
|     fun loadOAuth() { | ||||
|         val oauth = OAuth() | ||||
|         for (sourceId in 1..3) { | ||||
|             val authentication = oauth.authentications[sourceId - 1] | ||||
|             val sourceSuffixID by lazy { | ||||
|                 val key = "kavita_$sourceId/all/1" // Hardcoded versionID to 1 | ||||
|         for (id in 1..3) { | ||||
|             val authentication = oauth.authentications[id - 1] | ||||
|             val sourceId by lazy { | ||||
|                 val key = "kavita_$id/all/1" // Hardcoded versionID to 1 | ||||
|                 val bytes = MessageDigest.getInstance("MD5").digest(key.toByteArray()) | ||||
|                 (0..7).map { bytes[it].toLong() and 0xff shl 8 * (7 - it) } | ||||
|                     .reduce(Long::or) and Long.MAX_VALUE | ||||
|             } | ||||
|             val preferences: SharedPreferences by lazy { | ||||
|                 context.getSharedPreferences("source_$sourceSuffixID", 0x0000) | ||||
|             } | ||||
|             val prefApiUrl = preferences.getString("APIURL", "")!! | ||||
|             if (prefApiUrl.isEmpty()) { | ||||
|             val preferences = (sourceManager.get(sourceId) as ConfigurableSource).sourcePreferences() | ||||
|  | ||||
|             val prefApiUrl = preferences.getString("APIURL", "") | ||||
|             val prefApiKey = preferences.getString("APIKEY", "") | ||||
|             if (prefApiUrl.isNullOrEmpty() || prefApiKey.isNullOrEmpty()) { | ||||
|                 // Source not configured. Skip | ||||
|                 continue | ||||
|             } | ||||
|             val prefApiKey = preferences.getString("APIKEY", "")!! | ||||
|  | ||||
|             val token = api.getNewToken(apiUrl = prefApiUrl, apiKey = prefApiKey) | ||||
|             if (token.isNullOrEmpty()) { | ||||
|                 // Source is not accessible. Skip | ||||
|                 continue | ||||
|             } | ||||
|  | ||||
|             authentication.apiUrl = prefApiUrl | ||||
|             authentication.jwtToken = token.toString() | ||||
|         } | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| package eu.kanade.tachiyomi.data.track.kavita | ||||
|  | ||||
| import eu.kanade.tachiyomi.data.track.TrackManager | ||||
| import eu.kanade.tachiyomi.data.track.TrackerManager | ||||
| import eu.kanade.tachiyomi.data.track.model.TrackSearch | ||||
| import kotlinx.serialization.Serializable | ||||
|  | ||||
| @@ -22,7 +22,7 @@ data class SeriesDto( | ||||
|     val libraryId: Int, | ||||
|     val libraryName: String? = "", | ||||
| ) { | ||||
|     fun toTrack(): TrackSearch = TrackSearch.create(TrackManager.KAVITA).also { | ||||
|     fun toTrack(): TrackSearch = TrackSearch.create(TrackerManager.KAVITA).also { | ||||
|         it.title = name | ||||
|         it.summary = "" | ||||
|     } | ||||
|   | ||||
| @@ -4,15 +4,15 @@ import android.graphics.Color | ||||
| import androidx.annotation.StringRes | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.database.models.Track | ||||
| import eu.kanade.tachiyomi.data.track.DeletableTrackService | ||||
| import eu.kanade.tachiyomi.data.track.TrackService | ||||
| import eu.kanade.tachiyomi.data.track.DeletableTracker | ||||
| import eu.kanade.tachiyomi.data.track.Tracker | ||||
| import eu.kanade.tachiyomi.data.track.model.TrackSearch | ||||
| import kotlinx.serialization.encodeToString | ||||
| import kotlinx.serialization.json.Json | ||||
| import uy.kohesive.injekt.injectLazy | ||||
| import java.text.DecimalFormat | ||||
|  | ||||
| class Kitsu(id: Long) : TrackService(id, "Kitsu"), DeletableTrackService { | ||||
| class Kitsu(id: Long) : Tracker(id, "Kitsu"), DeletableTracker { | ||||
|  | ||||
|     companion object { | ||||
|         const val READING = 1 | ||||
|   | ||||
| @@ -2,7 +2,7 @@ package eu.kanade.tachiyomi.data.track.kitsu | ||||
|  | ||||
| import androidx.annotation.CallSuper | ||||
| import eu.kanade.tachiyomi.data.database.models.Track | ||||
| import eu.kanade.tachiyomi.data.track.TrackManager | ||||
| import eu.kanade.tachiyomi.data.track.TrackerManager | ||||
| import eu.kanade.tachiyomi.data.track.model.TrackSearch | ||||
| import kotlinx.serialization.Serializable | ||||
| import kotlinx.serialization.json.JsonObject | ||||
| @@ -35,7 +35,7 @@ class KitsuSearchManga(obj: JsonObject) { | ||||
|     private val endDate = obj["endDate"]?.jsonPrimitive?.contentOrNull | ||||
|  | ||||
|     @CallSuper | ||||
|     fun toTrack() = TrackSearch.create(TrackManager.KITSU).apply { | ||||
|     fun toTrack() = TrackSearch.create(TrackerManager.KITSU).apply { | ||||
|         media_id = this@KitsuSearchManga.id | ||||
|         title = canonicalTitle | ||||
|         total_chapters = chapterCount ?: 0 | ||||
| @@ -67,7 +67,7 @@ class KitsuLibManga(obj: JsonObject, manga: JsonObject) { | ||||
|     private val ratingTwenty = obj["attributes"]!!.jsonObject["ratingTwenty"]?.jsonPrimitive?.contentOrNull | ||||
|     val progress = obj["attributes"]!!.jsonObject["progress"]!!.jsonPrimitive.int | ||||
|  | ||||
|     fun toTrack() = TrackSearch.create(TrackManager.KITSU).apply { | ||||
|     fun toTrack() = TrackSearch.create(TrackerManager.KITSU).apply { | ||||
|         media_id = libraryId | ||||
|         title = canonicalTitle | ||||
|         total_chapters = chapterCount ?: 0 | ||||
|   | ||||
| @@ -4,8 +4,8 @@ import android.graphics.Color | ||||
| import androidx.annotation.StringRes | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.database.models.Track | ||||
| import eu.kanade.tachiyomi.data.track.EnhancedTrackService | ||||
| import eu.kanade.tachiyomi.data.track.TrackService | ||||
| import eu.kanade.tachiyomi.data.track.EnhancedTracker | ||||
| import eu.kanade.tachiyomi.data.track.Tracker | ||||
| import eu.kanade.tachiyomi.data.track.model.TrackSearch | ||||
| import eu.kanade.tachiyomi.source.Source | ||||
| import okhttp3.Dns | ||||
| @@ -13,7 +13,7 @@ import okhttp3.OkHttpClient | ||||
| import tachiyomi.domain.manga.model.Manga | ||||
| import tachiyomi.domain.track.model.Track as DomainTrack | ||||
|  | ||||
| class Komga(id: Long) : TrackService(id, "Komga"), EnhancedTrackService { | ||||
| class Komga(id: Long) : Tracker(id, "Komga"), EnhancedTracker { | ||||
|  | ||||
|     companion object { | ||||
|         const val UNREAD = 1 | ||||
| @@ -26,7 +26,7 @@ class Komga(id: Long) : TrackService(id, "Komga"), EnhancedTrackService { | ||||
|             .dns(Dns.SYSTEM) // don't use DNS over HTTPS as it breaks IP addressing | ||||
|             .build() | ||||
|  | ||||
|     val api by lazy { KomgaApi(client) } | ||||
|     val api by lazy { KomgaApi(id, client) } | ||||
|  | ||||
|     override fun getLogo() = R.drawable.ic_tracker_komga | ||||
|  | ||||
| @@ -85,7 +85,7 @@ class Komga(id: Long) : TrackService(id, "Komga"), EnhancedTrackService { | ||||
|         saveCredentials("user", "pass") | ||||
|     } | ||||
|  | ||||
|     // TrackService.isLogged works by checking that credentials are saved. | ||||
|     // [Tracker].isLogged works by checking that credentials are saved. | ||||
|     // By saving dummy, unused credentials, we can activate the tracker simply by login/logout | ||||
|     override fun loginNoop() { | ||||
|         saveCredentials("user", "pass") | ||||
|   | ||||
| @@ -1,7 +1,6 @@ | ||||
| package eu.kanade.tachiyomi.data.track.komga | ||||
|  | ||||
| import eu.kanade.tachiyomi.data.database.models.Track | ||||
| import eu.kanade.tachiyomi.data.track.TrackManager | ||||
| import eu.kanade.tachiyomi.data.track.model.TrackSearch | ||||
| import eu.kanade.tachiyomi.network.GET | ||||
| import eu.kanade.tachiyomi.network.awaitSuccess | ||||
| @@ -19,7 +18,10 @@ import uy.kohesive.injekt.injectLazy | ||||
|  | ||||
| private const val READLIST_API = "/api/v1/readlists" | ||||
|  | ||||
| class KomgaApi(private val client: OkHttpClient) { | ||||
| class KomgaApi( | ||||
|     private val trackId: Long, | ||||
|     private val client: OkHttpClient, | ||||
| ) { | ||||
|  | ||||
|     private val json: Json by injectLazy() | ||||
|  | ||||
| @@ -85,13 +87,13 @@ class KomgaApi(private val client: OkHttpClient) { | ||||
|         return getTrackSearch(track.tracking_url) | ||||
|     } | ||||
|  | ||||
|     private fun SeriesDto.toTrack(): TrackSearch = TrackSearch.create(TrackManager.KOMGA).also { | ||||
|     private fun SeriesDto.toTrack(): TrackSearch = TrackSearch.create(trackId).also { | ||||
|         it.title = metadata.title | ||||
|         it.summary = metadata.summary | ||||
|         it.publishing_status = metadata.status | ||||
|     } | ||||
|  | ||||
|     private fun ReadListDto.toTrack(): TrackSearch = TrackSearch.create(TrackManager.KOMGA).also { | ||||
|     private fun ReadListDto.toTrack(): TrackSearch = TrackSearch.create(trackId).also { | ||||
|         it.title = name | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -4,13 +4,13 @@ import android.graphics.Color | ||||
| import androidx.annotation.StringRes | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.database.models.Track | ||||
| import eu.kanade.tachiyomi.data.track.DeletableTrackService | ||||
| import eu.kanade.tachiyomi.data.track.TrackService | ||||
| import eu.kanade.tachiyomi.data.track.DeletableTracker | ||||
| import eu.kanade.tachiyomi.data.track.Tracker | ||||
| import eu.kanade.tachiyomi.data.track.mangaupdates.dto.copyTo | ||||
| import eu.kanade.tachiyomi.data.track.mangaupdates.dto.toTrackSearch | ||||
| import eu.kanade.tachiyomi.data.track.model.TrackSearch | ||||
|  | ||||
| class MangaUpdates(id: Long) : TrackService(id, "MangaUpdates"), DeletableTrackService { | ||||
| class MangaUpdates(id: Long) : Tracker(id, "MangaUpdates"), DeletableTracker { | ||||
|  | ||||
|     companion object { | ||||
|         const val READING_LIST = 0 | ||||
|   | ||||
| @@ -4,14 +4,14 @@ import android.graphics.Color | ||||
| import androidx.annotation.StringRes | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.database.models.Track | ||||
| import eu.kanade.tachiyomi.data.track.DeletableTrackService | ||||
| import eu.kanade.tachiyomi.data.track.TrackService | ||||
| import eu.kanade.tachiyomi.data.track.DeletableTracker | ||||
| import eu.kanade.tachiyomi.data.track.Tracker | ||||
| import eu.kanade.tachiyomi.data.track.model.TrackSearch | ||||
| import kotlinx.serialization.encodeToString | ||||
| import kotlinx.serialization.json.Json | ||||
| import uy.kohesive.injekt.injectLazy | ||||
|  | ||||
| class MyAnimeList(id: Long) : TrackService(id, "MyAnimeList"), DeletableTrackService { | ||||
| class MyAnimeList(id: Long) : Tracker(id, "MyAnimeList"), DeletableTracker { | ||||
|  | ||||
|     companion object { | ||||
|         const val READING = 1 | ||||
| @@ -28,7 +28,7 @@ class MyAnimeList(id: Long) : TrackService(id, "MyAnimeList"), DeletableTrackSer | ||||
|     private val json: Json by injectLazy() | ||||
|  | ||||
|     private val interceptor by lazy { MyAnimeListInterceptor(this, getPassword()) } | ||||
|     private val api by lazy { MyAnimeListApi(client, interceptor) } | ||||
|     private val api by lazy { MyAnimeListApi(id, client, interceptor) } | ||||
|  | ||||
|     override val supportsReadingDates: Boolean = true | ||||
|  | ||||
|   | ||||
| @@ -3,7 +3,6 @@ package eu.kanade.tachiyomi.data.track.myanimelist | ||||
| import android.net.Uri | ||||
| import androidx.core.net.toUri | ||||
| import eu.kanade.tachiyomi.data.database.models.Track | ||||
| import eu.kanade.tachiyomi.data.track.TrackManager | ||||
| import eu.kanade.tachiyomi.data.track.model.TrackSearch | ||||
| import eu.kanade.tachiyomi.network.GET | ||||
| import eu.kanade.tachiyomi.network.POST | ||||
| @@ -32,7 +31,11 @@ import uy.kohesive.injekt.injectLazy | ||||
| import java.text.SimpleDateFormat | ||||
| import java.util.Locale | ||||
|  | ||||
| class MyAnimeListApi(private val client: OkHttpClient, interceptor: MyAnimeListInterceptor) { | ||||
| class MyAnimeListApi( | ||||
|     private val trackId: Long, | ||||
|     private val client: OkHttpClient, | ||||
|     interceptor: MyAnimeListInterceptor, | ||||
| ) { | ||||
|  | ||||
|     private val json: Json by injectLazy() | ||||
|  | ||||
| @@ -106,7 +109,7 @@ class MyAnimeListApi(private val client: OkHttpClient, interceptor: MyAnimeListI | ||||
|                     .parseAs<JsonObject>() | ||||
|                     .let { | ||||
|                         val obj = it.jsonObject | ||||
|                         TrackSearch.create(TrackManager.MYANIMELIST).apply { | ||||
|                         TrackSearch.create(trackId).apply { | ||||
|                             media_id = obj["id"]!!.jsonPrimitive.long | ||||
|                             title = obj["title"]!!.jsonPrimitive.content | ||||
|                             summary = obj["synopsis"]?.jsonPrimitive?.content ?: "" | ||||
|   | ||||
| @@ -4,14 +4,14 @@ import android.graphics.Color | ||||
| import androidx.annotation.StringRes | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.database.models.Track | ||||
| import eu.kanade.tachiyomi.data.track.DeletableTrackService | ||||
| import eu.kanade.tachiyomi.data.track.TrackService | ||||
| import eu.kanade.tachiyomi.data.track.DeletableTracker | ||||
| import eu.kanade.tachiyomi.data.track.Tracker | ||||
| import eu.kanade.tachiyomi.data.track.model.TrackSearch | ||||
| import kotlinx.serialization.encodeToString | ||||
| import kotlinx.serialization.json.Json | ||||
| import uy.kohesive.injekt.injectLazy | ||||
|  | ||||
| class Shikimori(id: Long) : TrackService(id, "Shikimori"), DeletableTrackService { | ||||
| class Shikimori(id: Long) : Tracker(id, "Shikimori"), DeletableTracker { | ||||
|  | ||||
|     companion object { | ||||
|         const val READING = 1 | ||||
| @@ -26,7 +26,7 @@ class Shikimori(id: Long) : TrackService(id, "Shikimori"), DeletableTrackService | ||||
|  | ||||
|     private val interceptor by lazy { ShikimoriInterceptor(this) } | ||||
|  | ||||
|     private val api by lazy { ShikimoriApi(client, interceptor) } | ||||
|     private val api by lazy { ShikimoriApi(id, client, interceptor) } | ||||
|  | ||||
|     override fun getScoreList(): List<String> { | ||||
|         return IntRange(0, 10).map(Int::toString) | ||||
|   | ||||
| @@ -2,7 +2,6 @@ package eu.kanade.tachiyomi.data.track.shikimori | ||||
|  | ||||
| import androidx.core.net.toUri | ||||
| import eu.kanade.tachiyomi.data.database.models.Track | ||||
| import eu.kanade.tachiyomi.data.track.TrackManager | ||||
| import eu.kanade.tachiyomi.data.track.model.TrackSearch | ||||
| import eu.kanade.tachiyomi.network.DELETE | ||||
| import eu.kanade.tachiyomi.network.GET | ||||
| @@ -28,7 +27,11 @@ import okhttp3.RequestBody.Companion.toRequestBody | ||||
| import tachiyomi.core.util.lang.withIOContext | ||||
| import uy.kohesive.injekt.injectLazy | ||||
|  | ||||
| class ShikimoriApi(private val client: OkHttpClient, interceptor: ShikimoriInterceptor) { | ||||
| class ShikimoriApi( | ||||
|     private val trackId: Long, | ||||
|     private val client: OkHttpClient, | ||||
|     interceptor: ShikimoriInterceptor, | ||||
| ) { | ||||
|  | ||||
|     private val json: Json by injectLazy() | ||||
|  | ||||
| @@ -96,7 +99,7 @@ class ShikimoriApi(private val client: OkHttpClient, interceptor: ShikimoriInter | ||||
|     } | ||||
|  | ||||
|     private fun jsonToSearch(obj: JsonObject): TrackSearch { | ||||
|         return TrackSearch.create(TrackManager.SHIKIMORI).apply { | ||||
|         return TrackSearch.create(trackId).apply { | ||||
|             media_id = obj["id"]!!.jsonPrimitive.long | ||||
|             title = obj["name"]!!.jsonPrimitive.content | ||||
|             total_chapters = obj["chapters"]!!.jsonPrimitive.int | ||||
| @@ -110,7 +113,7 @@ class ShikimoriApi(private val client: OkHttpClient, interceptor: ShikimoriInter | ||||
|     } | ||||
|  | ||||
|     private fun jsonToTrack(obj: JsonObject, mangas: JsonObject): Track { | ||||
|         return Track.create(TrackManager.SHIKIMORI).apply { | ||||
|         return Track.create(trackId).apply { | ||||
|             title = mangas["name"]!!.jsonPrimitive.content | ||||
|             media_id = obj["id"]!!.jsonPrimitive.long | ||||
|             total_chapters = mangas["chapters"]!!.jsonPrimitive.int | ||||
|   | ||||
| @@ -4,16 +4,16 @@ import android.graphics.Color | ||||
| import androidx.annotation.StringRes | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.database.models.Track | ||||
| import eu.kanade.tachiyomi.data.track.EnhancedTrackService | ||||
| import eu.kanade.tachiyomi.data.track.TrackService | ||||
| import eu.kanade.tachiyomi.data.track.EnhancedTracker | ||||
| import eu.kanade.tachiyomi.data.track.Tracker | ||||
| import eu.kanade.tachiyomi.data.track.model.TrackSearch | ||||
| import eu.kanade.tachiyomi.source.Source | ||||
| import tachiyomi.domain.manga.model.Manga as DomainManga | ||||
| import tachiyomi.domain.track.model.Track as DomainTrack | ||||
|  | ||||
| class Suwayomi(id: Long) : TrackService(id, "Suwayomi"), EnhancedTrackService { | ||||
| class Suwayomi(id: Long) : Tracker(id, "Suwayomi"), EnhancedTracker { | ||||
|  | ||||
|     val api by lazy { TachideskApi() } | ||||
|     val api by lazy { SuwayomiApi(id) } | ||||
|  | ||||
|     override fun getLogo() = R.drawable.ic_tracker_suwayomi | ||||
|  | ||||
|   | ||||
| @@ -1,9 +1,9 @@ | ||||
| package eu.kanade.tachiyomi.data.track.suwayomi | ||||
| 
 | ||||
| import android.app.Application | ||||
| import android.content.Context | ||||
| import android.content.SharedPreferences | ||||
| import eu.kanade.tachiyomi.data.database.models.Track | ||||
| import eu.kanade.tachiyomi.data.track.TrackManager | ||||
| import eu.kanade.tachiyomi.data.track.model.TrackSearch | ||||
| import eu.kanade.tachiyomi.network.GET | ||||
| import eu.kanade.tachiyomi.network.NetworkHelper | ||||
| @@ -23,7 +23,7 @@ import uy.kohesive.injekt.injectLazy | ||||
| import java.nio.charset.Charset | ||||
| import java.security.MessageDigest | ||||
| 
 | ||||
| class TachideskApi { | ||||
| class SuwayomiApi(private val trackId: Long) { | ||||
| 
 | ||||
|     private val network: NetworkHelper by injectLazy() | ||||
|     private val json: Json by injectLazy() | ||||
| @@ -61,7 +61,7 @@ class TachideskApi { | ||||
|                 .parseAs<MangaDataClass>() | ||||
|         } | ||||
| 
 | ||||
|         TrackSearch.create(TrackManager.SUWAYOMI).apply { | ||||
|         TrackSearch.create(trackId).apply { | ||||
|             title = manga.title | ||||
|             cover_url = "$url/thumbnail" | ||||
|             summary = manga.description.orEmpty() | ||||
| @@ -100,26 +100,24 @@ class TachideskApi { | ||||
|         return getTrackSearch(track.tracking_url) | ||||
|     } | ||||
| 
 | ||||
|     private val tachideskExtensionId by lazy { | ||||
|     private val sourceId by lazy { | ||||
|         val key = "tachidesk/en/1" | ||||
|         val bytes = MessageDigest.getInstance("MD5").digest(key.toByteArray()) | ||||
|         (0..7).map { bytes[it].toLong() and 0xff shl 8 * (7 - it) }.reduce(Long::or) and Long.MAX_VALUE | ||||
|     } | ||||
| 
 | ||||
|     private val preferences: SharedPreferences by lazy { | ||||
|         Injekt.get<Application>().getSharedPreferences("source_$tachideskExtensionId", 0x0000) | ||||
|         Injekt.get<Application>().getSharedPreferences("source_$sourceId", Context.MODE_PRIVATE) | ||||
|     } | ||||
| 
 | ||||
|     private fun getPrefBaseUrl(): String = preferences.getString(ADDRESS_TITLE, ADDRESS_DEFAULT)!! | ||||
|     private fun getPrefBaseLogin(): String = preferences.getString(LOGIN_TITLE, LOGIN_DEFAULT)!! | ||||
|     private fun getPrefBasePassword(): String = preferences.getString(PASSWORD_TITLE, PASSWORD_DEFAULT)!! | ||||
| 
 | ||||
|     companion object { | ||||
|         private const val ADDRESS_TITLE = "Server URL Address" | ||||
|         private const val ADDRESS_DEFAULT = "" | ||||
|         private const val LOGIN_TITLE = "Login (Basic Auth)" | ||||
|         private const val LOGIN_DEFAULT = "" | ||||
|         private const val PASSWORD_TITLE = "Password (Basic Auth)" | ||||
|         private const val PASSWORD_DEFAULT = "" | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| private const val ADDRESS_TITLE = "Server URL Address" | ||||
| private const val ADDRESS_DEFAULT = "" | ||||
| private const val LOGIN_TITLE = "Login (Basic Auth)" | ||||
| private const val LOGIN_DEFAULT = "" | ||||
| private const val PASSWORD_TITLE = "Password (Basic Auth)" | ||||
| private const val PASSWORD_DEFAULT = "" | ||||
| @@ -143,7 +143,7 @@ internal class AppUpdateNotifier(private val context: Context) { | ||||
|             setContentTitle(context.getString(R.string.update_check_notification_update_available)) | ||||
|             setContentText(context.getString(R.string.update_check_fdroid_migration_info)) | ||||
|             setSmallIcon(R.drawable.ic_tachi) | ||||
|             setContentIntent(NotificationHandler.openUrl(context, "https://tachiyomi.org/help/faq/#how-do-i-migrate-from-the-f-droid-version")) | ||||
|             setContentIntent(NotificationHandler.openUrl(context, "https://tachiyomi.org/docs/faq/general#how-do-i-update-from-the-f-droid-builds")) | ||||
|         } | ||||
|         notificationBuilder.show(Notifications.ID_APP_UPDATE_PROMPT) | ||||
|     } | ||||
|   | ||||
| @@ -8,6 +8,7 @@ import android.content.Intent | ||||
| import android.content.IntentFilter | ||||
| import android.content.pm.PackageInstaller | ||||
| import android.os.Build | ||||
| import androidx.core.content.ContextCompat | ||||
| import eu.kanade.tachiyomi.extension.model.InstallStep | ||||
| import eu.kanade.tachiyomi.util.lang.use | ||||
| import eu.kanade.tachiyomi.util.system.getParcelableExtraCompat | ||||
| @@ -100,7 +101,7 @@ class PackageInstallerInstaller(private val service: Service) : Installer(servic | ||||
|     } | ||||
|  | ||||
|     init { | ||||
|         service.registerReceiver(packageActionReceiver, IntentFilter(INSTALL_ACTION)) | ||||
|         ContextCompat.registerReceiver(service, packageActionReceiver, IntentFilter(INSTALL_ACTION), ContextCompat.RECEIVER_EXPORTED) | ||||
|     } | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -264,7 +264,7 @@ internal class ExtensionInstaller(private val context: Context) { | ||||
|             isRegistered = true | ||||
|  | ||||
|             val filter = IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE) | ||||
|             context.registerReceiver(this, filter) | ||||
|             ContextCompat.registerReceiver(context, this, filter, ContextCompat.RECEIVER_EXPORTED) | ||||
|         } | ||||
|  | ||||
|         /** | ||||
|   | ||||
| @@ -10,8 +10,6 @@ import uy.kohesive.injekt.api.get | ||||
|  | ||||
| fun Source.icon(): Drawable? = Injekt.get<ExtensionManager>().getAppIconForSource(this.id) | ||||
|  | ||||
| fun Source.getPreferenceKey(): String = "source_$id" | ||||
|  | ||||
| fun Source.toStubSource(): StubSource = StubSource(id = id, lang = lang, name = name) | ||||
|  | ||||
| fun Source.getNameForMangaInfo(): String { | ||||
|   | ||||
| @@ -102,7 +102,7 @@ class ExtensionDetailsScreenModel( | ||||
|         val extension = state.value.extension ?: return "" | ||||
|  | ||||
|         if (!extension.hasReadme) { | ||||
|             return "https://tachiyomi.org/help/faq/#extensions" | ||||
|             return "https://tachiyomi.org/docs/faq/browse/extensions" | ||||
|         } | ||||
|  | ||||
|         val pkgName = extension.pkgName.substringAfter("eu.kanade.tachiyomi.extension.") | ||||
|   | ||||
| @@ -39,7 +39,7 @@ import eu.kanade.presentation.util.Screen | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.preference.SharedPreferencesDataStore | ||||
| import eu.kanade.tachiyomi.source.ConfigurableSource | ||||
| import eu.kanade.tachiyomi.source.getPreferenceKey | ||||
| import eu.kanade.tachiyomi.source.sourcePreferences | ||||
| import eu.kanade.tachiyomi.widget.TachiyomiTextInputEditText.Companion.setIncognito | ||||
| import tachiyomi.domain.source.service.SourceManager | ||||
| import tachiyomi.presentation.core.components.material.Scaffold | ||||
| @@ -134,12 +134,9 @@ class SourcePreferencesFragment : PreferenceFragmentCompat() { | ||||
|  | ||||
|     private fun populateScreen(): PreferenceScreen { | ||||
|         val sourceId = requireArguments().getLong(SOURCE_ID) | ||||
|         val source = Injekt.get<SourceManager>().get(sourceId)!! | ||||
|         val source = Injekt.get<SourceManager>().get(sourceId)!! as ConfigurableSource | ||||
|  | ||||
|         check(source is ConfigurableSource) | ||||
|  | ||||
|         val sharedPreferences = requireContext().getSharedPreferences(source.getPreferenceKey(), Context.MODE_PRIVATE) | ||||
|         val dataStore = SharedPreferencesDataStore(sharedPreferences) | ||||
|         val dataStore = SharedPreferencesDataStore(source.sourcePreferences()) | ||||
|         preferenceManager.preferenceDataStore = dataStore | ||||
|  | ||||
|         val sourceScreen = preferenceManager.createPreferenceScreen(requireContext()) | ||||
|   | ||||
| @@ -35,8 +35,8 @@ import eu.kanade.domain.manga.model.toSManga | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.cache.CoverCache | ||||
| import eu.kanade.tachiyomi.data.download.DownloadManager | ||||
| import eu.kanade.tachiyomi.data.track.EnhancedTrackService | ||||
| import eu.kanade.tachiyomi.data.track.TrackManager | ||||
| import eu.kanade.tachiyomi.data.track.EnhancedTracker | ||||
| import eu.kanade.tachiyomi.data.track.TrackerManager | ||||
| import eu.kanade.tachiyomi.source.Source | ||||
| import eu.kanade.tachiyomi.source.model.SChapter | ||||
| import eu.kanade.tachiyomi.ui.browse.migration.MigrationFlags | ||||
| @@ -177,7 +177,7 @@ internal class MigrateDialogScreenModel( | ||||
|     } | ||||
|  | ||||
|     private val enhancedServices by lazy { | ||||
|         Injekt.get<TrackManager>().services.filterIsInstance<EnhancedTrackService>() | ||||
|         Injekt.get<TrackerManager>().trackers.filterIsInstance<EnhancedTracker>() | ||||
|     } | ||||
|  | ||||
|     suspend fun migrateManga( | ||||
|   | ||||
| @@ -31,7 +31,7 @@ fun Screen.migrateSourceTab(): TabContent { | ||||
|                 title = stringResource(R.string.migration_help_guide), | ||||
|                 icon = Icons.Outlined.HelpOutline, | ||||
|                 onClick = { | ||||
|                     uriHandler.openUri("https://tachiyomi.org/help/guides/source-migration/") | ||||
|                     uriHandler.openUri("https://tachiyomi.org/docs/guides/source-migration") | ||||
|                 }, | ||||
|             ), | ||||
|         ), | ||||
|   | ||||
| @@ -9,39 +9,33 @@ import androidx.compose.ui.unit.dp | ||||
| import androidx.paging.Pager | ||||
| import androidx.paging.PagingConfig | ||||
| import androidx.paging.cachedIn | ||||
| import androidx.paging.filter | ||||
| import androidx.paging.map | ||||
| import cafe.adriel.voyager.core.model.StateScreenModel | ||||
| import cafe.adriel.voyager.core.model.coroutineScope | ||||
| import eu.kanade.core.preference.asState | ||||
| import eu.kanade.domain.base.BasePreferences | ||||
| import eu.kanade.domain.chapter.interactor.SyncChapterProgressWithTrack | ||||
| import eu.kanade.domain.manga.interactor.UpdateManga | ||||
| import eu.kanade.domain.manga.model.toDomainManga | ||||
| import eu.kanade.domain.source.service.SourcePreferences | ||||
| import eu.kanade.domain.track.model.toDomainTrack | ||||
| import eu.kanade.domain.track.interactor.AddTracks | ||||
| import eu.kanade.presentation.util.ioCoroutineScope | ||||
| import eu.kanade.tachiyomi.data.cache.CoverCache | ||||
| import eu.kanade.tachiyomi.data.track.EnhancedTrackService | ||||
| import eu.kanade.tachiyomi.data.track.TrackManager | ||||
| import eu.kanade.tachiyomi.data.track.TrackService | ||||
| import eu.kanade.tachiyomi.source.CatalogueSource | ||||
| import eu.kanade.tachiyomi.source.model.FilterList | ||||
| import eu.kanade.tachiyomi.util.removeCovers | ||||
| import kotlinx.coroutines.flow.SharingStarted | ||||
| import kotlinx.coroutines.flow.distinctUntilChanged | ||||
| import kotlinx.coroutines.flow.emptyFlow | ||||
| import kotlinx.coroutines.flow.filter | ||||
| import kotlinx.coroutines.flow.filterNotNull | ||||
| import kotlinx.coroutines.flow.firstOrNull | ||||
| import kotlinx.coroutines.flow.map | ||||
| import kotlinx.coroutines.flow.stateIn | ||||
| import kotlinx.coroutines.flow.update | ||||
| import kotlinx.coroutines.launch | ||||
| import logcat.LogPriority | ||||
| import tachiyomi.core.preference.CheckboxState | ||||
| import tachiyomi.core.preference.mapAsCheckboxState | ||||
| import tachiyomi.core.util.lang.launchIO | ||||
| import tachiyomi.core.util.system.logcat | ||||
| import tachiyomi.domain.category.interactor.GetCategories | ||||
| import tachiyomi.domain.category.interactor.SetMangaCategories | ||||
| import tachiyomi.domain.category.model.Category | ||||
| @@ -54,7 +48,6 @@ import tachiyomi.domain.manga.model.Manga | ||||
| import tachiyomi.domain.manga.model.toMangaUpdate | ||||
| import tachiyomi.domain.source.interactor.GetRemoteManga | ||||
| import tachiyomi.domain.source.service.SourceManager | ||||
| import tachiyomi.domain.track.interactor.InsertTrack | ||||
| import uy.kohesive.injekt.Injekt | ||||
| import uy.kohesive.injekt.api.get | ||||
| import java.util.Date | ||||
| @@ -76,12 +69,9 @@ class BrowseSourceScreenModel( | ||||
|     private val getManga: GetManga = Injekt.get(), | ||||
|     private val networkToLocalManga: NetworkToLocalManga = Injekt.get(), | ||||
|     private val updateManga: UpdateManga = Injekt.get(), | ||||
|     private val insertTrack: InsertTrack = Injekt.get(), | ||||
|     private val syncChapterProgressWithTrack: SyncChapterProgressWithTrack = Injekt.get(), | ||||
|     private val addTracks: AddTracks = Injekt.get(), | ||||
| ) : StateScreenModel<BrowseSourceScreenModel.State>(State(Listing.valueOf(listingQuery))) { | ||||
|  | ||||
|     private val loggedServices by lazy { Injekt.get<TrackManager>().services.filter { it.isLoggedIn } } | ||||
|  | ||||
|     var displayMode by sourcePreferences.sourceDisplayMode().asState(coroutineScope) | ||||
|  | ||||
|     val source = sourceManager.getOrStub(sourceId) | ||||
| @@ -113,25 +103,20 @@ class BrowseSourceScreenModel( | ||||
|     /** | ||||
|      * Flow of Pager flow tied to [State.listing] | ||||
|      */ | ||||
|     private val hideInLibraryItems = sourcePreferences.hideInLibraryItems().get() | ||||
|     val mangaPagerFlowFlow = state.map { it.listing } | ||||
|         .distinctUntilChanged() | ||||
|         .map { listing -> | ||||
|             Pager( | ||||
|                 PagingConfig(pageSize = 25), | ||||
|             ) { | ||||
|             Pager(PagingConfig(pageSize = 25)) { | ||||
|                 getRemoteManga.subscribe(sourceId, listing.query ?: "", listing.filters) | ||||
|             }.flow.map { pagingData -> | ||||
|                 pagingData.map { | ||||
|                     networkToLocalManga.await(it.toDomainManga(sourceId)) | ||||
|                         .let { localManga -> | ||||
|                             getManga.subscribe(localManga.url, localManga.source) | ||||
|                         } | ||||
|                         .let { localManga -> getManga.subscribe(localManga.url, localManga.source) } | ||||
|                         .filterNotNull() | ||||
|                         .filter { localManga -> | ||||
|                             !sourcePreferences.hideInLibraryItems().get() || !localManga.favorite | ||||
|                         } | ||||
|                         .stateIn(ioCoroutineScope) | ||||
|                 } | ||||
|                     .filter { !hideInLibraryItems || !it.value.favorite } | ||||
|             } | ||||
|                 .cachedIn(ioCoroutineScope) | ||||
|         } | ||||
| @@ -248,8 +233,7 @@ class BrowseSourceScreenModel( | ||||
|                 new = new.removeCovers(coverCache) | ||||
|             } else { | ||||
|                 setMangaDefaultChapterFlags.await(manga) | ||||
|  | ||||
|                 autoAddTrack(manga) | ||||
|                 addTracks.bindEnhancedTracks(manga, source) | ||||
|             } | ||||
|  | ||||
|             updateManga.await(new.toMangaUpdate()) | ||||
| @@ -286,25 +270,6 @@ class BrowseSourceScreenModel( | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private suspend fun autoAddTrack(manga: Manga) { | ||||
|         loggedServices | ||||
|             .filterIsInstance<EnhancedTrackService>() | ||||
|             .filter { it.accept(source) } | ||||
|             .forEach { service -> | ||||
|                 try { | ||||
|                     service.match(manga)?.let { track -> | ||||
|                         track.manga_id = manga.id | ||||
|                         (service as TrackService).bind(track) | ||||
|                         insertTrack.await(track.toDomainTrack()!!) | ||||
|  | ||||
|                         syncChapterProgressWithTrack.await(manga.id, track.toDomainTrack()!!, service) | ||||
|                     } | ||||
|                 } catch (e: Exception) { | ||||
|                     logcat(LogPriority.WARN, e) { "Could not match manga: ${manga.title} with service $service" } | ||||
|                 } | ||||
|             } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Get user categories. | ||||
|      * | ||||
|   | ||||
| @@ -64,7 +64,7 @@ fun SourceFilterDialog( | ||||
|                     Button(onClick = { | ||||
|                         onFilter() | ||||
|                         onDismissRequest() | ||||
|                     },) { | ||||
|                     }) { | ||||
|                         Text(stringResource(R.string.action_filter)) | ||||
|                     } | ||||
|                 } | ||||
|   | ||||
| @@ -19,7 +19,6 @@ import kotlinx.coroutines.flow.update | ||||
| import kotlinx.coroutines.isActive | ||||
| import kotlinx.coroutines.launch | ||||
| import kotlinx.coroutines.withContext | ||||
| import tachiyomi.core.util.lang.awaitSingle | ||||
| import tachiyomi.domain.manga.interactor.GetManga | ||||
| import tachiyomi.domain.manga.interactor.NetworkToLocalManga | ||||
| import tachiyomi.domain.manga.model.Manga | ||||
| @@ -140,7 +139,7 @@ abstract class SearchScreenModel( | ||||
|  | ||||
|                     try { | ||||
|                         val page = withContext(coroutineDispatcher) { | ||||
|                             source.fetchSearchManga(1, query, source.getFilterList()).awaitSingle() | ||||
|                             source.getSearchManga(1, query, source.getFilterList()) | ||||
|                         } | ||||
|  | ||||
|                         val titles = page.mangas.map { | ||||
|   | ||||
| @@ -12,6 +12,7 @@ import eu.kanade.presentation.category.CategoryScreen | ||||
| import eu.kanade.presentation.category.components.CategoryCreateDialog | ||||
| import eu.kanade.presentation.category.components.CategoryDeleteDialog | ||||
| import eu.kanade.presentation.category.components.CategoryRenameDialog | ||||
| import eu.kanade.presentation.category.components.CategorySortAlphabeticallyDialog | ||||
| import eu.kanade.presentation.util.Screen | ||||
| import eu.kanade.tachiyomi.util.system.toast | ||||
| import kotlinx.coroutines.flow.collectLatest | ||||
| @@ -37,6 +38,7 @@ class CategoryScreen : Screen() { | ||||
|         CategoryScreen( | ||||
|             state = successState, | ||||
|             onClickCreate = { screenModel.showDialog(CategoryDialog.Create) }, | ||||
|             onClickSortAlphabetically = { screenModel.showDialog(CategoryDialog.SortAlphabetically) }, | ||||
|             onClickRename = { screenModel.showDialog(CategoryDialog.Rename(it)) }, | ||||
|             onClickDelete = { screenModel.showDialog(CategoryDialog.Delete(it)) }, | ||||
|             onClickMoveUp = screenModel::moveUp, | ||||
| @@ -68,6 +70,12 @@ class CategoryScreen : Screen() { | ||||
|                     category = dialog.category, | ||||
|                 ) | ||||
|             } | ||||
|             is CategoryDialog.SortAlphabetically -> { | ||||
|                 CategorySortAlphabeticallyDialog( | ||||
|                     onDismissRequest = screenModel::dismissDialog, | ||||
|                     onSort = { screenModel.sortAlphabetically() }, | ||||
|                 ) | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         LaunchedEffect(Unit) { | ||||
|   | ||||
| @@ -61,6 +61,15 @@ class CategoryScreenModel( | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fun sortAlphabetically() { | ||||
|         coroutineScope.launch { | ||||
|             when (reorderCategory.sortAlphabetically()) { | ||||
|                 is ReorderCategory.Result.InternalError -> _events.send(CategoryEvent.InternalError) | ||||
|                 else -> {} | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fun moveUp(category: Category) { | ||||
|         coroutineScope.launch { | ||||
|             when (reorderCategory.moveUp(category)) { | ||||
| @@ -109,6 +118,7 @@ class CategoryScreenModel( | ||||
|  | ||||
| sealed interface CategoryDialog { | ||||
|     data object Create : CategoryDialog | ||||
|     data object SortAlphabetically : CategoryDialog | ||||
|     data class Rename(val category: Category) : CategoryDialog | ||||
|     data class Delete(val category: Category) : CategoryDialog | ||||
| } | ||||
|   | ||||
| @@ -1,13 +1,13 @@ | ||||
| package eu.kanade.tachiyomi.ui.download | ||||
|  | ||||
| import android.view.LayoutInflater | ||||
| import android.view.ViewGroup | ||||
| import androidx.compose.animation.AnimatedVisibility | ||||
| import androidx.compose.animation.fadeIn | ||||
| import androidx.compose.animation.fadeOut | ||||
| import androidx.compose.foundation.isSystemInDarkTheme | ||||
| import androidx.compose.foundation.layout.Box | ||||
| import androidx.compose.foundation.layout.Row | ||||
| import androidx.compose.foundation.layout.fillMaxWidth | ||||
| import androidx.compose.foundation.layout.padding | ||||
| import androidx.compose.material.icons.Icons | ||||
| import androidx.compose.material.icons.filled.PlayArrow | ||||
| @@ -42,7 +42,6 @@ import androidx.compose.ui.unit.dp | ||||
| import androidx.compose.ui.unit.sp | ||||
| import androidx.compose.ui.viewinterop.AndroidView | ||||
| import androidx.core.view.ViewCompat | ||||
| import androidx.core.view.updateLayoutParams | ||||
| import androidx.core.view.updatePadding | ||||
| import androidx.recyclerview.widget.LinearLayoutManager | ||||
| import cafe.adriel.voyager.core.model.rememberScreenModel | ||||
| @@ -243,6 +242,7 @@ object DownloadQueueScreen : Screen() { | ||||
|                 ) | ||||
|                 return@Scaffold | ||||
|             } | ||||
|  | ||||
|             val density = LocalDensity.current | ||||
|             val layoutDirection = LocalLayoutDirection.current | ||||
|             val left = with(density) { contentPadding.calculateLeftPadding(layoutDirection).toPx().roundToInt() } | ||||
| @@ -252,13 +252,13 @@ object DownloadQueueScreen : Screen() { | ||||
|  | ||||
|             Box(modifier = Modifier.nestedScroll(nestedScrollConnection)) { | ||||
|                 AndroidView( | ||||
|                     modifier = Modifier.fillMaxWidth(), | ||||
|                     factory = { context -> | ||||
|                         screenModel.controllerBinding = DownloadListBinding.inflate(LayoutInflater.from(context)) | ||||
|                         screenModel.adapter = DownloadAdapter(screenModel.listener) | ||||
|                         screenModel.controllerBinding.recycler.adapter = screenModel.adapter | ||||
|                         screenModel.controllerBinding.root.adapter = screenModel.adapter | ||||
|                         screenModel.adapter?.isHandleDragEnabled = true | ||||
|                         screenModel.adapter?.fastScroller = screenModel.controllerBinding.fastScroller | ||||
|                         screenModel.controllerBinding.recycler.layoutManager = LinearLayoutManager(context) | ||||
|                         screenModel.controllerBinding.root.layoutManager = LinearLayoutManager(context) | ||||
|  | ||||
|                         ViewCompat.setNestedScrollingEnabled(screenModel.controllerBinding.root, true) | ||||
|  | ||||
| @@ -274,7 +274,7 @@ object DownloadQueueScreen : Screen() { | ||||
|                         screenModel.controllerBinding.root | ||||
|                     }, | ||||
|                     update = { | ||||
|                         screenModel.controllerBinding.recycler | ||||
|                         screenModel.controllerBinding.root | ||||
|                             .updatePadding( | ||||
|                                 left = left, | ||||
|                                 top = top, | ||||
| @@ -282,14 +282,6 @@ object DownloadQueueScreen : Screen() { | ||||
|                                 bottom = bottom, | ||||
|                             ) | ||||
|  | ||||
|                         screenModel.controllerBinding.fastScroller | ||||
|                             .updateLayoutParams<ViewGroup.MarginLayoutParams> { | ||||
|                                 leftMargin = left | ||||
|                                 topMargin = top | ||||
|                                 rightMargin = right | ||||
|                                 bottomMargin = bottom | ||||
|                             } | ||||
|  | ||||
|                         screenModel.adapter?.updateDataSet(downloadList) | ||||
|                     }, | ||||
|                 ) | ||||
|   | ||||
| @@ -84,13 +84,17 @@ class DownloadQueueScreenModel( | ||||
|                         } | ||||
|                         reorder(newDownloads) | ||||
|                     } | ||||
|                     R.id.move_to_top_series -> { | ||||
|                     R.id.move_to_top_series, R.id.move_to_bottom_series -> { | ||||
|                         val (selectedSeries, otherSeries) = adapter?.currentItems | ||||
|                             ?.filterIsInstance<DownloadItem>() | ||||
|                             ?.map(DownloadItem::download) | ||||
|                             ?.partition { item.download.manga.id == it.manga.id } | ||||
|                             ?: Pair(emptyList(), emptyList()) | ||||
|                         reorder(selectedSeries + otherSeries) | ||||
|                         if (menuItem.itemId == R.id.move_to_top_series) { | ||||
|                             reorder(selectedSeries + otherSeries) | ||||
|                         } else { | ||||
|                             reorder(otherSeries + selectedSeries) | ||||
|                         } | ||||
|                     } | ||||
|                     R.id.cancel_download -> { | ||||
|                         cancel(listOf(item.download)) | ||||
| @@ -258,6 +262,6 @@ class DownloadQueueScreenModel( | ||||
|      * @return the holder of the download or null if it's not bound. | ||||
|      */ | ||||
|     private fun getHolder(download: Download): DownloadHolder? { | ||||
|         return controllerBinding.recycler.findViewHolderForItemId(download.chapter.id) as? DownloadHolder | ||||
|         return controllerBinding.root.findViewHolderForItemId(download.chapter.id) as? DownloadHolder | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -116,7 +116,7 @@ object HistoryTab : Tab { | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     suspend fun openChapter(context: Context, chapter: Chapter?) { | ||||
|     private suspend fun openChapter(context: Context, chapter: Chapter?) { | ||||
|         if (chapter != null) { | ||||
|             val intent = ReaderActivity.newIntent(context, chapter.mangaId, chapter.id) | ||||
|             context.startActivity(intent) | ||||
|   | ||||
| @@ -23,7 +23,7 @@ import eu.kanade.presentation.manga.DownloadAction | ||||
| import eu.kanade.tachiyomi.data.cache.CoverCache | ||||
| import eu.kanade.tachiyomi.data.download.DownloadCache | ||||
| import eu.kanade.tachiyomi.data.download.DownloadManager | ||||
| import eu.kanade.tachiyomi.data.track.TrackManager | ||||
| import eu.kanade.tachiyomi.data.track.TrackerManager | ||||
| import eu.kanade.tachiyomi.source.model.SManga | ||||
| import eu.kanade.tachiyomi.source.online.HttpSource | ||||
| import eu.kanade.tachiyomi.util.chapter.getNextUnread | ||||
| @@ -88,7 +88,7 @@ class LibraryScreenModel( | ||||
|     private val sourceManager: SourceManager = Injekt.get(), | ||||
|     private val downloadManager: DownloadManager = Injekt.get(), | ||||
|     private val downloadCache: DownloadCache = Injekt.get(), | ||||
|     private val trackManager: TrackManager = Injekt.get(), | ||||
|     private val trackerManager: TrackerManager = Injekt.get(), | ||||
| ) : StateScreenModel<LibraryScreenModel.State>(State()) { | ||||
|  | ||||
|     var activeCategoryIndex: Int by libraryPreferences.lastUsedCategory().asState(coroutineScope) | ||||
| @@ -101,9 +101,9 @@ class LibraryScreenModel( | ||||
|                 getTracksPerManga.subscribe(), | ||||
|                 getTrackingFilterFlow(), | ||||
|                 downloadCache.changes, | ||||
|             ) { searchQuery, library, tracks, loggedInTrackServices, _ -> | ||||
|             ) { searchQuery, library, tracks, loggedInTrackers, _ -> | ||||
|                 library | ||||
|                     .applyFilters(tracks, loggedInTrackServices) | ||||
|                     .applyFilters(tracks, loggedInTrackers) | ||||
|                     .applySort() | ||||
|                     .mapValues { (_, value) -> | ||||
|                         if (searchQuery != null) { | ||||
| @@ -169,7 +169,7 @@ class LibraryScreenModel( | ||||
|      */ | ||||
|     private suspend fun LibraryMap.applyFilters( | ||||
|         trackMap: Map<Long, List<Long>>, | ||||
|         loggedInTrackServices: Map<Long, TriState>, | ||||
|         loggedInTrackers: Map<Long, TriState>, | ||||
|     ): LibraryMap { | ||||
|         val prefs = getLibraryItemPreferencesFlow().first() | ||||
|         val downloadedOnly = prefs.globalFilterDownloaded | ||||
| @@ -180,10 +180,10 @@ class LibraryScreenModel( | ||||
|         val filterBookmarked = prefs.filterBookmarked | ||||
|         val filterCompleted = prefs.filterCompleted | ||||
|  | ||||
|         val isNotLoggedInAnyTrack = loggedInTrackServices.isEmpty() | ||||
|         val isNotLoggedInAnyTrack = loggedInTrackers.isEmpty() | ||||
|  | ||||
|         val excludedTracks = loggedInTrackServices.mapNotNull { if (it.value == TriState.ENABLED_NOT) it.key else null } | ||||
|         val includedTracks = loggedInTrackServices.mapNotNull { if (it.value == TriState.ENABLED_IS) it.key else null } | ||||
|         val excludedTracks = loggedInTrackers.mapNotNull { if (it.value == TriState.ENABLED_NOT) it.key else null } | ||||
|         val includedTracks = loggedInTrackers.mapNotNull { if (it.value == TriState.ENABLED_IS) it.key else null } | ||||
|         val trackFiltersIsIgnored = includedTracks.isEmpty() && excludedTracks.isEmpty() | ||||
|  | ||||
|         val filterFnDownloaded: (LibraryItem) -> Boolean = { | ||||
| @@ -366,14 +366,14 @@ class LibraryScreenModel( | ||||
|      * @return map of track id with the filter value | ||||
|      */ | ||||
|     private fun getTrackingFilterFlow(): Flow<Map<Long, TriState>> { | ||||
|         val loggedServices = trackManager.services.filter { it.isLoggedIn } | ||||
|         return if (loggedServices.isNotEmpty()) { | ||||
|             val prefFlows = loggedServices | ||||
|         val loggedInTrackers = trackerManager.trackers.filter { it.isLoggedIn } | ||||
|         return if (loggedInTrackers.isNotEmpty()) { | ||||
|             val prefFlows = loggedInTrackers | ||||
|                 .map { libraryPreferences.filterTracking(it.id.toInt()).changes() } | ||||
|                 .toTypedArray() | ||||
|             combine(*prefFlows) { | ||||
|                 loggedServices | ||||
|                     .mapIndexed { index, trackService -> trackService.id to it[index] } | ||||
|                 loggedInTrackers | ||||
|                     .mapIndexed { index, tracker -> tracker.id to it[index] } | ||||
|                     .toMap() | ||||
|             } | ||||
|         } else { | ||||
|   | ||||
| @@ -3,7 +3,7 @@ package eu.kanade.tachiyomi.ui.library | ||||
| import cafe.adriel.voyager.core.model.ScreenModel | ||||
| import cafe.adriel.voyager.core.model.coroutineScope | ||||
| import eu.kanade.domain.base.BasePreferences | ||||
| import eu.kanade.tachiyomi.data.track.TrackManager | ||||
| import eu.kanade.tachiyomi.data.track.TrackerManager | ||||
| import tachiyomi.core.preference.Preference | ||||
| import tachiyomi.core.preference.TriState | ||||
| import tachiyomi.core.preference.getAndSet | ||||
| @@ -22,11 +22,11 @@ class LibrarySettingsScreenModel( | ||||
|     val libraryPreferences: LibraryPreferences = Injekt.get(), | ||||
|     private val setDisplayMode: SetDisplayMode = Injekt.get(), | ||||
|     private val setSortModeForCategory: SetSortModeForCategory = Injekt.get(), | ||||
|     private val trackManager: TrackManager = Injekt.get(), | ||||
|     private val trackerManager: TrackerManager = Injekt.get(), | ||||
| ) : ScreenModel { | ||||
|  | ||||
|     val trackServices | ||||
|         get() = trackManager.services.filter { it.isLoggedIn } | ||||
|     val trackers | ||||
|         get() = trackerManager.trackers.filter { it.isLoggedIn } | ||||
|  | ||||
|     fun toggleFilter(preference: (LibraryPreferences) -> Preference<TriState>) { | ||||
|         preference(libraryPreferences).getAndSet { | ||||
|   | ||||
| @@ -158,7 +158,7 @@ object LibraryTab : Tab { | ||||
|                             EmptyScreenAction( | ||||
|                                 stringResId = R.string.getting_started_guide, | ||||
|                                 icon = Icons.Outlined.HelpOutline, | ||||
|                                 onClick = { handler.openUri("https://tachiyomi.org/help/guides/getting-started") }, | ||||
|                                 onClick = { handler.openUri("https://tachiyomi.org/docs/guides/getting-started") }, | ||||
|                             ), | ||||
|                         ), | ||||
|                     ) | ||||
|   | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user