Move sources from java to kotlin dir

This commit is contained in:
Koitharu
2023-05-22 18:16:50 +03:00
parent a8f5714b35
commit c3216871ed
711 changed files with 1 additions and 0 deletions

View File

@@ -0,0 +1,30 @@
package org.koitharu.kotatsu.tracker.data
import java.util.*
import org.koitharu.kotatsu.core.db.entity.toManga
import org.koitharu.kotatsu.core.db.entity.toMangaTags
import org.koitharu.kotatsu.tracker.domain.model.TrackingLogItem
fun TrackLogWithManga.toTrackingLogItem(counters: MutableMap<Long, Int>): TrackingLogItem {
val chaptersList = trackLog.chapters.split('\n').filterNot { x -> x.isEmpty() }
return TrackingLogItem(
id = trackLog.id,
chapters = chaptersList,
manga = manga.toManga(tags.toMangaTags()),
createdAt = Date(trackLog.createdAt),
isNew = counters.decrement(trackLog.mangaId, chaptersList.size),
)
}
private fun MutableMap<Long, Int>.decrement(key: Long, count: Int): Boolean {
val counter = get(key)
if (counter == null || counter <= 0) {
return false
}
if (counter < count) {
remove(key)
} else {
put(key, counter - count)
}
return true
}

View File

@@ -0,0 +1,30 @@
package org.koitharu.kotatsu.tracker.data
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.PrimaryKey
import org.koitharu.kotatsu.core.db.entity.MangaEntity
@Entity(
tableName = "tracks",
foreignKeys = [
ForeignKey(
entity = MangaEntity::class,
parentColumns = ["manga_id"],
childColumns = ["manga_id"],
onDelete = ForeignKey.CASCADE
)
]
)
class TrackEntity(
@PrimaryKey(autoGenerate = false)
@ColumnInfo(name = "manga_id") val mangaId: Long,
@get:Deprecated(message = "Should not be used", level = DeprecationLevel.ERROR)
@ColumnInfo(name = "chapters_total") val totalChapters: Int,
@ColumnInfo(name = "last_chapter_id") val lastChapterId: Long,
@ColumnInfo(name = "chapters_new") val newChapters: Int,
@ColumnInfo(name = "last_check") val lastCheck: Long,
@get:Deprecated(message = "Should not be used", level = DeprecationLevel.ERROR)
@ColumnInfo(name = "last_notified_id") val lastNotifiedChapterId: Long
)

View File

@@ -0,0 +1,26 @@
package org.koitharu.kotatsu.tracker.data
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.PrimaryKey
import org.koitharu.kotatsu.core.db.entity.MangaEntity
@Entity(
tableName = "track_logs",
foreignKeys = [
ForeignKey(
entity = MangaEntity::class,
parentColumns = ["manga_id"],
childColumns = ["manga_id"],
onDelete = ForeignKey.CASCADE
)
]
)
class TrackLogEntity(
@PrimaryKey(autoGenerate = true)
@ColumnInfo(name = "id") val id: Long = 0L,
@ColumnInfo(name = "manga_id", index = true) val mangaId: Long,
@ColumnInfo(name = "chapters") val chapters: String,
@ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis(),
)

View File

@@ -0,0 +1,23 @@
package org.koitharu.kotatsu.tracker.data
import androidx.room.Embedded
import androidx.room.Junction
import androidx.room.Relation
import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.MangaTagsEntity
import org.koitharu.kotatsu.core.db.entity.TagEntity
class TrackLogWithManga(
@Embedded val trackLog: TrackLogEntity,
@Relation(
parentColumn = "manga_id",
entityColumn = "manga_id"
)
val manga: MangaEntity,
@Relation(
parentColumn = "manga_id",
entityColumn = "tag_id",
associateBy = Junction(MangaTagsEntity::class)
)
val tags: List<TagEntity>,
)

View File

@@ -0,0 +1,55 @@
package org.koitharu.kotatsu.tracker.data
import androidx.room.Dao
import androidx.room.MapInfo
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Upsert
import kotlinx.coroutines.flow.Flow
import org.koitharu.kotatsu.core.db.entity.MangaWithTags
@Dao
abstract class TracksDao {
@Query("SELECT * FROM tracks")
abstract suspend fun findAll(): List<TrackEntity>
@Query("SELECT * FROM tracks WHERE manga_id IN (:ids)")
abstract suspend fun findAll(ids: Collection<Long>): List<TrackEntity>
@Query("SELECT * FROM tracks WHERE manga_id = :mangaId")
abstract suspend fun find(mangaId: Long): TrackEntity?
@Query("SELECT chapters_new FROM tracks WHERE manga_id = :mangaId")
abstract suspend fun findNewChapters(mangaId: Long): Int?
@MapInfo(keyColumn = "manga_id", valueColumn = "chapters_new")
@Query("SELECT manga_id, chapters_new FROM tracks")
abstract fun observeNewChaptersMap(): Flow<Map<Long, Int>>
@Query("SELECT chapters_new FROM tracks")
abstract fun observeNewChapters(): Flow<List<Int>>
@Query("SELECT chapters_new FROM tracks WHERE manga_id = :mangaId")
abstract fun observeNewChapters(mangaId: Long): Flow<Int?>
@Transaction
@MapInfo(valueColumn = "chapters_new")
@Query("SELECT manga.*, chapters_new FROM tracks LEFT JOIN manga ON manga.manga_id = tracks.manga_id WHERE chapters_new > 0 ORDER BY chapters_new DESC")
abstract fun observeUpdatedManga(): Flow<Map<MangaWithTags, Int>>
@Query("DELETE FROM tracks")
abstract suspend fun clear()
@Query("UPDATE tracks SET chapters_new = 0")
abstract suspend fun clearCounters()
@Query("DELETE FROM tracks WHERE manga_id = :mangaId")
abstract suspend fun delete(mangaId: Long)
@Query("DELETE FROM tracks WHERE manga_id NOT IN (SELECT manga_id FROM history UNION SELECT manga_id FROM favourites)")
abstract suspend fun gc()
@Upsert
abstract suspend fun upsert(entity: TrackEntity)
}

View File

@@ -0,0 +1,125 @@
package org.koitharu.kotatsu.tracker.domain
import androidx.annotation.VisibleForTesting
import javax.inject.Inject
import org.koitharu.kotatsu.core.model.getPreferredBranch
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.tracker.domain.model.MangaTracking
import org.koitharu.kotatsu.tracker.domain.model.MangaUpdates
import org.koitharu.kotatsu.tracker.work.TrackerNotificationChannels
import org.koitharu.kotatsu.tracker.work.TrackingItem
class Tracker @Inject constructor(
private val settings: AppSettings,
private val repository: TrackingRepository,
private val historyRepository: HistoryRepository,
private val channels: TrackerNotificationChannels,
private val mangaRepositoryFactory: MangaRepository.Factory,
) {
suspend fun getAllTracks(): List<TrackingItem> {
val sources = settings.trackSources
if (sources.isEmpty()) {
return emptyList()
}
val knownIds = HashSet<Manga>()
val result = ArrayList<TrackingItem>()
// Favourites
if (AppSettings.TRACK_FAVOURITES in sources) {
val favourites = repository.getAllFavouritesManga()
channels.updateChannels(favourites.keys)
for ((category, mangaList) in favourites) {
if (!category.isTrackingEnabled || mangaList.isEmpty()) {
continue
}
val categoryTracks = repository.getTracks(mangaList)
val channelId = if (channels.isFavouriteNotificationsEnabled(category)) {
channels.getFavouritesChannelId(category.id)
} else {
null
}
for (track in categoryTracks) {
if (knownIds.add(track.manga)) {
result.add(TrackingItem(track, channelId))
}
}
}
}
// History
if (AppSettings.TRACK_HISTORY in sources) {
val history = repository.getAllHistoryManga()
val historyTracks = repository.getTracks(history)
val channelId = if (channels.isHistoryNotificationsEnabled()) {
channels.getHistoryChannelId()
} else {
null
}
for (track in historyTracks) {
if (knownIds.add(track.manga)) {
result.add(TrackingItem(track, channelId))
}
}
}
result.trimToSize()
return result
}
suspend fun gc() {
repository.gc()
}
suspend fun fetchUpdates(track: MangaTracking, commit: Boolean): MangaUpdates {
val manga = mangaRepositoryFactory.create(track.manga.source).getDetails(track.manga)
val updates = compare(track, manga, getBranch(manga))
if (commit) {
repository.saveUpdates(updates)
}
return updates
}
@VisibleForTesting
suspend fun checkUpdates(manga: Manga, commit: Boolean): MangaUpdates {
val track = repository.getTrack(manga)
val updates = compare(track, manga, getBranch(manga))
if (commit) {
repository.saveUpdates(updates)
}
return updates
}
@VisibleForTesting
suspend fun deleteTrack(mangaId: Long) {
repository.deleteTrack(mangaId)
}
private suspend fun getBranch(manga: Manga): String? {
val history = historyRepository.getOne(manga)
return manga.getPreferredBranch(history)
}
/**
* The main functionality of tracker: check new chapters in [manga] comparing to the [track]
*/
private fun compare(track: MangaTracking, manga: Manga, branch: String?): MangaUpdates {
if (track.isEmpty()) {
// first check or manga was empty on last check
return MangaUpdates(manga, emptyList(), isValid = false)
}
val chapters = requireNotNull(manga.getChapters(branch))
val newChapters = chapters.takeLastWhile { x -> x.id != track.lastChapterId }
return when {
newChapters.isEmpty() -> {
MangaUpdates(manga, emptyList(), isValid = chapters.lastOrNull()?.id == track.lastChapterId)
}
newChapters.size == chapters.size -> {
MangaUpdates(manga, emptyList(), isValid = false)
}
else -> {
MangaUpdates(manga, newChapters, isValid = true)
}
}
}
}

View File

@@ -0,0 +1,209 @@
package org.koitharu.kotatsu.tracker.domain
import androidx.annotation.VisibleForTesting
import androidx.room.withTransaction
import dagger.Reusable
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.toManga
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.favourites.data.toFavouriteCategory
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.tracker.data.TrackEntity
import org.koitharu.kotatsu.tracker.data.TrackLogEntity
import org.koitharu.kotatsu.tracker.data.toTrackingLogItem
import org.koitharu.kotatsu.tracker.domain.model.MangaTracking
import org.koitharu.kotatsu.tracker.domain.model.MangaUpdates
import org.koitharu.kotatsu.tracker.domain.model.TrackingLogItem
import java.util.Date
import javax.inject.Inject
private const val NO_ID = 0L
@Reusable
class TrackingRepository @Inject constructor(
private val db: MangaDatabase,
) {
suspend fun getNewChaptersCount(mangaId: Long): Int {
return db.tracksDao.findNewChapters(mangaId) ?: 0
}
fun observeNewChaptersCount(mangaId: Long): Flow<Int> {
return db.tracksDao.observeNewChapters(mangaId).map { it ?: 0 }
}
fun observeUpdatedMangaCount(): Flow<Int> {
return db.tracksDao.observeNewChapters().map { list -> list.count { it > 0 } }
}
fun observeUpdatedManga(): Flow<Map<Manga, Int>> {
return db.tracksDao.observeUpdatedManga()
.map { x -> x.mapKeys { it.key.toManga() } }
.distinctUntilChanged()
}
suspend fun getTracks(mangaList: Collection<Manga>): List<MangaTracking> {
val ids = mangaList.mapToSet { it.id }
val tracks = db.tracksDao.findAll(ids).groupBy { it.mangaId }
val idSet = HashSet<Long>()
val result = ArrayList<MangaTracking>(mangaList.size)
for (item in mangaList) {
if (item.source == MangaSource.LOCAL || !idSet.add(item.id)) {
continue
}
val track = tracks[item.id]?.lastOrNull()
result += MangaTracking(
manga = item,
lastChapterId = track?.lastChapterId ?: NO_ID,
lastCheck = track?.lastCheck?.takeUnless { it == 0L }?.let(::Date),
)
}
return result
}
@VisibleForTesting
suspend fun getTrack(manga: Manga): MangaTracking {
val track = db.tracksDao.find(manga.id)
return MangaTracking(
manga = manga,
lastChapterId = track?.lastChapterId ?: NO_ID,
lastCheck = track?.lastCheck?.takeUnless { it == 0L }?.let(::Date),
)
}
@VisibleForTesting
suspend fun deleteTrack(mangaId: Long) {
db.tracksDao.delete(mangaId)
}
fun observeTrackingLog(limit: Flow<Int>): Flow<List<TrackingLogItem>> {
return limit.flatMapLatest { limitValue ->
combine(
db.tracksDao.observeNewChaptersMap(),
db.trackLogsDao.observeAll(limitValue),
) { counters, entities ->
val countersMap = counters.toMutableMap()
entities.map { x -> x.toTrackingLogItem(countersMap) }
}
}
}
suspend fun getLogsCount() = db.trackLogsDao.count()
suspend fun clearLogs() = db.trackLogsDao.clear()
suspend fun clearCounters() = db.tracksDao.clearCounters()
suspend fun gc() {
db.withTransaction {
db.tracksDao.gc()
db.trackLogsDao.gc()
}
}
suspend fun saveUpdates(updates: MangaUpdates) {
db.withTransaction {
val track = getOrCreateTrack(updates.manga.id).mergeWith(updates)
db.tracksDao.upsert(track)
if (updates.isValid && updates.newChapters.isNotEmpty()) {
updatePercent(updates)
val logEntity = TrackLogEntity(
mangaId = updates.manga.id,
chapters = updates.newChapters.joinToString("\n") { x -> x.name },
createdAt = System.currentTimeMillis(),
)
db.trackLogsDao.insert(logEntity)
}
}
}
suspend fun syncWithHistory(manga: Manga, chapterId: Long) {
val chapters = manga.chapters ?: return
val chapterIndex = chapters.indexOfFirst { x -> x.id == chapterId }
val track = getOrCreateTrack(manga.id)
val lastNewChapterIndex = chapters.size - track.newChapters
val lastChapterId = chapters.lastOrNull()?.id ?: NO_ID
val entity = TrackEntity(
mangaId = manga.id,
totalChapters = chapters.size,
lastChapterId = lastChapterId,
newChapters = when {
track.newChapters == 0 -> 0
chapterIndex < 0 -> track.newChapters
chapterIndex >= lastNewChapterIndex -> chapters.lastIndex - chapterIndex
else -> track.newChapters
},
lastCheck = System.currentTimeMillis(),
lastNotifiedChapterId = lastChapterId,
)
db.tracksDao.upsert(entity)
}
suspend fun getCategoriesCount(): IntArray {
val categories = db.favouriteCategoriesDao.findAll()
return intArrayOf(
categories.count { it.track },
categories.size,
)
}
suspend fun getAllFavouritesManga(): Map<FavouriteCategory, List<Manga>> {
val categories = db.favouriteCategoriesDao.findAll()
return categories.associateTo(LinkedHashMap(categories.size)) { categoryEntity ->
categoryEntity.toFavouriteCategory() to
db.favouritesDao.findAllManga(categoryEntity.categoryId).toMangaList()
}
}
suspend fun getAllHistoryManga(): List<Manga> {
return db.historyDao.findAllManga().toMangaList()
}
private suspend fun getOrCreateTrack(mangaId: Long): TrackEntity {
return db.tracksDao.find(mangaId) ?: TrackEntity(
mangaId = mangaId,
totalChapters = 0,
lastChapterId = 0L,
newChapters = 0,
lastCheck = 0L,
lastNotifiedChapterId = 0L,
)
}
private suspend fun updatePercent(updates: MangaUpdates) {
val history = db.historyDao.find(updates.manga.id) ?: return
val chapters = updates.manga.chapters
if (chapters.isNullOrEmpty()) {
return
}
val chapterIndex = chapters.indexOfFirst { it.id == history.chapterId }
if (chapterIndex < 0) {
return
}
val position = (chapters.size - updates.newChapters.size) * history.percent
val newPercent = position / chapters.size.toFloat()
db.historyDao.update(history.copy(percent = newPercent))
}
private fun TrackEntity.mergeWith(updates: MangaUpdates): TrackEntity {
val chapters = updates.manga.chapters.orEmpty()
return TrackEntity(
mangaId = mangaId,
totalChapters = chapters.size,
lastChapterId = chapters.lastOrNull()?.id ?: NO_ID,
newChapters = if (updates.isValid) newChapters + updates.newChapters.size else 0,
lastCheck = System.currentTimeMillis(),
lastNotifiedChapterId = NO_ID,
)
}
private fun Collection<MangaEntity>.toMangaList() = map { it.toManga(emptySet()) }
}

View File

@@ -0,0 +1,35 @@
package org.koitharu.kotatsu.tracker.domain.model
import java.util.*
import org.koitharu.kotatsu.parsers.model.Manga
class MangaTracking(
val manga: Manga,
val lastChapterId: Long,
val lastCheck: Date?,
) {
fun isEmpty(): Boolean {
return lastChapterId == 0L
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as MangaTracking
if (manga != other.manga) return false
if (lastChapterId != other.lastChapterId) return false
if (lastCheck != other.lastCheck) return false
return true
}
override fun hashCode(): Int {
var result = manga.hashCode()
result = 31 * result + lastChapterId.hashCode()
result = 31 * result + (lastCheck?.hashCode() ?: 0)
return result
}
}

View File

@@ -0,0 +1,13 @@
package org.koitharu.kotatsu.tracker.domain.model
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
class MangaUpdates(
val manga: Manga,
val newChapters: List<MangaChapter>,
val isValid: Boolean,
) {
fun isNotEmpty() = newChapters.isNotEmpty()
}

View File

@@ -0,0 +1,12 @@
package org.koitharu.kotatsu.tracker.domain.model
import java.util.*
import org.koitharu.kotatsu.parsers.model.Manga
data class TrackingLogItem(
val id: Long,
val manga: Manga,
val chapters: List<String>,
val createdAt: Date,
val isNew: Boolean,
)

View File

@@ -0,0 +1,146 @@
package org.koitharu.kotatsu.tracker.ui.feed
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.graphics.Insets
import androidx.core.view.updatePadding
import androidx.fragment.app.viewModels
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import coil.ImageLoader
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.ui.BaseFragment
import org.koitharu.kotatsu.core.ui.list.PaginationScrollListener
import org.koitharu.kotatsu.core.ui.list.decor.TypedSpacingItemDecoration
import org.koitharu.kotatsu.core.util.ext.addMenuProvider
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.databinding.FragmentFeedBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.list.ui.adapter.MangaListListener
import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.tracker.ui.feed.adapter.FeedAdapter
import org.koitharu.kotatsu.tracker.work.TrackWorker
import javax.inject.Inject
@AndroidEntryPoint
class FeedFragment :
BaseFragment<FragmentFeedBinding>(),
PaginationScrollListener.Callback,
MangaListListener, SwipeRefreshLayout.OnRefreshListener {
@Inject
lateinit var coil: ImageLoader
private val viewModel by viewModels<FeedViewModel>()
private var feedAdapter: FeedAdapter? = null
override fun onCreateViewBinding(
inflater: LayoutInflater,
container: ViewGroup?,
) = FragmentFeedBinding.inflate(inflater, container, false)
override fun onViewBindingCreated(binding: FragmentFeedBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState)
feedAdapter = FeedAdapter(coil, viewLifecycleOwner, this)
with(binding.recyclerView) {
adapter = feedAdapter
setHasFixedSize(true)
addOnScrollListener(PaginationScrollListener(4, this@FeedFragment))
val spacing = resources.getDimensionPixelOffset(R.dimen.list_spacing)
val decoration = TypedSpacingItemDecoration(
FeedAdapter.ITEM_TYPE_FEED to 0,
fallbackSpacing = spacing,
)
addItemDecoration(decoration)
}
with(binding.swipeRefreshLayout) {
setProgressBackgroundColorSchemeColor(context.getThemeColor(com.google.android.material.R.attr.colorPrimary))
setColorSchemeColors(context.getThemeColor(com.google.android.material.R.attr.colorOnPrimary))
setOnRefreshListener(this@FeedFragment)
}
addMenuProvider(
FeedMenuProvider(
binding.recyclerView,
(activity as? BottomNavOwner)?.bottomNav,
viewModel,
),
)
viewModel.content.observe(viewLifecycleOwner, this::onListChanged)
viewModel.onError.observe(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this))
viewModel.onFeedCleared.observe(viewLifecycleOwner) {
onFeedCleared()
}
TrackWorker.getIsRunningLiveData(binding.root.context.applicationContext)
.observe(viewLifecycleOwner, this::onIsTrackerRunningChanged)
}
override fun onDestroyView() {
feedAdapter = null
super.onDestroyView()
}
override fun onWindowInsetsChanged(insets: Insets) {
requireViewBinding().recyclerView.updatePadding(
bottom = insets.bottom,
)
}
override fun onRefresh() {
TrackWorker.startNow(context ?: return)
}
override fun onRetryClick(error: Throwable) = Unit
override fun onUpdateFilter(tags: Set<MangaTag>) = Unit
override fun onFilterClick(view: View?) = Unit
override fun onEmptyActionClick() = Unit
override fun onListHeaderClick(item: ListHeader, view: View) = Unit
private fun onListChanged(list: List<ListModel>) {
feedAdapter?.items = list
}
private fun onFeedCleared() {
val snackbar = Snackbar.make(
requireViewBinding().recyclerView,
R.string.updates_feed_cleared,
Snackbar.LENGTH_LONG,
)
snackbar.anchorView = (activity as? BottomNavOwner)?.bottomNav
snackbar.show()
}
private fun onIsTrackerRunningChanged(isRunning: Boolean) {
requireViewBinding().swipeRefreshLayout.isRefreshing = isRunning
}
override fun onScrolledToEnd() {
viewModel.requestMoreItems()
}
override fun onItemClick(item: Manga, view: View) {
startActivity(DetailsActivity.newIntent(context ?: return, item))
}
override fun onReadClick(manga: Manga, view: View) = Unit
override fun onTagClick(manga: Manga, tag: MangaTag, view: View) = Unit
companion object {
fun newInstance() = FeedFragment()
}
}

View File

@@ -0,0 +1,54 @@
package org.koitharu.kotatsu.tracker.ui.feed
import android.content.Context
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import androidx.core.view.MenuProvider
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.dialog.CheckBoxAlertDialog
import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.tracker.work.TrackWorker
class FeedMenuProvider(
private val snackbarHost: View,
private val anchorView: View?,
private val viewModel: FeedViewModel,
) : MenuProvider {
private val context: Context
get() = snackbarHost.context
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.opt_feed, menu)
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) {
R.id.action_update -> {
TrackWorker.startNow(context)
true
}
R.id.action_clear_feed -> {
CheckBoxAlertDialog.Builder(context)
.setTitle(R.string.clear_updates_feed)
.setMessage(R.string.text_clear_updates_feed_prompt)
.setNegativeButton(android.R.string.cancel, null)
.setCheckBoxChecked(true)
.setCheckBoxText(R.string.clear_new_chapters_counters)
.setPositiveButton(R.string.clear) { _, isChecked ->
viewModel.clearFeed(isChecked)
}.create().show()
true
}
R.id.action_settings -> {
val intent = SettingsActivity.newTrackerSettingsIntent(context)
context.startActivity(intent)
true
}
else -> false
}
}

View File

@@ -0,0 +1,95 @@
package org.koitharu.kotatsu.tracker.ui.feed
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.ui.model.DateTimeAgo
import org.koitharu.kotatsu.core.util.SingleLiveEvent
import org.koitharu.kotatsu.core.util.asFlowLiveData
import org.koitharu.kotatsu.core.util.ext.daysDiff
import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.tracker.domain.model.TrackingLogItem
import org.koitharu.kotatsu.tracker.ui.feed.model.toFeedItem
import java.util.Date
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject
private const val PAGE_SIZE = 20
@HiltViewModel
class FeedViewModel @Inject constructor(
private val repository: TrackingRepository,
) : BaseViewModel() {
private val limit = MutableStateFlow(PAGE_SIZE)
private val isReady = AtomicBoolean(false)
val onFeedCleared = SingleLiveEvent<Unit>()
val content = repository.observeTrackingLog(limit)
.map { list ->
if (list.isEmpty()) {
listOf(
EmptyState(
icon = R.drawable.ic_empty_feed,
textPrimary = R.string.text_empty_holder_primary,
textSecondary = R.string.text_feed_holder,
actionStringRes = 0,
),
)
} else {
isReady.set(true)
list.mapList()
}
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
fun clearFeed(clearCounters: Boolean) {
launchLoadingJob(Dispatchers.Default) {
repository.clearLogs()
if (clearCounters) {
repository.clearCounters()
}
onFeedCleared.emitCall(Unit)
}
}
fun requestMoreItems() {
if (isReady.compareAndSet(true, false)) {
limit.value += PAGE_SIZE
}
}
private fun List<TrackingLogItem>.mapList(): List<ListModel> {
val destination = ArrayList<ListModel>((size * 1.4).toInt())
var prevDate: DateTimeAgo? = null
for (item in this) {
val date = timeAgo(item.createdAt)
if (prevDate != date) {
destination += date
}
prevDate = date
destination += item.toFeedItem()
}
return destination
}
private fun timeAgo(date: Date): DateTimeAgo {
val diff = (System.currentTimeMillis() - date.time).coerceAtLeast(0L)
val diffMinutes = TimeUnit.MILLISECONDS.toMinutes(diff).toInt()
val diffDays = -date.daysDiff(System.currentTimeMillis())
return when {
diffMinutes < 3 -> DateTimeAgo.JustNow
diffDays < 1 -> DateTimeAgo.Today
diffDays == 1 -> DateTimeAgo.Yesterday
diffDays < 6 -> DateTimeAgo.DaysAgo(diffDays)
else -> DateTimeAgo.Absolute(date)
}
}
}

View File

@@ -0,0 +1,71 @@
package org.koitharu.kotatsu.tracker.ui.feed.adapter
import androidx.lifecycle.LifecycleOwner
import androidx.recyclerview.widget.DiffUtil
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import org.koitharu.kotatsu.core.ui.model.DateTimeAgo
import org.koitharu.kotatsu.list.ui.adapter.MangaListListener
import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD
import org.koitharu.kotatsu.list.ui.adapter.errorFooterAD
import org.koitharu.kotatsu.list.ui.adapter.errorStateListAD
import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
import org.koitharu.kotatsu.list.ui.adapter.relatedDateItemAD
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingFooter
import org.koitharu.kotatsu.tracker.ui.feed.model.FeedItem
import kotlin.jvm.internal.Intrinsics
class FeedAdapter(
coil: ImageLoader,
lifecycleOwner: LifecycleOwner,
listener: MangaListListener,
) : AsyncListDifferDelegationAdapter<ListModel>(DiffCallback()) {
init {
delegatesManager
.addDelegate(ITEM_TYPE_FEED, feedItemAD(coil, lifecycleOwner, listener))
.addDelegate(ITEM_TYPE_LOADING_FOOTER, loadingFooterAD())
.addDelegate(ITEM_TYPE_LOADING_STATE, loadingStateAD())
.addDelegate(ITEM_TYPE_ERROR_FOOTER, errorFooterAD(listener))
.addDelegate(ITEM_TYPE_ERROR_STATE, errorStateListAD(listener))
.addDelegate(ITEM_TYPE_EMPTY, emptyStateListAD(coil, lifecycleOwner, listener))
.addDelegate(ITEM_TYPE_DATE_HEADER, relatedDateItemAD())
}
private class DiffCallback : DiffUtil.ItemCallback<ListModel>() {
override fun areItemsTheSame(oldItem: ListModel, newItem: ListModel) = when {
oldItem is FeedItem && newItem is FeedItem -> {
oldItem.id == newItem.id
}
oldItem is DateTimeAgo && newItem is DateTimeAgo -> {
oldItem == newItem
}
oldItem is LoadingFooter && newItem is LoadingFooter -> {
oldItem.key == newItem.key
}
else -> oldItem.javaClass == newItem.javaClass
}
override fun areContentsTheSame(oldItem: ListModel, newItem: ListModel): Boolean {
return Intrinsics.areEqual(oldItem, newItem)
}
}
companion object {
const val ITEM_TYPE_FEED = 0
const val ITEM_TYPE_LOADING_FOOTER = 1
const val ITEM_TYPE_LOADING_STATE = 2
const val ITEM_TYPE_ERROR_STATE = 3
const val ITEM_TYPE_ERROR_FOOTER = 4
const val ITEM_TYPE_EMPTY = 5
const val ITEM_TYPE_HEADER = 6
const val ITEM_TYPE_DATE_HEADER = 7
}
}

View File

@@ -0,0 +1,51 @@
package org.koitharu.kotatsu.tracker.ui.feed.adapter
import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.disposeImageRequest
import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.isBold
import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.source
import org.koitharu.kotatsu.databinding.ItemFeedBinding
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.tracker.ui.feed.model.FeedItem
fun feedItemAD(
coil: ImageLoader,
lifecycleOwner: LifecycleOwner,
clickListener: OnListItemClickListener<Manga>,
) = adapterDelegateViewBinding<FeedItem, ListModel, ItemFeedBinding>(
{ inflater, parent -> ItemFeedBinding.inflate(inflater, parent, false) },
) {
itemView.setOnClickListener {
clickListener.onItemClick(item.manga, it)
}
bind {
binding.textViewTitle.isBold = item.isNew
binding.textViewSummary.isBold = item.isNew
binding.imageViewCover.newImageRequest(lifecycleOwner, item.imageUrl)?.run {
placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_error_placeholder)
allowRgb565(true)
source(item.manga.source)
enqueueWith(coil)
}
binding.textViewTitle.text = item.title
binding.textViewSummary.text = context.resources.getQuantityString(
R.plurals.new_chapters,
item.count,
item.count,
)
}
onViewRecycled {
binding.imageViewCover.disposeImageRequest()
}
}

View File

@@ -0,0 +1,13 @@
package org.koitharu.kotatsu.tracker.ui.feed.model
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.Manga
data class FeedItem(
val id: Long,
val imageUrl: String,
val title: String,
val manga: Manga,
val count: Int,
val isNew: Boolean,
) : ListModel

View File

@@ -0,0 +1,12 @@
package org.koitharu.kotatsu.tracker.ui.feed.model
import org.koitharu.kotatsu.tracker.domain.model.TrackingLogItem
fun TrackingLogItem.toFeedItem() = FeedItem(
id = id,
imageUrl = manga.coverUrl,
title = manga.title,
count = chapters.size,
manga = manga,
isNew = isNew,
)

View File

@@ -0,0 +1,49 @@
package org.koitharu.kotatsu.tracker.ui.updates
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.core.graphics.Insets
import androidx.core.view.updatePadding
import androidx.fragment.app.commit
import com.google.android.material.appbar.AppBarLayout
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.databinding.ActivityContainerBinding
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
@AndroidEntryPoint
class UpdatesActivity :
BaseActivity<ActivityContainerBinding>(),
AppBarOwner {
override val appBar: AppBarLayout
get() = viewBinding.appbar
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(ActivityContainerBinding.inflate(layoutInflater))
supportActionBar?.setDisplayHomeAsUpEnabled(true)
val fm = supportFragmentManager
if (fm.findFragmentById(R.id.container) == null) {
fm.commit {
setReorderingAllowed(true)
val fragment = UpdatesFragment.newInstance()
replace(R.id.container, fragment)
}
}
}
override fun onWindowInsetsChanged(insets: Insets) {
viewBinding.root.updatePadding(
left = insets.left,
right = insets.right,
)
}
companion object {
fun newIntent(context: Context) = Intent(context, UpdatesActivity::class.java)
}
}

View File

@@ -0,0 +1,19 @@
package org.koitharu.kotatsu.tracker.ui.updates
import androidx.fragment.app.viewModels
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.list.ui.MangaListFragment
@AndroidEntryPoint
class UpdatesFragment : MangaListFragment() {
override val viewModel by viewModels<UpdatesViewModel>()
override val isSwipeRefreshEnabled = false
override fun onScrolledToEnd() = Unit
companion object {
fun newInstance() = UpdatesFragment()
}
}

View File

@@ -0,0 +1,81 @@
package org.koitharu.kotatsu.tracker.ui.updates
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.onStart
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.parser.MangaTagHighlighter
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.core.util.asFlowLiveData
import org.koitharu.kotatsu.core.util.ext.onFirst
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.history.domain.PROGRESS_NONE
import org.koitharu.kotatsu.list.ui.MangaListViewModel
import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.list.ui.model.toErrorState
import org.koitharu.kotatsu.list.ui.model.toGridModel
import org.koitharu.kotatsu.list.ui.model.toListDetailedModel
import org.koitharu.kotatsu.list.ui.model.toListModel
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import javax.inject.Inject
@HiltViewModel
class UpdatesViewModel @Inject constructor(
private val repository: TrackingRepository,
private val settings: AppSettings,
private val historyRepository: HistoryRepository,
private val tagHighlighter: MangaTagHighlighter,
downloadScheduler: DownloadWorker.Scheduler,
) : MangaListViewModel(settings, downloadScheduler) {
override val content = combine(
repository.observeUpdatedManga(),
listModeFlow,
) { mangaMap, mode ->
when {
mangaMap.isEmpty() -> listOf(
EmptyState(
icon = R.drawable.ic_empty_history,
textPrimary = R.string.text_history_holder_primary,
textSecondary = R.string.text_history_holder_secondary,
actionStringRes = 0,
),
)
else -> mapList(mangaMap, mode)
}
}.onStart {
loadingCounter.increment()
}.onFirst {
loadingCounter.decrement()
}.catch {
emit(listOf(it.toErrorState(canRetry = false)))
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
override fun onRefresh() = Unit
override fun onRetry() = Unit
private suspend fun mapList(
mangaMap: Map<Manga, Int>,
mode: ListMode,
): List<ListModel> {
val showPercent = settings.isReadingIndicatorsEnabled
return mangaMap.map { (manga, counter) ->
val percent = if (showPercent) historyRepository.getProgress(manga.id) else PROGRESS_NONE
when (mode) {
ListMode.LIST -> manga.toListModel(counter, percent)
ListMode.DETAILED_LIST -> manga.toListDetailedModel(counter, percent, tagHighlighter)
ListMode.GRID -> manga.toGridModel(counter, percent)
}
}
}
}

View File

@@ -0,0 +1,273 @@
package org.koitharu.kotatsu.tracker.work
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationCompat.VISIBILITY_PUBLIC
import androidx.core.app.NotificationCompat.VISIBILITY_SECRET
import androidx.core.app.PendingIntentCompat
import androidx.core.content.ContextCompat
import androidx.hilt.work.HiltWorker
import androidx.lifecycle.LiveData
import androidx.lifecycle.map
import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.Data
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ForegroundInfo
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.OutOfQuotaPolicy
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkInfo
import androidx.work.WorkManager
import androidx.work.WorkQuery
import androidx.work.WorkerParameters
import coil.ImageLoader
import coil.request.ImageRequest
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.logs.FileLogger
import org.koitharu.kotatsu.core.logs.TrackerLogger
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.ext.toBitmapOrNull
import org.koitharu.kotatsu.core.util.ext.trySetForeground
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.tracker.domain.Tracker
import org.koitharu.kotatsu.tracker.domain.model.MangaUpdates
import java.util.concurrent.TimeUnit
@HiltWorker
class TrackWorker @AssistedInject constructor(
@Assisted context: Context,
@Assisted workerParams: WorkerParameters,
private val coil: ImageLoader,
private val settings: AppSettings,
private val tracker: Tracker,
@TrackerLogger private val logger: FileLogger,
) : CoroutineWorker(context, workerParams) {
private val notificationManager by lazy {
applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
}
override suspend fun doWork(): Result {
logger.log("doWork()")
try {
return doWorkImpl()
} catch (e: Throwable) {
logger.log("fatal", e)
throw e
} finally {
withContext(NonCancellable) {
logger.flush()
notificationManager.cancel(WORKER_NOTIFICATION_ID)
}
}
}
private suspend fun doWorkImpl(): Result {
if (!settings.isTrackerEnabled) {
return Result.success(workDataOf(0, 0))
}
trySetForeground()
val tracks = tracker.getAllTracks()
logger.log("Total ${tracks.size} tracks")
if (tracks.isEmpty()) {
return Result.success(workDataOf(0, 0))
}
val results = checkUpdatesAsync(tracks)
tracker.gc()
var success = 0
var failed = 0
results.forEach { x ->
if (x == null) {
failed++
} else {
success++
}
}
logger.log("Result: success: $success, failed: $failed")
val resultData = workDataOf(success, failed)
return if (success == 0 && failed != 0) {
Result.failure(resultData)
} else {
Result.success(resultData)
}
}
private suspend fun checkUpdatesAsync(tracks: List<TrackingItem>): List<MangaUpdates?> {
val dispatcher = Dispatchers.Default.limitedParallelism(MAX_PARALLELISM)
return supervisorScope {
tracks.map { (track, channelId) ->
async(dispatcher) {
runCatchingCancellable {
tracker.fetchUpdates(track, commit = true)
}.onFailure {
logger.log("checkUpdatesAsync", it)
}.onSuccess { updates ->
if (updates.isValid && updates.isNotEmpty()) {
showNotification(
manga = updates.manga,
channelId = channelId,
newChapters = updates.newChapters,
)
}
}.getOrNull()
}
}.awaitAll()
}
}
private suspend fun showNotification(manga: Manga, channelId: String?, newChapters: List<MangaChapter>) {
if (newChapters.isEmpty() || channelId == null) {
return
}
val id = manga.url.hashCode()
val colorPrimary = ContextCompat.getColor(applicationContext, R.color.blue_primary)
val builder = NotificationCompat.Builder(applicationContext, channelId)
val summary = applicationContext.resources.getQuantityString(
R.plurals.new_chapters,
newChapters.size,
newChapters.size,
)
with(builder) {
setContentText(summary)
setContentTitle(manga.title)
setNumber(newChapters.size)
setLargeIcon(
coil.execute(
ImageRequest.Builder(applicationContext)
.data(manga.coverUrl)
.tag(manga.source)
.build(),
).toBitmapOrNull(),
)
setSmallIcon(R.drawable.ic_stat_book_plus)
val style = NotificationCompat.InboxStyle(this)
for (chapter in newChapters) {
style.addLine(chapter.name)
}
style.setSummaryText(manga.title)
style.setBigContentTitle(summary)
setStyle(style)
val intent = DetailsActivity.newIntent(applicationContext, manga)
setContentIntent(
PendingIntentCompat.getActivity(
applicationContext,
id,
intent,
PendingIntent.FLAG_UPDATE_CURRENT,
false,
),
)
setAutoCancel(true)
setCategory(NotificationCompat.CATEGORY_PROMO)
setVisibility(if (manga.isNsfw) VISIBILITY_SECRET else VISIBILITY_PUBLIC)
setShortcutId(manga.id.toString())
priority = NotificationCompat.PRIORITY_DEFAULT
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
builder.setSound(settings.notificationSound)
var defaults = if (settings.notificationLight) {
setLights(colorPrimary, 1000, 5000)
NotificationCompat.DEFAULT_LIGHTS
} else 0
if (settings.notificationVibrate) {
builder.setVibrate(longArrayOf(500, 500, 500, 500))
defaults = defaults or NotificationCompat.DEFAULT_VIBRATE
}
builder.setDefaults(defaults)
}
}
notificationManager.notify(TAG, id, builder.build())
}
override suspend fun getForegroundInfo(): ForegroundInfo {
val title = applicationContext.getString(R.string.check_for_new_chapters)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
WORKER_CHANNEL_ID,
title,
NotificationManager.IMPORTANCE_LOW,
)
channel.setShowBadge(false)
channel.enableVibration(false)
channel.setSound(null, null)
channel.enableLights(false)
notificationManager.createNotificationChannel(channel)
}
val notification = NotificationCompat.Builder(applicationContext, WORKER_CHANNEL_ID)
.setContentTitle(title)
.setPriority(NotificationCompat.PRIORITY_MIN)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setDefaults(0)
.setOngoing(true)
.setSilent(true)
.setProgress(0, 0, true)
.setSmallIcon(android.R.drawable.stat_notify_sync)
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_DEFERRED)
.build()
return ForegroundInfo(WORKER_NOTIFICATION_ID, notification)
}
private fun workDataOf(success: Int, failed: Int): Data {
return Data.Builder()
.putInt(DATA_KEY_SUCCESS, success)
.putInt(DATA_KEY_FAILED, failed)
.build()
}
companion object {
private const val WORKER_CHANNEL_ID = "track_worker"
private const val WORKER_NOTIFICATION_ID = 35
private const val TAG = "tracking"
private const val TAG_ONESHOT = "tracking_oneshot"
private const val MAX_PARALLELISM = 4
private const val DATA_KEY_SUCCESS = "success"
private const val DATA_KEY_FAILED = "failed"
fun setup(context: Context) {
val constraints = Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()
val request = PeriodicWorkRequestBuilder<TrackWorker>(4, TimeUnit.HOURS)
.setConstraints(constraints)
.addTag(TAG)
.setBackoffCriteria(BackoffPolicy.LINEAR, 30, TimeUnit.MINUTES)
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(TAG, ExistingPeriodicWorkPolicy.KEEP, request)
}
fun startNow(context: Context) {
val constraints = Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()
val request = OneTimeWorkRequestBuilder<TrackWorker>()
.setConstraints(constraints)
.addTag(TAG_ONESHOT)
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.build()
WorkManager.getInstance(context).enqueue(request)
}
fun getIsRunningLiveData(context: Context): LiveData<Boolean> {
val query = WorkQuery.Builder.fromTags(listOf(TAG, TAG_ONESHOT)).build()
return WorkManager.getInstance(context).getWorkInfosLiveData(query).map { works ->
works.any { x -> x.state == WorkInfo.State.RUNNING }
}
}
}
}

View File

@@ -0,0 +1,145 @@
package org.koitharu.kotatsu.tracker.work
import android.app.NotificationChannel
import android.app.NotificationChannelGroup
import android.app.NotificationManager
import android.content.Context
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.core.app.NotificationManagerCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.prefs.AppSettings
class TrackerNotificationChannels @Inject constructor(
@ApplicationContext private val context: Context,
private val settings: AppSettings,
) {
private val manager = NotificationManagerCompat.from(context)
val areNotificationsDisabled: Boolean
get() = !manager.areNotificationsEnabled()
fun updateChannels(categories: Collection<FavouriteCategory>) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return
}
manager.deleteNotificationChannel(OLD_CHANNEL_ID)
val group = createGroup()
val existingChannels = group.channels.associateByTo(HashMap()) { it.id }
for (category in categories) {
val id = getFavouritesChannelId(category.id)
if (existingChannels.remove(id)?.name == category.title) {
continue
}
val channel = NotificationChannel(id, category.title, NotificationManager.IMPORTANCE_DEFAULT)
channel.group = GROUP_ID
manager.createNotificationChannel(channel)
}
existingChannels.remove(CHANNEL_ID_HISTORY)
createHistoryChannel()
for (id in existingChannels.keys) {
manager.deleteNotificationChannel(id)
}
}
fun createChannel(category: FavouriteCategory) {
renameChannel(category.id, category.title)
}
fun renameChannel(categoryId: Long, name: String) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return
}
val id = getFavouritesChannelId(categoryId)
val channel = NotificationChannel(id, name, NotificationManager.IMPORTANCE_DEFAULT)
channel.group = createGroup().id
manager.createNotificationChannel(channel)
}
fun deleteChannel(categoryId: Long) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return
}
manager.deleteNotificationChannel(getFavouritesChannelId(categoryId))
}
fun isFavouriteNotificationsEnabled(category: FavouriteCategory): Boolean {
if (!manager.areNotificationsEnabled()) {
return false
}
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = manager.getNotificationChannel(getFavouritesChannelId(category.id))
channel != null && channel.importance != NotificationManager.IMPORTANCE_NONE
} else {
// fallback
settings.isTrackerNotificationsEnabled
}
}
fun isHistoryNotificationsEnabled(): Boolean {
if (!manager.areNotificationsEnabled()) {
return false
}
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = manager.getNotificationChannel(getHistoryChannelId())
channel != null && channel.importance != NotificationManager.IMPORTANCE_NONE
} else {
// fallback
settings.isTrackerNotificationsEnabled
}
}
fun isNotificationGroupEnabled(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return settings.isTrackerNotificationsEnabled
}
val group = manager.getNotificationChannelGroup(GROUP_ID) ?: return true
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && group.isBlocked) {
return false
}
return group.channels.any { it.importance != NotificationManagerCompat.IMPORTANCE_NONE }
}
fun getFavouritesChannelId(categoryId: Long): String {
return CHANNEL_ID_PREFIX + categoryId
}
fun getHistoryChannelId(): String {
return CHANNEL_ID_HISTORY
}
@RequiresApi(Build.VERSION_CODES.O)
private fun createGroup(): NotificationChannelGroup {
manager.getNotificationChannelGroup(GROUP_ID)?.let {
return it
}
val group = NotificationChannelGroup(GROUP_ID, context.getString(R.string.new_chapters))
manager.createNotificationChannelGroup(group)
return group
}
private fun createHistoryChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return
}
val channel = NotificationChannel(
CHANNEL_ID_HISTORY,
context.getString(R.string.history),
NotificationManager.IMPORTANCE_DEFAULT,
)
channel.group = GROUP_ID
manager.createNotificationChannel(channel)
}
companion object {
const val GROUP_ID = "trackers"
private const val CHANNEL_ID_PREFIX = "track_fav_"
private const val CHANNEL_ID_HISTORY = "track_history"
private const val OLD_CHANNEL_ID = "tracking"
}
}

View File

@@ -0,0 +1,31 @@
package org.koitharu.kotatsu.tracker.work
import org.koitharu.kotatsu.tracker.domain.model.MangaTracking
class TrackingItem(
val tracking: MangaTracking,
val channelId: String?,
) {
operator fun component1() = tracking
operator fun component2() = channelId
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as TrackingItem
if (tracking != other.tracking) return false
if (channelId != other.channelId) return false
return true
}
override fun hashCode(): Int {
var result = tracking.hashCode()
result = 31 * result + channelId.hashCode()
return result
}
}