diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml
index 87bd3a768..bda8001d6 100755
--- a/.idea/codeStyles/Project.xml
+++ b/.idea/codeStyles/Project.xml
@@ -23,6 +23,7 @@
+
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 338d25c9f..20cdafacd 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -9,6 +9,7 @@
+
+
+
+ @Query("SELECT * FROM manga WHERE manga_id IN (SELECT manga_id FROM favourites)")
+ abstract suspend fun findAllManga(): List
+
@Transaction
@Query("SELECT * FROM favourites WHERE manga_id = :id GROUP BY manga_id")
abstract suspend fun find(id: Long): FavouriteManga?
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/HistoryDao.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/HistoryDao.kt
index 52931b071..23c006949 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/db/HistoryDao.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/db/HistoryDao.kt
@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.core.db
import androidx.room.*
import org.koitharu.kotatsu.core.db.entity.HistoryEntity
import org.koitharu.kotatsu.core.db.entity.HistoryWithManga
+import org.koitharu.kotatsu.core.db.entity.MangaEntity
@Dao
@@ -15,6 +16,9 @@ abstract class HistoryDao {
@Query("SELECT * FROM history ORDER BY updated_at DESC LIMIT :limit OFFSET :offset")
abstract suspend fun findAll(offset: Int, limit: Int): List
+ @Query("SELECT * FROM manga WHERE manga_id IN (SELECT manga_id FROM history)")
+ abstract suspend fun findAllManga(): List
+
@Query("SELECT * FROM history WHERE manga_id = :id")
abstract suspend fun find(id: Long): HistoryEntity?
@@ -33,10 +37,11 @@ abstract class HistoryDao {
suspend fun update(entity: HistoryEntity) = update(entity.mangaId, entity.page, entity.chapterId, entity.scroll, entity.updatedAt)
@Transaction
- open suspend fun upsert(entity: HistoryEntity) {
- if (update(entity) == 0) {
+ open suspend fun upsert(entity: HistoryEntity): Boolean {
+ return if (update(entity) == 0) {
insert(entity)
- }
+ true
+ } else false
}
}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/TracksDao.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/TracksDao.kt
index 5eb087dde..ae72b2c11 100644
--- a/app/src/main/java/org/koitharu/kotatsu/core/db/TracksDao.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/core/db/TracksDao.kt
@@ -10,6 +10,9 @@ abstract class TracksDao {
@Query("SELECT * FROM tracks")
abstract suspend fun findAll(): List
+ @Query("SELECT * FROM tracks WHERE manga_id = :mangaId")
+ abstract suspend fun find(mangaId: Long): TrackEntity?
+
@Query("DELETE FROM tracks")
abstract suspend fun clear()
diff --git a/app/src/main/java/org/koitharu/kotatsu/core/model/MangaTracking.kt b/app/src/main/java/org/koitharu/kotatsu/core/model/MangaTracking.kt
new file mode 100644
index 000000000..e9a0629c9
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/core/model/MangaTracking.kt
@@ -0,0 +1,13 @@
+package org.koitharu.kotatsu.core.model
+
+import android.os.Parcelable
+import kotlinx.android.parcel.Parcelize
+import java.util.*
+
+@Parcelize
+data class MangaTracking (
+ val manga: Manga,
+ val knownChaptersCount: Int,
+ val lastChapterId: Long,
+ val lastCheck: Date?
+): Parcelable
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/domain/history/HistoryRepository.kt b/app/src/main/java/org/koitharu/kotatsu/domain/history/HistoryRepository.kt
index fc7df11ce..0346aa5bd 100644
--- a/app/src/main/java/org/koitharu/kotatsu/domain/history/HistoryRepository.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/domain/history/HistoryRepository.kt
@@ -10,6 +10,7 @@ import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.TagEntity
import org.koitharu.kotatsu.core.model.Manga
import org.koitharu.kotatsu.core.model.MangaHistory
+import org.koitharu.kotatsu.domain.tracking.TrackingRepository
import java.util.*
class HistoryRepository : KoinComponent {
@@ -25,7 +26,7 @@ class HistoryRepository : KoinComponent {
val tags = manga.tags.map(TagEntity.Companion::fromMangaTag)
db.tagsDao.upsert(tags)
db.mangaDao.upsert(MangaEntity.from(manga), tags)
- db.historyDao.upsert(
+ if (db.historyDao.upsert(
HistoryEntity(
mangaId = manga.id,
createdAt = System.currentTimeMillis(),
@@ -34,7 +35,9 @@ class HistoryRepository : KoinComponent {
page = page,
scroll = scroll
)
- )
+ )) {
+ TrackingRepository().insertOrNothing(manga)
+ }
notifyHistoryChanged()
}
diff --git a/app/src/main/java/org/koitharu/kotatsu/domain/tracking/TrackingRepository.kt b/app/src/main/java/org/koitharu/kotatsu/domain/tracking/TrackingRepository.kt
new file mode 100644
index 000000000..95ca283dc
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/domain/tracking/TrackingRepository.kt
@@ -0,0 +1,63 @@
+package org.koitharu.kotatsu.domain.tracking
+
+import org.koin.core.KoinComponent
+import org.koin.core.inject
+import org.koitharu.kotatsu.core.db.MangaDatabase
+import org.koitharu.kotatsu.core.db.entity.TrackEntity
+import org.koitharu.kotatsu.core.model.Manga
+import org.koitharu.kotatsu.core.model.MangaTracking
+import java.util.*
+
+class TrackingRepository : KoinComponent {
+
+ private val db: MangaDatabase by inject()
+
+ suspend fun getNewChaptersCount(mangaId: Long): Int {
+ val entity = db.tracksDao.find(mangaId) ?: return 0
+ return entity.newChapters
+ }
+
+ suspend fun getAllTracks(): List {
+ val favourites = db.favouritesDao.findAllManga()
+ val history = db.historyDao.findAllManga()
+ val manga = (favourites + history).distinctBy { it.id }
+ val tracks = db.tracksDao.findAll().groupBy { it.mangaId }
+ return manga.map { m ->
+ val track = tracks[m.id]?.singleOrNull()
+ MangaTracking(
+ manga = m.toManga(),
+ knownChaptersCount = track?.totalChapters ?: -1,
+ lastChapterId = track?.lastChapterId ?: 0,
+ lastCheck = track?.lastCheck?.takeUnless { it == 0L }?.let(::Date)
+ )
+ }
+ }
+
+ suspend fun storeTrackResult(
+ mangaId: Long,
+ knownChaptersCount: Int,
+ lastChapterId: Long,
+ newChapters: Int
+ ) {
+ val entity = TrackEntity(
+ mangaId = mangaId,
+ newChapters = newChapters,
+ lastCheck = System.currentTimeMillis(),
+ lastChapterId = lastChapterId,
+ totalChapters = knownChaptersCount
+ )
+ db.tracksDao.upsert(entity)
+ }
+
+ suspend fun insertOrNothing(manga: Manga) {
+ val chapters = manga.chapters ?: return
+ val entity = TrackEntity(
+ mangaId = manga.id,
+ totalChapters = chapters.size,
+ lastChapterId = chapters.lastOrNull()?.id ?: 0L,
+ newChapters = 0,
+ lastCheck = System.currentTimeMillis()
+ )
+ db.tracksDao.insert(entity)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/ui/common/BaseJobService.kt b/app/src/main/java/org/koitharu/kotatsu/ui/common/BaseJobService.kt
new file mode 100644
index 000000000..3be358a6e
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/ui/common/BaseJobService.kt
@@ -0,0 +1,39 @@
+package org.koitharu.kotatsu.ui.common
+
+import android.app.job.JobParameters
+import android.app.job.JobService
+import android.util.SparseArray
+import androidx.annotation.CallSuper
+import androidx.core.util.set
+import kotlinx.coroutines.*
+
+abstract class BaseJobService : JobService() {
+
+ private val jobServiceScope = object : CoroutineScope {
+ override val coroutineContext = Dispatchers.Main + SupervisorJob()
+ }
+ private val jobs = SparseArray(2)
+
+ @CallSuper
+ override fun onStartJob(params: JobParameters): Boolean {
+ jobs[params.jobId] = jobServiceScope.launch {
+ val isSuccess = try {
+ doWork(params)
+ true
+ } catch (_: Throwable) {
+ false
+ }
+ jobFinished(params, !isSuccess)
+ }
+ return true
+ }
+
+ @CallSuper
+ override fun onStopJob(params: JobParameters): Boolean {
+ val job = jobs[params.jobId] ?: return false
+ return !job.isCompleted
+ }
+
+ @Throws(Throwable::class)
+ protected abstract suspend fun doWork(params: JobParameters)
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/ui/common/BaseService.kt b/app/src/main/java/org/koitharu/kotatsu/ui/common/BaseService.kt
index 68959b7fa..6d9c39a74 100644
--- a/app/src/main/java/org/koitharu/kotatsu/ui/common/BaseService.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/ui/common/BaseService.kt
@@ -7,10 +7,9 @@ import androidx.annotation.CallSuper
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
-import org.koin.core.KoinComponent
import kotlin.coroutines.CoroutineContext
-abstract class BaseService : Service(), KoinComponent, CoroutineScope {
+abstract class BaseService : Service(), CoroutineScope {
private val job = SupervisorJob()
diff --git a/app/src/main/java/org/koitharu/kotatsu/ui/tracker/TrackerJobService.kt b/app/src/main/java/org/koitharu/kotatsu/ui/tracker/TrackerJobService.kt
new file mode 100644
index 000000000..cdff3d53d
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/ui/tracker/TrackerJobService.kt
@@ -0,0 +1,154 @@
+package org.koitharu.kotatsu.ui.tracker
+
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.job.JobInfo
+import android.app.job.JobParameters
+import android.app.job.JobScheduler
+import android.content.ComponentName
+import android.content.Context
+import android.os.Build
+import androidx.annotation.MainThread
+import androidx.annotation.RequiresApi
+import androidx.core.content.ContextCompat
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.core.model.Manga
+import org.koitharu.kotatsu.core.model.MangaChapter
+import org.koitharu.kotatsu.domain.MangaProviderFactory
+import org.koitharu.kotatsu.domain.tracking.TrackingRepository
+import org.koitharu.kotatsu.ui.common.BaseJobService
+import org.koitharu.kotatsu.utils.ext.safe
+import java.util.concurrent.TimeUnit
+
+class TrackerJobService : BaseJobService() {
+
+ private lateinit var repo: TrackingRepository
+
+ override fun onCreate() {
+ super.onCreate()
+ repo = TrackingRepository()
+ }
+
+ override suspend fun doWork(params: JobParameters) {
+ withContext(Dispatchers.IO) {
+ val tracks = repo.getAllTracks()
+ if (tracks.isEmpty()) {
+ return@withContext
+ }
+ var success = 0
+ for (track in tracks) {
+ val details = safe {
+ MangaProviderFactory.create(track.manga.source)
+ .getDetails(track.manga)
+ }
+ val chapters = details?.chapters ?: continue
+ when {
+ track.knownChaptersCount == -1 -> { //first check
+ repo.storeTrackResult(
+ mangaId = track.manga.id,
+ knownChaptersCount = chapters.size,
+ lastChapterId = chapters.lastOrNull()?.id ?: 0L,
+ newChapters = 0
+ )
+ }
+ track.knownChaptersCount == 0 && track.lastChapterId == 0L -> { //manga was empty on last check
+ repo.storeTrackResult(
+ mangaId = track.manga.id,
+ knownChaptersCount = track.knownChaptersCount,
+ lastChapterId = 0L,
+ newChapters = chapters.size
+ )
+ //TODO notify
+ }
+ chapters.size == track.knownChaptersCount -> {
+ if (chapters.lastOrNull()?.id == track.lastChapterId) {
+ // manga was not updated. skip
+ } else {
+ // number of chapters still the same, bu last chapter changed.
+ // maybe some chapters are removed. we need to find last known chapter
+ val knownChapter = chapters.indexOfLast { it.id == track.lastChapterId }
+ if (knownChapter == -1) {
+ // confuse. reset anything
+ repo.storeTrackResult(
+ mangaId = track.manga.id,
+ knownChaptersCount = chapters.size,
+ lastChapterId = chapters.lastOrNull()?.id ?: 0L,
+ newChapters = 0
+ )
+ } else {
+ repo.storeTrackResult(
+ mangaId = track.manga.id,
+ knownChaptersCount = knownChapter + 1,
+ lastChapterId = track.lastChapterId,
+ newChapters = chapters.size - knownChapter + 1
+ )
+ //TODO notify
+ }
+ }
+ }
+ else -> {
+ repo.storeTrackResult(
+ mangaId = track.manga.id,
+ knownChaptersCount = track.knownChaptersCount,
+ lastChapterId = track.lastChapterId,
+ newChapters = chapters.size - track.knownChaptersCount
+ )
+ //TODO notify
+ }
+ }
+ success++
+ }
+ if (success == 0) {
+ throw RuntimeException("Cannot check any manga updates")
+ }
+ }
+ }
+
+ @MainThread
+ private fun showNotification(manga: Manga, newChapters: List) {
+ //TODO
+ }
+
+ companion object {
+
+ private const val JOB_ID = 7
+ private const val CHANNEL_ID = "tracking"
+
+ @RequiresApi(Build.VERSION_CODES.O)
+ private fun createNotificationChannel(context: Context) {
+ val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ if (manager.getNotificationChannel(CHANNEL_ID) == null) {
+ val channel = NotificationChannel(CHANNEL_ID,
+ context.getString(R.string.new_chapters), NotificationManager.IMPORTANCE_DEFAULT)
+ channel.setShowBadge(true)
+ channel.lightColor = ContextCompat.getColor(context, R.color.blue_primary)
+ manager.createNotificationChannel(channel)
+ }
+ }
+
+ fun setup(context: Context) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ createNotificationChannel(context)
+ }
+ val scheduler = context.getSystemService(Context.JOB_SCHEDULER_SERVICE) as JobScheduler
+ // if (scheduler.allPendingJobs != null) {
+ // return
+ // }
+ val jobInfo = JobInfo.Builder(JOB_ID, ComponentName(context, TrackerJobService::class.java))
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ jobInfo.setRequiredNetworkType(JobInfo.NETWORK_TYPE_NOT_ROAMING)
+ } else {
+ jobInfo.setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY)
+ }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ jobInfo.setRequiresBatteryNotLow(true)
+ }
+ jobInfo.setRequiresDeviceIdle(true)
+ jobInfo.setPersisted(true)
+ jobInfo.setPeriodic(TimeUnit.HOURS.toMillis(6))
+ scheduler.schedule(jobInfo.build())
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml
index 241763581..c5775d26d 100644
--- a/app/src/main/res/values-ru/strings.xml
+++ b/app/src/main/res/values-ru/strings.xml
@@ -113,4 +113,5 @@
Сохранить мангу
Уведомления
Включено %1$d из %2$d
+ Новые главы
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 4ea2d4002..d35cd0b47 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -114,4 +114,5 @@
Save manga
Notifications
Enabled %1$d from %2$d
+ New chapters
\ No newline at end of file