mirror of
				https://github.com/mihonapp/mihon.git
				synced 2025-10-29 21:37:56 +01:00 
			
		
		
		
	Add Kavita tracker (#7488)
* Added kavita tracker * Changed api endpoint since tachiyomi has it's own. Moved some processing to backend * Bugfix. Parsing to int instead of float * Ignore DOH, update migration and cleanup * Fix Unexpected JSON token modified: app/src/main/java/eu/kanade/tachiyomi/data/track/TrackManager.kt modified: app/src/main/java/eu/kanade/tachiyomi/data/track/kavita/KavitaApi.kt modified: app/src/main/java/eu/kanade/tachiyomi/data/track/kavita/KavitaModels.kt * Apply code format suggestions from code review Co-authored-by: Andreas <andreas.everos@gmail.com> * Apply simplified code suggestions from code review Co-authored-by: Andreas <andreas.everos@gmail.com> * Removed unused dtos * Use setter instead of function to get apiurl * Added Interceptor * Handle not configured/not accesible sources * Unused import * Added kavita to new tracking settings screen * Delete SettingsTrackingController.kt to solve conflict * Review comments * Removed break lines from log messages * Fixed jwt typo * Merged enhanced services compatibility warning message to be more generic. * Updated Komga String res to use new formatted one * Added Kavita String res to use formatted one * Apply suggestions from code review - hardcoded strings to track name Co-authored-by: Andreas <andreas.everos@gmail.com> Co-authored-by: Andreas <andreas.everos@gmail.com>
This commit is contained in:
		| @@ -164,11 +164,28 @@ class SettingsTrackingScreen : SearchableSettings { | ||||
|                             if (hasValidSourceInstalled) { | ||||
|                                 trackManager.komga.loginNoop() | ||||
|                             } else { | ||||
|                                 context.toast(R.string.tracker_komga_warning, Toast.LENGTH_LONG) | ||||
|                                 context.toast(context.getString(R.string.enhanced_tracking_warning, context.getString(trackManager.komga.nameRes())), Toast.LENGTH_LONG) | ||||
|                             } | ||||
|                         }, | ||||
|                         logout = trackManager.komga::logout, | ||||
|                     ), | ||||
|                     Preference.PreferenceItem.TrackingPreference( | ||||
|                         title = stringResource(trackManager.kavita.nameRes()), | ||||
|                         service = trackManager.kavita, | ||||
|                         login = { | ||||
|                             val sourceManager = Injekt.get<SourceManager>() | ||||
|                             val acceptedSources = trackManager.kavita.getAcceptedSources() | ||||
|                             val hasValidSourceInstalled = sourceManager.getCatalogueSources() | ||||
|                                 .any { it::class.qualifiedName in acceptedSources } | ||||
|  | ||||
|                             if (hasValidSourceInstalled) { | ||||
|                                 trackManager.kavita.loginNoop() | ||||
|                             } else { | ||||
|                                 context.toast(context.getString(R.string.enhanced_tracking_warning, context.getString(trackManager.kavita.nameRes())), Toast.LENGTH_LONG) | ||||
|                             } | ||||
|                         }, | ||||
|                         logout = trackManager.kavita::logout, | ||||
|                     ), | ||||
|                     Preference.PreferenceItem.InfoPreference(stringResource(R.string.enhanced_tracking_info)), | ||||
|                 ), | ||||
|             ), | ||||
|   | ||||
| @@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.data.track | ||||
| import android.content.Context | ||||
| import eu.kanade.tachiyomi.data.track.anilist.Anilist | ||||
| import eu.kanade.tachiyomi.data.track.bangumi.Bangumi | ||||
| import eu.kanade.tachiyomi.data.track.kavita.Kavita | ||||
| import eu.kanade.tachiyomi.data.track.kitsu.Kitsu | ||||
| import eu.kanade.tachiyomi.data.track.komga.Komga | ||||
| import eu.kanade.tachiyomi.data.track.mangaupdates.MangaUpdates | ||||
| @@ -19,6 +20,7 @@ class TrackManager(context: Context) { | ||||
|         const val BANGUMI = 5L | ||||
|         const val KOMGA = 6L | ||||
|         const val MANGA_UPDATES = 7L | ||||
|         const val KAVITA = 8L | ||||
|     } | ||||
|  | ||||
|     val myAnimeList = MyAnimeList(context, MYANIMELIST) | ||||
| @@ -28,8 +30,9 @@ class TrackManager(context: Context) { | ||||
|     val bangumi = Bangumi(context, BANGUMI) | ||||
|     val komga = Komga(context, KOMGA) | ||||
|     val mangaUpdates = MangaUpdates(context, MANGA_UPDATES) | ||||
|     val kavita = Kavita(context, KAVITA) | ||||
|  | ||||
|     val services = listOf(myAnimeList, aniList, kitsu, shikimori, bangumi, komga, mangaUpdates) | ||||
|     val services = listOf(myAnimeList, aniList, kitsu, shikimori, bangumi, komga, mangaUpdates, kavita) | ||||
|  | ||||
|     fun getService(id: Long) = services.find { it.id == id } | ||||
|  | ||||
|   | ||||
| @@ -0,0 +1,146 @@ | ||||
| package eu.kanade.tachiyomi.data.track.kavita | ||||
|  | ||||
| import android.app.Application | ||||
| import android.content.Context | ||||
| import android.content.SharedPreferences | ||||
| import android.graphics.Color | ||||
| import androidx.annotation.StringRes | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.database.models.Manga | ||||
| import eu.kanade.tachiyomi.data.database.models.Track | ||||
| import eu.kanade.tachiyomi.data.track.EnhancedTrackService | ||||
| import eu.kanade.tachiyomi.data.track.NoLoginTrackService | ||||
| import eu.kanade.tachiyomi.data.track.TrackService | ||||
| import eu.kanade.tachiyomi.data.track.model.TrackSearch | ||||
| import eu.kanade.tachiyomi.source.Source | ||||
| import uy.kohesive.injekt.Injekt | ||||
| import uy.kohesive.injekt.api.get | ||||
| import java.security.MessageDigest | ||||
|  | ||||
| class Kavita(private val context: Context, id: Long) : TrackService(id), EnhancedTrackService, NoLoginTrackService { | ||||
|     var authentications: OAuth? = null | ||||
|     companion object { | ||||
|         const val UNREAD = 1 | ||||
|         const val READING = 2 | ||||
|         const val COMPLETED = 3 | ||||
|     } | ||||
|  | ||||
|     private val interceptor by lazy { KavitaInterceptor(this) } | ||||
|     val api by lazy { KavitaApi(client, interceptor) } | ||||
|  | ||||
|     @StringRes | ||||
|     override fun nameRes() = R.string.tracker_kavita | ||||
|  | ||||
|     override fun getLogo(): Int = R.drawable.ic_tracker_kavita | ||||
|  | ||||
|     override fun getLogoColor() = Color.rgb(74, 198, 148) | ||||
|  | ||||
|     override fun getStatusList() = listOf(UNREAD, READING, COMPLETED) | ||||
|  | ||||
|     override fun getStatus(status: Int): String = with(context) { | ||||
|         when (status) { | ||||
|             Kavita.UNREAD -> getString(R.string.unread) | ||||
|             Kavita.READING -> getString(R.string.reading) | ||||
|             Kavita.COMPLETED -> getString(R.string.completed) | ||||
|             else -> "" | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun getReadingStatus(): Int = Kavita.READING | ||||
|  | ||||
|     override fun getRereadingStatus(): Int = -1 | ||||
|  | ||||
|     override fun getCompletionStatus(): Int = Kavita.COMPLETED | ||||
|  | ||||
|     override fun getScoreList(): List<String> = emptyList() | ||||
|  | ||||
|     override fun displayScore(track: Track): String = "" | ||||
|  | ||||
|     override suspend fun update(track: Track, didReadChapter: Boolean): Track { | ||||
|         if (track.status != COMPLETED) { | ||||
|             if (didReadChapter) { | ||||
|                 if (track.last_chapter_read.toInt() == track.total_chapters && track.total_chapters > 0) { | ||||
|                     track.status = COMPLETED | ||||
|                 } else { | ||||
|                     track.status = READING | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|         return api.updateProgress(track) | ||||
|     } | ||||
|  | ||||
|     override suspend fun bind(track: Track, hasReadChapters: Boolean): Track { | ||||
|         return track | ||||
|     } | ||||
|  | ||||
|     override suspend fun search(query: String): List<TrackSearch> { | ||||
|         TODO("Not yet implemented: search") | ||||
|     } | ||||
|  | ||||
|     override suspend fun refresh(track: Track): Track { | ||||
|         val remoteTrack = api.getTrackSearch(track.tracking_url) | ||||
|         track.copyPersonalFrom(remoteTrack) | ||||
|         track.total_chapters = remoteTrack.total_chapters | ||||
|         return track | ||||
|     } | ||||
|  | ||||
|     override suspend fun login(username: String, password: String) { | ||||
|         saveCredentials("user", "pass") | ||||
|     } | ||||
|  | ||||
|     // TrackService.isLogged works by checking that credentials are saved. | ||||
|     // By saving dummy, unused credentials, we can activate the tracker simply by login/logout | ||||
|     override fun loginNoop() { | ||||
|         saveCredentials("user", "pass") | ||||
|     } | ||||
|  | ||||
|     override fun getAcceptedSources() = listOf("eu.kanade.tachiyomi.extension.all.kavita.Kavita") | ||||
|  | ||||
|     override suspend fun match(manga: Manga): TrackSearch? = | ||||
|         try { | ||||
|             api.getTrackSearch(manga.url) | ||||
|         } catch (e: Exception) { | ||||
|             null | ||||
|         } | ||||
|  | ||||
|     override fun isTrackFrom(track: eu.kanade.domain.track.model.Track, manga: eu.kanade.domain.manga.model.Manga, source: Source?): Boolean = | ||||
|         track.remoteUrl == manga.url && source?.let { accept(it) } == true | ||||
|  | ||||
|     override fun migrateTrack(track: eu.kanade.domain.track.model.Track, manga: eu.kanade.domain.manga.model.Manga, newSource: Source): eu.kanade.domain.track.model.Track? = | ||||
|         if (accept(newSource)) { | ||||
|             track.copy(remoteUrl = manga.url) | ||||
|         } else { | ||||
|             null | ||||
|         } | ||||
|  | ||||
|     fun loadOAuth() { | ||||
|         val oauth = OAuth() | ||||
|         for (sourceId in 1..3) { | ||||
|             val authentication = oauth.authentications[sourceId - 1] | ||||
|             val sourceSuffixID by lazy { | ||||
|                 val key = "${"kavita_$sourceId"}/all/1" // Hardcoded versionID to 1 | ||||
|                 val bytes = MessageDigest.getInstance("MD5").digest(key.toByteArray()) | ||||
|                 (0..7).map { bytes[it].toLong() and 0xff shl 8 * (7 - it) } | ||||
|                     .reduce(Long::or) and Long.MAX_VALUE | ||||
|             } | ||||
|             val preferences: SharedPreferences by lazy { | ||||
|                 Injekt.get<Application>().getSharedPreferences("source_$sourceSuffixID", 0x0000) | ||||
|             } | ||||
|             val prefApiUrl = preferences.getString("APIURL", "")!! | ||||
|             if (prefApiUrl.isEmpty()) { | ||||
|                 // Source not configured. Skip | ||||
|                 continue | ||||
|             } | ||||
|             val prefApiKey = preferences.getString("APIKEY", "")!! | ||||
|             val token = api.getNewToken(apiUrl = prefApiUrl, apiKey = prefApiKey) | ||||
|  | ||||
|             if (token.isNullOrEmpty()) { | ||||
|                 // Source is not accessible. Skip | ||||
|                 continue | ||||
|             } | ||||
|             authentication.apiUrl = prefApiUrl | ||||
|             authentication.jwtToken = token.toString() | ||||
|         } | ||||
|         authentications = oauth | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,157 @@ | ||||
| package eu.kanade.tachiyomi.data.track.kavita | ||||
|  | ||||
| import eu.kanade.tachiyomi.data.database.models.Track | ||||
| import eu.kanade.tachiyomi.data.track.model.TrackSearch | ||||
| import eu.kanade.tachiyomi.network.GET | ||||
| import eu.kanade.tachiyomi.network.POST | ||||
| import eu.kanade.tachiyomi.network.await | ||||
| import eu.kanade.tachiyomi.network.parseAs | ||||
| import eu.kanade.tachiyomi.util.lang.withIOContext | ||||
| import eu.kanade.tachiyomi.util.system.logcat | ||||
| import logcat.LogPriority | ||||
| import okhttp3.Dns | ||||
| import okhttp3.MediaType.Companion.toMediaTypeOrNull | ||||
| import okhttp3.OkHttpClient | ||||
| import okhttp3.RequestBody.Companion.toRequestBody | ||||
| import java.net.SocketTimeoutException | ||||
|  | ||||
| class KavitaApi(private val client: OkHttpClient, interceptor: KavitaInterceptor) { | ||||
|     private val authClient = client.newBuilder().dns(Dns.SYSTEM).addInterceptor(interceptor).build() | ||||
|     fun getApiFromUrl(url: String): String { | ||||
|         return url.split("/api/").first() + "/api" | ||||
|     } | ||||
|  | ||||
|     fun getNewToken(apiUrl: String, apiKey: String): String? { | ||||
|         /* | ||||
|          * Uses url to compare against each source APIURL's to get the correct custom source preference. | ||||
|          * Now having source preference we can do getString("APIKEY") | ||||
|          * Authenticates to get the token | ||||
|          * Saves the token in the var jwtToken | ||||
|          */ | ||||
|  | ||||
|         val request = POST( | ||||
|             "$apiUrl/Plugin/authenticate?apiKey=$apiKey&pluginName=Tachiyomi-Kavita", | ||||
|             body = "{}".toRequestBody("application/json; charset=utf-8".toMediaTypeOrNull()), | ||||
|         ) | ||||
|         try { | ||||
|             client.newCall(request).execute().use { | ||||
|                 if (it.code == 200) { | ||||
|                     return it.parseAs<AuthenticationDto>().token | ||||
|                 } | ||||
|                 if (it.code == 401) { | ||||
|                     logcat(LogPriority.WARN) { "Unauthorized / api key not valid:Cleaned api URL:${apiUrl}Api key is empty:${apiKey.isEmpty()}" } | ||||
|                     throw Exception("Unauthorized / api key not valid") | ||||
|                 } | ||||
|                 if (it.code == 500) { | ||||
|                     logcat(LogPriority.WARN) { "Error fetching jwt token. Cleaned api URL:$apiUrl Api key is empty:${apiKey.isEmpty()}" } | ||||
|                     throw Exception("Error fetching jwt token") | ||||
|                 } | ||||
|             } | ||||
|             // Not sure which one to cathc | ||||
|         } catch (e: SocketTimeoutException) { | ||||
|             logcat(LogPriority.WARN) { | ||||
|                 "Could not fetch jwt token. Probably due to connectivity issue or the url '$apiUrl' is not available. Skipping" | ||||
|             } | ||||
|             return null | ||||
|         } catch (e: Exception) { | ||||
|             logcat(LogPriority.ERROR) { | ||||
|                 "Unhandled Exception fetching jwt token for url: '$apiUrl'" | ||||
|             } | ||||
|             throw e | ||||
|         } | ||||
|  | ||||
|         return null | ||||
|     } | ||||
|  | ||||
|     private fun getApiVolumesUrl(url: String): String { | ||||
|         return "${getApiFromUrl(url)}/Series/volumes?seriesId=${getIdFromUrl(url)}" | ||||
|     } | ||||
|  | ||||
|     private fun getIdFromUrl(url: String): Int { | ||||
|         /*Strips serie id from Url*/ | ||||
|         return url.substringAfterLast("/").toInt() | ||||
|     } | ||||
|  | ||||
|     private fun getTotalChapters(url: String): Int { | ||||
|         /*Returns total chapters in the series. | ||||
|          * Ignores volumes. | ||||
|          * Volumes consisting of 1 file treated as chapter | ||||
|          */ | ||||
|         val requestUrl = getApiVolumesUrl(url) | ||||
|         try { | ||||
|             val listVolumeDto = authClient.newCall(GET(requestUrl)) | ||||
|                 .execute() | ||||
|                 .parseAs<List<VolumeDto>>() | ||||
|             var volumeNumber = 0 | ||||
|             var maxChapterNumber = 0 | ||||
|             for (volume in listVolumeDto) { | ||||
|                 if (volume.chapters.maxOf { it.number!!.toFloat() } == 0f) { | ||||
|                     volumeNumber++ | ||||
|                 } else if (maxChapterNumber < volume.chapters.maxOf { it.number!!.toFloat() }) { | ||||
|                     maxChapterNumber = volume.chapters.maxOf { it.number!!.toFloat().toInt() } | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             return if (maxChapterNumber > volumeNumber) maxChapterNumber else volumeNumber | ||||
|         } catch (e: Exception) { | ||||
|             logcat(LogPriority.WARN, e) { "Exception fetching Total Chapters. Request:$requestUrl" } | ||||
|             throw e | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private fun getLatestChapterRead(url: String): Float { | ||||
|         val serieId = getIdFromUrl(url) | ||||
|         val requestUrl = "${getApiFromUrl(url)}/Tachiyomi/latest-chapter?seriesId=$serieId" | ||||
|         try { | ||||
|             authClient.newCall(GET(requestUrl)) | ||||
|                 .execute().use { | ||||
|                     if (it.code == 200) { | ||||
|                         return it.parseAs<ChapterDto>().number!!.replace(",", ".").toFloat() | ||||
|                     } | ||||
|                     if (it.code == 204) { | ||||
|                         return 0F | ||||
|                     } | ||||
|                 } | ||||
|         } catch (e: Exception) { | ||||
|             logcat(LogPriority.WARN, e) { "Exception getting latest chapter read. Could not get itemRequest:$requestUrl" } | ||||
|             throw e | ||||
|         } | ||||
|         return 0F | ||||
|     } | ||||
|  | ||||
|     suspend fun getTrackSearch(url: String): TrackSearch = | ||||
|         withIOContext { | ||||
|             try { | ||||
|                 val serieDto: SeriesDto = | ||||
|                     authClient.newCall(GET(url)) | ||||
|                         .await() | ||||
|                         .parseAs<SeriesDto>() | ||||
|  | ||||
|                 val track = serieDto.toTrack() | ||||
|  | ||||
|                 track.apply { | ||||
|                     cover_url = serieDto.thumbnail_url.toString() | ||||
|                     tracking_url = url | ||||
|                     total_chapters = getTotalChapters(url) | ||||
|  | ||||
|                     title = serieDto.name | ||||
|                     status = when (serieDto.pagesRead) { | ||||
|                         serieDto.pages -> Kavita.COMPLETED | ||||
|                         0 -> Kavita.UNREAD | ||||
|                         else -> Kavita.READING | ||||
|                     } | ||||
|                     last_chapter_read = getLatestChapterRead(url) | ||||
|                 } | ||||
|             } catch (e: Exception) { | ||||
|                 logcat(LogPriority.WARN, e) { "Could not get item: $url" } | ||||
|                 throw e | ||||
|             } | ||||
|         } | ||||
|  | ||||
|     suspend fun updateProgress(track: Track): Track { | ||||
|         val requestUrl = "${getApiFromUrl(track.tracking_url)}/Tachiyomi/mark-chapter-until-as-read?seriesId=${getIdFromUrl(track.tracking_url)}&chapterNumber=${track.last_chapter_read}" | ||||
|         authClient.newCall(POST(requestUrl, body = "{}".toRequestBody("application/json; charset=utf-8".toMediaTypeOrNull()))) | ||||
|             .await() | ||||
|         return getTrackSearch(track.tracking_url) | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,26 @@ | ||||
| package eu.kanade.tachiyomi.data.track.kavita | ||||
|  | ||||
| import eu.kanade.tachiyomi.BuildConfig | ||||
| import okhttp3.Interceptor | ||||
| import okhttp3.Response | ||||
|  | ||||
| class KavitaInterceptor(private val kavita: Kavita) : Interceptor { | ||||
|  | ||||
|     override fun intercept(chain: Interceptor.Chain): Response { | ||||
|         val originalRequest = chain.request() | ||||
|         if (kavita.authentications == null) { | ||||
|             kavita.loadOAuth() | ||||
|         } | ||||
|         val jwtToken = kavita.authentications?.getToken( | ||||
|             kavita.api.getApiFromUrl(originalRequest.url.toString()), | ||||
|         ) | ||||
|  | ||||
|         // Add the authorization header to the original request. | ||||
|         val authRequest = originalRequest.newBuilder() | ||||
|             .addHeader("Authorization", "Bearer $jwtToken") | ||||
|             .header("User-Agent", "Tachiyomi Kavita v${BuildConfig.VERSION_NAME}") | ||||
|             .build() | ||||
|  | ||||
|         return chain.proceed(authRequest) | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,70 @@ | ||||
| package eu.kanade.tachiyomi.data.track.kavita | ||||
|  | ||||
| import eu.kanade.tachiyomi.data.track.TrackManager | ||||
| import eu.kanade.tachiyomi.data.track.model.TrackSearch | ||||
| import kotlinx.serialization.Serializable | ||||
|  | ||||
| @Serializable | ||||
| data class SeriesDto( | ||||
|     val id: Int, | ||||
|     val name: String, | ||||
|     val originalName: String = "", | ||||
|     val thumbnail_url: String? = "", | ||||
|     val localizedName: String? = "", | ||||
|     val sortName: String? = "", | ||||
|     val pages: Int, | ||||
|     val coverImageLocked: Boolean = true, | ||||
|     val pagesRead: Int, | ||||
|     val userRating: Int? = 0, | ||||
|     val userReview: String? = "", | ||||
|     val format: Int, | ||||
|     val created: String? = "", | ||||
|     val libraryId: Int, | ||||
|     val libraryName: String? = "", | ||||
|  | ||||
| ) { | ||||
|     fun toTrack(): TrackSearch = TrackSearch.create(TrackManager.KAVITA).also { | ||||
|         it.title = name | ||||
|         it.summary = "" | ||||
|     } | ||||
| } | ||||
|  | ||||
| @Serializable | ||||
| data class VolumeDto( | ||||
|     val id: Int, | ||||
|     val number: Int, | ||||
|     val name: String, | ||||
|     val pages: Int, | ||||
|     val pagesRead: Int, | ||||
|     val lastModified: String, | ||||
|     val created: String, | ||||
|     val seriesId: Int, | ||||
|     val chapters: List<ChapterDto> = emptyList(), | ||||
| ) | ||||
|  | ||||
| @Serializable | ||||
| data class ChapterDto( | ||||
|     val id: Int? = -1, | ||||
|     val range: String? = "", | ||||
|     val number: String? = "-1", | ||||
|     val pages: Int? = 0, | ||||
|     val isSpecial: Boolean? = false, | ||||
|     val title: String? = "", | ||||
|     val pagesRead: Int? = 0, | ||||
|     val coverImageLocked: Boolean? = false, | ||||
|     val volumeId: Int? = -1, | ||||
|     val created: String? = "", | ||||
| ) | ||||
|  | ||||
| @Serializable | ||||
| data class AuthenticationDto( | ||||
|     val username: String, | ||||
|     val token: String, | ||||
|     val apiKey: String, | ||||
| ) | ||||
|  | ||||
| data class SourceAuth( | ||||
|     var sourceId: Int, | ||||
|     var apiUrl: String = "", | ||||
|     var jwtToken: String = "", | ||||
| ) | ||||
| @@ -0,0 +1,19 @@ | ||||
| package eu.kanade.tachiyomi.data.track.kavita | ||||
|  | ||||
| class OAuth( | ||||
|     val authentications: List<SourceAuth> = listOf<SourceAuth>( | ||||
|         SourceAuth(1), | ||||
|         SourceAuth(2), | ||||
|         SourceAuth(3), | ||||
|     ), | ||||
| ) { | ||||
|  | ||||
|     fun getToken(apiUrl: String): String? { | ||||
|         for (authentication in authentications) { | ||||
|             if (authentication.apiUrl == apiUrl) { | ||||
|                 return authentication.jwtToken | ||||
|             } | ||||
|         } | ||||
|         return null | ||||
|     } | ||||
| } | ||||
							
								
								
									
										
											BIN
										
									
								
								app/src/main/res/drawable-nodpi/ic_tracker_kavita.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								app/src/main/res/drawable-nodpi/ic_tracker_kavita.webp
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 3.5 KiB | 
		Reference in New Issue
	
	Block a user