Manga tracking job service

This commit is contained in:
Koitharu
2020-03-29 13:32:25 +03:00
parent 44b23d0b69
commit 80c8344f8d
14 changed files with 300 additions and 7 deletions

View File

@@ -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">

View File

@@ -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"

View File

@@ -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)
}

View File

@@ -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?

View File

@@ -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
}
}

View File

@@ -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()

View File

@@ -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

View File

@@ -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()
}

View File

@@ -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)
}
}

View File

@@ -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)
}

View File

@@ -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()

View File

@@ -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())
}
}
}

View File

@@ -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>

View File

@@ -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>