Manga tracking job service
This commit is contained in:
1
.idea/codeStyles/Project.xml
generated
1
.idea/codeStyles/Project.xml
generated
@@ -23,6 +23,7 @@
|
||||
</option>
|
||||
</AndroidXmlCodeStyleSettings>
|
||||
<JetCodeStyleSettings>
|
||||
<option name="ALLOW_TRAILING_COMMA" value="true" />
|
||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||
</JetCodeStyleSettings>
|
||||
<codeStyleSettings language="CMake">
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
|
||||
<application
|
||||
android:name="org.koitharu.kotatsu.KotatsuApp"
|
||||
@@ -54,6 +55,10 @@
|
||||
android:foregroundServiceType="dataSync" />
|
||||
<service android:name=".ui.settings.AppUpdateService" />
|
||||
|
||||
<service android:name=".ui.tracker.TrackerJobService"
|
||||
android:exported="true"
|
||||
android:permission="android.permission.BIND_JOB_SERVICE" />
|
||||
|
||||
<provider
|
||||
android:name=".ui.search.MangaSuggestionsProvider"
|
||||
android:authorities="${applicationId}.MangaSuggestionsProvider"
|
||||
|
||||
@@ -25,6 +25,7 @@ import org.koitharu.kotatsu.core.local.cookies.persistence.SharedPrefsCookiePers
|
||||
import org.koitharu.kotatsu.core.parser.UserAgentInterceptor
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.domain.MangaLoaderContext
|
||||
import org.koitharu.kotatsu.ui.tracker.TrackerJobService
|
||||
import org.koitharu.kotatsu.utils.CacheUtils
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@@ -45,6 +46,7 @@ class KotatsuApp : Application() {
|
||||
if (BuildConfig.DEBUG) {
|
||||
initErrorHandler()
|
||||
}
|
||||
TrackerJobService.setup(this)
|
||||
AppCompatDelegate.setDefaultNightMode(AppSettings(this).theme)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.core.db
|
||||
import androidx.room.*
|
||||
import org.koitharu.kotatsu.core.db.entity.FavouriteEntity
|
||||
import org.koitharu.kotatsu.core.db.entity.FavouriteManga
|
||||
import org.koitharu.kotatsu.core.db.entity.MangaEntity
|
||||
|
||||
@Dao
|
||||
abstract class FavouritesDao {
|
||||
@@ -11,6 +12,9 @@ abstract class FavouritesDao {
|
||||
@Query("SELECT * FROM favourites GROUP BY manga_id ORDER BY :orderBy LIMIT :limit OFFSET :offset")
|
||||
abstract suspend fun findAll(offset: Int, limit: Int, orderBy: String): List<FavouriteManga>
|
||||
|
||||
@Query("SELECT * FROM manga WHERE manga_id IN (SELECT manga_id FROM favourites)")
|
||||
abstract suspend fun findAllManga(): List<MangaEntity>
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM favourites WHERE manga_id = :id GROUP BY manga_id")
|
||||
abstract suspend fun find(id: Long): FavouriteManga?
|
||||
|
||||
@@ -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<HistoryWithManga>
|
||||
|
||||
@Query("SELECT * FROM manga WHERE manga_id IN (SELECT manga_id FROM history)")
|
||||
abstract suspend fun findAllManga(): List<MangaEntity>
|
||||
|
||||
@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
|
||||
}
|
||||
|
||||
}
|
||||
@@ -10,6 +10,9 @@ abstract class TracksDao {
|
||||
@Query("SELECT * FROM tracks")
|
||||
abstract suspend fun findAll(): List<TrackEntity>
|
||||
|
||||
@Query("SELECT * FROM tracks WHERE manga_id = :mangaId")
|
||||
abstract suspend fun find(mangaId: Long): TrackEntity?
|
||||
|
||||
@Query("DELETE FROM tracks")
|
||||
abstract suspend fun clear()
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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<MangaTracking> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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<Job>(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)
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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<MangaChapter>) {
|
||||
//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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -113,4 +113,5 @@
|
||||
<string name="save_manga">Сохранить мангу</string>
|
||||
<string name="notifications">Уведомления</string>
|
||||
<string name="enabled_d_from_d">Включено %1$d из %2$d</string>
|
||||
<string name="new_chapters">Новые главы</string>
|
||||
</resources>
|
||||
@@ -114,4 +114,5 @@
|
||||
<string name="save_manga">Save manga</string>
|
||||
<string name="notifications">Notifications</string>
|
||||
<string name="enabled_d_from_d">Enabled %1$d from %2$d</string>
|
||||
<string name="new_chapters">New chapters</string>
|
||||
</resources>
|
||||
Reference in New Issue
Block a user