From 6cb6c891dde928040fdb8e946df25a332db69dfa Mon Sep 17 00:00:00 2001 From: Koitharu Date: Sun, 18 Feb 2024 13:11:41 +0200 Subject: [PATCH] Collecting reading stats --- .../kotatsu/core/backup/JsonDeserializer.kt | 1 + .../kotatsu/core/backup/JsonSerializer.kt | 1 + .../koitharu/kotatsu/core/db/MangaDatabase.kt | 10 ++- .../core/db/migrations/Migration18To19.kt | 12 ++++ .../kotatsu/core/prefs/AppSettings.kt | 6 +- .../kotatsu/history/data/HistoryEntity.kt | 5 +- .../kotatsu/history/data/HistoryRepository.kt | 2 + .../kotatsu/reader/ui/ReaderViewModel.kt | 12 ++-- .../koitharu/kotatsu/stats/data/StatsDao.kt | 11 +++ .../kotatsu/stats/data/StatsEntity.kt | 25 +++++++ .../kotatsu/stats/domain/StatsCollector.kt | 68 +++++++++++++++++++ 11 files changed, 143 insertions(+), 10 deletions(-) create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration18To19.kt create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/stats/data/StatsDao.kt create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/stats/data/StatsEntity.kt create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/stats/domain/StatsCollector.kt diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/JsonDeserializer.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/JsonDeserializer.kt index 60462178e..53e26189d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/JsonDeserializer.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/JsonDeserializer.kt @@ -54,6 +54,7 @@ class JsonDeserializer(private val json: JSONObject) { page = json.getInt("page"), scroll = json.getDouble("scroll").toFloat(), percent = json.getFloatOrDefault("percent", -1f), + chaptersCount = json.getIntOrDefault("chapters", -1), deletedAt = 0L, ) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/JsonSerializer.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/JsonSerializer.kt index 208f4ed32..cfe7451d0 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/JsonSerializer.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/JsonSerializer.kt @@ -41,6 +41,7 @@ class JsonSerializer private constructor(private val json: JSONObject) { put("page", e.page) put("scroll", e.scroll) put("percent", e.percent) + put("chapters", e.chaptersCount) }, ) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/MangaDatabase.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/MangaDatabase.kt index 5565f2c58..8d6571aaa 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/MangaDatabase.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/MangaDatabase.kt @@ -30,6 +30,7 @@ import org.koitharu.kotatsu.core.db.migrations.Migration14To15 import org.koitharu.kotatsu.core.db.migrations.Migration15To16 import org.koitharu.kotatsu.core.db.migrations.Migration16To17 import org.koitharu.kotatsu.core.db.migrations.Migration17To18 +import org.koitharu.kotatsu.core.db.migrations.Migration18To19 import org.koitharu.kotatsu.core.db.migrations.Migration1To2 import org.koitharu.kotatsu.core.db.migrations.Migration2To3 import org.koitharu.kotatsu.core.db.migrations.Migration3To4 @@ -48,20 +49,22 @@ import org.koitharu.kotatsu.history.data.HistoryDao import org.koitharu.kotatsu.history.data.HistoryEntity import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingDao import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingEntity +import org.koitharu.kotatsu.stats.data.StatsDao +import org.koitharu.kotatsu.stats.data.StatsEntity import org.koitharu.kotatsu.suggestions.data.SuggestionDao import org.koitharu.kotatsu.suggestions.data.SuggestionEntity import org.koitharu.kotatsu.tracker.data.TrackEntity import org.koitharu.kotatsu.tracker.data.TrackLogEntity import org.koitharu.kotatsu.tracker.data.TracksDao -const val DATABASE_VERSION = 18 +const val DATABASE_VERSION = 19 @Database( entities = [ MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class, FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class, TrackEntity::class, TrackLogEntity::class, SuggestionEntity::class, BookmarkEntity::class, - ScrobblingEntity::class, MangaSourceEntity::class, + ScrobblingEntity::class, MangaSourceEntity::class, StatsEntity::class, ], version = DATABASE_VERSION, ) @@ -90,6 +93,8 @@ abstract class MangaDatabase : RoomDatabase() { abstract fun getScrobblingDao(): ScrobblingDao abstract fun getSourcesDao(): MangaSourcesDao + + abstract fun getStatsDao(): StatsDao } fun getDatabaseMigrations(context: Context): Array = arrayOf( @@ -110,6 +115,7 @@ fun getDatabaseMigrations(context: Context): Array = arrayOf( Migration15To16(), Migration16To17(context), Migration17To18(), + Migration18To19(), ) fun MangaDatabase(context: Context): MangaDatabase = Room diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration18To19.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration18To19.kt new file mode 100644 index 000000000..5b4b0498b --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration18To19.kt @@ -0,0 +1,12 @@ +package org.koitharu.kotatsu.core.db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +class Migration18To19 : Migration(18, 19) { + + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE history ADD COLUMN `chapters` INTEGER NOT NULL DEFAULT -1") + db.execSQL("CREATE TABLE IF NOT EXISTS `stats` (`manga_id` INTEGER NOT NULL, `started_at` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `pages` INTEGER NOT NULL, PRIMARY KEY(`manga_id`, `started_at`), FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )") + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt index 931211d43..df5095b9e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt @@ -416,6 +416,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { val isPagesSavingAskEnabled: Boolean get() = prefs.getBoolean(KEY_PAGES_SAVE_ASK, true) + val isStatsEnabled: Boolean + get() = prefs.getBoolean(KEY_STATS_ENABLED, false) + fun isTipEnabled(tip: String): Boolean { return prefs.getStringSet(KEY_TIPS_CLOSED, emptySet())?.contains(tip) != true } @@ -606,8 +609,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { const val KEY_READING_TIME = "reading_time" const val KEY_PAGES_SAVE_DIR = "pages_dir" const val KEY_PAGES_SAVE_ASK = "pages_dir_ask" - - // About + const val KEY_STATS_ENABLED = "stats_on" const val KEY_APP_UPDATE = "app_update" const val KEY_APP_TRANSLATION = "about_app_translation" } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryEntity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryEntity.kt index c499e8c37..421565d20 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryEntity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryEntity.kt @@ -15,8 +15,8 @@ import org.koitharu.kotatsu.core.db.entity.MangaEntity parentColumns = ["manga_id"], childColumns = ["manga_id"], onDelete = ForeignKey.CASCADE, - ) - ] + ), + ], ) data class HistoryEntity( @PrimaryKey(autoGenerate = false) @@ -28,4 +28,5 @@ data class HistoryEntity( @ColumnInfo(name = "scroll") val scroll: Float, @ColumnInfo(name = "percent") val percent: Float, @ColumnInfo(name = "deleted_at") val deletedAt: Long, + @ColumnInfo(name = "chapters") val chaptersCount: Int, ) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryRepository.kt index 5d2e06292..c103fb506 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryRepository.kt @@ -94,6 +94,7 @@ class HistoryRepository @Inject constructor( if (shouldSkip(manga)) { return } + assert(manga.chapters != null) db.withTransaction { mangaRepository.storeManga(manga) db.getHistoryDao().upsert( @@ -105,6 +106,7 @@ class HistoryRepository @Inject constructor( page = page, scroll = scroll.toFloat(), // we migrate to int, but decide to not update database percent = percent, + chaptersCount = manga.chapters?.size ?: -1, deletedAt = 0L, ), ) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt index a16117e48..42665e775 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt @@ -58,6 +58,7 @@ import org.koitharu.kotatsu.reader.domain.DetectReaderModeUseCase import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.ui.config.ReaderSettings import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState +import org.koitharu.kotatsu.stats.domain.StatsCollector import java.time.Instant import javax.inject.Inject @@ -78,11 +79,11 @@ class ReaderViewModel @Inject constructor( private val detailsLoadUseCase: DetailsLoadUseCase, private val historyUpdateUseCase: HistoryUpdateUseCase, private val detectReaderModeUseCase: DetectReaderModeUseCase, + private val statsCollector: StatsCollector, ) : BaseViewModel() { private val intent = MangaIntent(savedStateHandle) private val preselectedBranch = savedStateHandle.get(ReaderActivity.EXTRA_BRANCH) - private val isIncognito = savedStateHandle.get(ReaderActivity.EXTRA_INCOGNITO) ?: false private var loadingJob: Job? = null private var pageSaveJob: Job? = null @@ -98,7 +99,7 @@ class ReaderViewModel @Inject constructor( val onShowToast = MutableEventFlow() val uiState = MutableStateFlow(null) - val incognitoMode = if (isIncognito) { + val incognitoMode = if (savedStateHandle.get(ReaderActivity.EXTRA_INCOGNITO) == true) { MutableStateFlow(true) } else mangaFlow.map { it != null && historyRepository.shouldSkip(it) @@ -208,7 +209,7 @@ class ReaderViewModel @Inject constructor( if (state != null) { currentState.value = state } - if (isIncognito) { + if (incognitoMode.value) { return } val readerState = state ?: currentState.value ?: return @@ -377,7 +378,7 @@ class ReaderViewModel @Inject constructor( chaptersLoader.loadSingleChapter(requireNotNull(currentState.value).chapterId) // save state - if (!isIncognito) { + if (!incognitoMode.value) { currentState.value?.let { val percent = computePercent(it.chapterId, it.page) historyUpdateUseCase.invoke(manga, it, percent) @@ -426,6 +427,9 @@ class ReaderViewModel @Inject constructor( percent = computePercent(state.chapterId, state.page), ) uiState.value = newState + if (!incognitoMode.value) { + statsCollector.onStateChanged(m.id, state) + } } private fun computePercent(chapterId: Long, pageIndex: Int): Float { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/stats/data/StatsDao.kt b/app/src/main/kotlin/org/koitharu/kotatsu/stats/data/StatsDao.kt new file mode 100644 index 000000000..b3ddb0cb9 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/stats/data/StatsDao.kt @@ -0,0 +1,11 @@ +package org.koitharu.kotatsu.stats.data + +import androidx.room.Dao +import androidx.room.Upsert + +@Dao +abstract class StatsDao { + + @Upsert + abstract suspend fun upsert(entity: StatsEntity) +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/stats/data/StatsEntity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/stats/data/StatsEntity.kt new file mode 100644 index 000000000..1807fb53c --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/stats/data/StatsEntity.kt @@ -0,0 +1,25 @@ +package org.koitharu.kotatsu.stats.data + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import org.koitharu.kotatsu.core.db.entity.MangaEntity + +@Entity( + tableName = "stats", + primaryKeys = ["manga_id", "started_at"], + foreignKeys = [ + ForeignKey( + entity = MangaEntity::class, + parentColumns = ["manga_id"], + childColumns = ["manga_id"], + onDelete = ForeignKey.CASCADE, + ), + ], +) +data class StatsEntity( + @ColumnInfo(name = "manga_id") val mangaId: Long, + @ColumnInfo(name = "started_at") val startedAt: Long, + @ColumnInfo(name = "duration") val duration: Long, + @ColumnInfo(name = "pages") val pages: Int, +) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/stats/domain/StatsCollector.kt b/app/src/main/kotlin/org/koitharu/kotatsu/stats/domain/StatsCollector.kt new file mode 100644 index 000000000..5970dfe74 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/stats/domain/StatsCollector.kt @@ -0,0 +1,68 @@ +package org.koitharu.kotatsu.stats.domain + +import androidx.collection.LongSparseArray +import androidx.collection.set +import dagger.hilt.android.ViewModelLifecycle +import dagger.hilt.android.scopes.ViewModelScoped +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.koitharu.kotatsu.core.db.MangaDatabase +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.core.util.RetainedLifecycleCoroutineScope +import org.koitharu.kotatsu.reader.ui.ReaderState +import org.koitharu.kotatsu.stats.data.StatsEntity +import javax.inject.Inject + +@ViewModelScoped +class StatsCollector @Inject constructor( + private val db: MangaDatabase, + private val settings: AppSettings, + lifecycle: ViewModelLifecycle, +) { + + private val viewModelScope = RetainedLifecycleCoroutineScope(lifecycle) + private val stats = LongSparseArray(1) + + @Synchronized + fun onStateChanged(mangaId: Long, state: ReaderState) { + if (!settings.isStatsEnabled) { + return + } + val now = System.currentTimeMillis() + val entry = stats[mangaId] + if (entry == null) { + stats[mangaId] = Entry( + state = state, + stats = StatsEntity( + mangaId = mangaId, + startedAt = now, + duration = 0, + pages = 0, + ), + ) + return + } + val pagesDelta = if (entry.state.page != state.page || entry.state.chapterId != state.chapterId) 1 else 0 + val newEntry = entry.copy( + stats = StatsEntity( + mangaId = mangaId, + startedAt = entry.stats.startedAt, + duration = now - entry.stats.startedAt, + pages = entry.stats.pages + pagesDelta, + ), + ) + stats[mangaId] = newEntry + commit(newEntry.stats) + } + + private fun commit(entity: StatsEntity) { + viewModelScope.launch(Dispatchers.Default) { + db.getStatsDao().upsert(entity) + } + } + + private data class Entry( + val state: ReaderState, + val stats: StatsEntity, + ) +}