Ability to resume download after IOException
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
package org.koitharu.kotatsu.download.domain
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.webkit.MimeTypeMap
|
||||
import coil.ImageLoader
|
||||
import coil.request.ImageRequest
|
||||
import coil.size.Scale
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
@@ -16,6 +16,7 @@ import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.download.ui.service.PausingHandle
|
||||
import org.koitharu.kotatsu.local.data.PagesCache
|
||||
import org.koitharu.kotatsu.local.domain.CbzMangaOutput
|
||||
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
|
||||
@@ -25,11 +26,9 @@ import org.koitharu.kotatsu.parsers.util.await
|
||||
import org.koitharu.kotatsu.utils.ext.deleteAwait
|
||||
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.utils.ext.referer
|
||||
import org.koitharu.kotatsu.utils.ext.waitForNetwork
|
||||
import org.koitharu.kotatsu.utils.progress.ProgressJob
|
||||
import java.io.File
|
||||
import org.koitharu.kotatsu.utils.progress.PausingProgressJob
|
||||
|
||||
private const val MAX_DOWNLOAD_ATTEMPTS = 3
|
||||
private const val MAX_FAILSAFE_ATTEMPTS = 2
|
||||
private const val DOWNLOAD_ERROR_DELAY = 500L
|
||||
private const val SLOWDOWN_DELAY = 200L
|
||||
|
||||
@@ -43,9 +42,6 @@ class DownloadManager(
|
||||
private val settings: AppSettings,
|
||||
) {
|
||||
|
||||
private val connectivityManager = context.getSystemService(
|
||||
Context.CONNECTIVITY_SERVICE
|
||||
) as ConnectivityManager
|
||||
private val coverWidth = context.resources.getDimensionPixelSize(
|
||||
androidx.core.R.dimen.compat_notification_large_icon_max_width
|
||||
)
|
||||
@@ -58,21 +54,24 @@ class DownloadManager(
|
||||
manga: Manga,
|
||||
chaptersIds: LongArray?,
|
||||
startId: Int,
|
||||
): ProgressJob<DownloadState> {
|
||||
): PausingProgressJob<DownloadState> {
|
||||
val stateFlow = MutableStateFlow<DownloadState>(
|
||||
DownloadState.Queued(startId = startId, manga = manga, cover = null)
|
||||
)
|
||||
val job = downloadMangaImpl(manga, chaptersIds?.takeUnless { it.isEmpty() }, stateFlow, startId)
|
||||
return ProgressJob(job, stateFlow)
|
||||
val pausingHandle = PausingHandle()
|
||||
val job = downloadMangaImpl(manga, chaptersIds?.takeUnless { it.isEmpty() }, stateFlow, pausingHandle, startId)
|
||||
return PausingProgressJob(job, stateFlow, pausingHandle)
|
||||
}
|
||||
|
||||
private fun downloadMangaImpl(
|
||||
manga: Manga,
|
||||
chaptersIds: LongArray?,
|
||||
outState: MutableStateFlow<DownloadState>,
|
||||
pausingHandle: PausingHandle,
|
||||
startId: Int,
|
||||
): Job = coroutineScope.launch(Dispatchers.Default + errorStateHandler(outState)) {
|
||||
@Suppress("NAME_SHADOWING") var manga = manga
|
||||
@Suppress("NAME_SHADOWING")
|
||||
var manga = manga
|
||||
val chaptersIdsSet = chaptersIds?.toMutableSet()
|
||||
val cover = loadCover(manga)
|
||||
outState.value = DownloadState.Queued(startId, manga, cover)
|
||||
@@ -108,38 +107,28 @@ class DownloadManager(
|
||||
"${chaptersIdsSet?.size} of ${chaptersIds?.size} requested chapters not found in manga"
|
||||
}
|
||||
for ((chapterIndex, chapter) in chapters.withIndex()) {
|
||||
val pages = repo.getPages(chapter)
|
||||
val pages = runFailsafe(outState, pausingHandle) {
|
||||
repo.getPages(chapter)
|
||||
}
|
||||
for ((pageIndex, page) in pages.withIndex()) {
|
||||
var retryCounter = 0
|
||||
failsafe@ while (true) {
|
||||
try {
|
||||
val url = repo.getPageUrl(page)
|
||||
val file = cache[url] ?: downloadFile(url, page.referer, destination, tempFileName)
|
||||
output.addPage(
|
||||
chapter = chapter,
|
||||
file = file,
|
||||
pageNumber = pageIndex,
|
||||
ext = MimeTypeMap.getFileExtensionFromUrl(url),
|
||||
)
|
||||
break@failsafe
|
||||
} catch (e: IOException) {
|
||||
if (retryCounter < MAX_DOWNLOAD_ATTEMPTS) {
|
||||
outState.value = DownloadState.WaitingForNetwork(startId, data, cover)
|
||||
delay(DOWNLOAD_ERROR_DELAY)
|
||||
connectivityManager.waitForNetwork()
|
||||
retryCounter++
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
runFailsafe(outState, pausingHandle) {
|
||||
val url = repo.getPageUrl(page)
|
||||
val file = cache[url] ?: downloadFile(url, page.referer, destination, tempFileName)
|
||||
output.addPage(
|
||||
chapter = chapter,
|
||||
file = file,
|
||||
pageNumber = pageIndex,
|
||||
ext = MimeTypeMap.getFileExtensionFromUrl(url)
|
||||
)
|
||||
}
|
||||
|
||||
outState.value = DownloadState.Progress(
|
||||
startId, data, cover,
|
||||
startId = startId,
|
||||
manga = data,
|
||||
cover = cover,
|
||||
totalChapters = chapters.size,
|
||||
currentChapter = chapterIndex,
|
||||
totalPages = pages.size,
|
||||
currentPage = pageIndex,
|
||||
currentPage = pageIndex
|
||||
)
|
||||
|
||||
if (settings.isDownloadsSlowdownEnabled) {
|
||||
@@ -157,15 +146,40 @@ class DownloadManager(
|
||||
throw e
|
||||
} catch (e: Throwable) {
|
||||
e.printStackTraceDebug()
|
||||
outState.value = DownloadState.Error(startId, manga, cover, e)
|
||||
outState.value = DownloadState.Error(startId, manga, cover, e, false)
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
output?.cleanup()
|
||||
File(destination, tempFileName).deleteAwait()
|
||||
coroutineContext[WakeLockNode]?.release()
|
||||
semaphore.release()
|
||||
localMangaRepository.unlockManga(manga.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun <R> runFailsafe(
|
||||
outState: MutableStateFlow<DownloadState>,
|
||||
pausingHandle: PausingHandle,
|
||||
block: suspend () -> R,
|
||||
): R {
|
||||
var countDown = MAX_FAILSAFE_ATTEMPTS
|
||||
failsafe@ while (true) {
|
||||
try {
|
||||
return block()
|
||||
} catch (e: IOException) {
|
||||
if (countDown <= 0) {
|
||||
val state = outState.value
|
||||
outState.value = DownloadState.Error(state.startId, state.manga, state.cover, e, true)
|
||||
countDown = MAX_FAILSAFE_ATTEMPTS
|
||||
pausingHandle.pause()
|
||||
pausingHandle.awaitResumed()
|
||||
outState.value = state
|
||||
} else {
|
||||
countDown--
|
||||
delay(DOWNLOAD_ERROR_DELAY)
|
||||
}
|
||||
}
|
||||
coroutineContext[WakeLockNode]?.release()
|
||||
semaphore.release()
|
||||
localMangaRepository.unlockManga(manga.id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,6 +209,7 @@ class DownloadManager(
|
||||
manga = prevValue.manga,
|
||||
cover = prevValue.cover,
|
||||
error = throwable,
|
||||
canRetry = false
|
||||
)
|
||||
}
|
||||
|
||||
@@ -225,7 +240,7 @@ class DownloadManager(
|
||||
okHttp = okHttp,
|
||||
cache = cache,
|
||||
localMangaRepository = localMangaRepository,
|
||||
settings = settings,
|
||||
settings = settings
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -108,6 +108,7 @@ sealed interface DownloadState {
|
||||
}
|
||||
}
|
||||
|
||||
@Deprecated("TODO: remove")
|
||||
class WaitingForNetwork(
|
||||
override val startId: Int,
|
||||
override val manga: Manga,
|
||||
@@ -170,6 +171,7 @@ sealed interface DownloadState {
|
||||
override val manga: Manga,
|
||||
override val cover: Drawable?,
|
||||
val error: Throwable,
|
||||
val canRetry: Boolean,
|
||||
) : DownloadState {
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
@@ -182,6 +184,7 @@ sealed interface DownloadState {
|
||||
if (manga != other.manga) return false
|
||||
if (cover != other.cover) return false
|
||||
if (error != other.error) return false
|
||||
if (canRetry != other.canRetry) return false
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -191,6 +194,7 @@ sealed interface DownloadState {
|
||||
result = 31 * result + manga.hashCode()
|
||||
result = 31 * result + (cover?.hashCode() ?: 0)
|
||||
result = 31 * result + error.hashCode()
|
||||
result = 31 * result + canRetry.hashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,16 +29,26 @@ class DownloadNotification(private val context: Context, startId: Int) {
|
||||
context.getString(android.R.string.cancel),
|
||||
PendingIntent.getBroadcast(
|
||||
context,
|
||||
startId,
|
||||
startId * 2,
|
||||
DownloadService.getCancelIntent(startId),
|
||||
PendingIntent.FLAG_CANCEL_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
|
||||
)
|
||||
)
|
||||
private val retryAction = NotificationCompat.Action(
|
||||
R.drawable.ic_restart_black,
|
||||
context.getString(R.string.try_again),
|
||||
PendingIntent.getBroadcast(
|
||||
context,
|
||||
startId * 2 + 1,
|
||||
DownloadService.getResumeIntent(startId),
|
||||
PendingIntent.FLAG_CANCEL_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
|
||||
)
|
||||
)
|
||||
private val listIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
REQUEST_LIST,
|
||||
DownloadsActivity.newIntent(context),
|
||||
PendingIntentCompat.FLAG_IMMUTABLE,
|
||||
PendingIntentCompat.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
init {
|
||||
@@ -89,10 +99,14 @@ class DownloadNotification(private val context: Context, startId: Int) {
|
||||
builder.setSmallIcon(android.R.drawable.stat_notify_error)
|
||||
builder.setSubText(context.getString(R.string.error))
|
||||
builder.setContentText(message)
|
||||
builder.setAutoCancel(true)
|
||||
builder.setOngoing(false)
|
||||
builder.setAutoCancel(!state.canRetry)
|
||||
builder.setOngoing(state.canRetry)
|
||||
builder.setCategory(NotificationCompat.CATEGORY_ERROR)
|
||||
builder.setStyle(NotificationCompat.BigTextStyle().bigText(message))
|
||||
if (state.canRetry) {
|
||||
builder.addAction(cancelAction)
|
||||
builder.addAction(retryAction)
|
||||
}
|
||||
}
|
||||
is DownloadState.PostProcessing -> {
|
||||
builder.setProgress(1, 0, true)
|
||||
|
||||
@@ -11,6 +11,7 @@ import android.widget.Toast
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.plus
|
||||
@@ -28,16 +29,16 @@ import org.koitharu.kotatsu.download.domain.WakeLockNode
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.utils.ext.connectivityManager
|
||||
import org.koitharu.kotatsu.utils.ext.throttle
|
||||
import org.koitharu.kotatsu.utils.progress.PausingProgressJob
|
||||
import org.koitharu.kotatsu.utils.progress.ProgressJob
|
||||
import org.koitharu.kotatsu.utils.progress.TimeLeftEstimator
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class DownloadService : BaseService() {
|
||||
|
||||
private lateinit var downloadManager: DownloadManager
|
||||
private lateinit var notificationSwitcher: ForegroundNotificationSwitcher
|
||||
|
||||
private val jobs = LinkedHashMap<Int, ProgressJob<DownloadState>>()
|
||||
private val jobs = LinkedHashMap<Int, PausingProgressJob<DownloadState>>()
|
||||
private val jobCount = MutableStateFlow(0)
|
||||
private val controlReceiver = ControlReceiver()
|
||||
private var binder: DownloadBinder? = null
|
||||
@@ -49,10 +50,13 @@ class DownloadService : BaseService() {
|
||||
val wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager)
|
||||
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "kotatsu:downloading")
|
||||
downloadManager = get<DownloadManager.Factory>().create(
|
||||
coroutineScope = lifecycleScope + WakeLockNode(wakeLock, TimeUnit.HOURS.toMillis(1)),
|
||||
coroutineScope = lifecycleScope + WakeLockNode(wakeLock, TimeUnit.HOURS.toMillis(1))
|
||||
)
|
||||
DownloadNotification.createChannel(this)
|
||||
registerReceiver(controlReceiver, IntentFilter(ACTION_DOWNLOAD_CANCEL))
|
||||
val intentFilter = IntentFilter()
|
||||
intentFilter.addAction(ACTION_DOWNLOAD_CANCEL)
|
||||
intentFilter.addAction(ACTION_DOWNLOAD_RESUME)
|
||||
registerReceiver(controlReceiver, intentFilter)
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
@@ -90,7 +94,7 @@ class DownloadService : BaseService() {
|
||||
startId: Int,
|
||||
manga: Manga,
|
||||
chaptersIds: LongArray?,
|
||||
): ProgressJob<DownloadState> {
|
||||
): PausingProgressJob<DownloadState> {
|
||||
val job = downloadManager.downloadManga(manga, chaptersIds, startId)
|
||||
listenJob(job)
|
||||
return job
|
||||
@@ -144,7 +148,7 @@ class DownloadService : BaseService() {
|
||||
}
|
||||
|
||||
private val DownloadState.isTerminal: Boolean
|
||||
get() = this is DownloadState.Done || this is DownloadState.Error || this is DownloadState.Cancelled
|
||||
get() = this is DownloadState.Done || this is DownloadState.Cancelled || (this is DownloadState.Error && !canRetry)
|
||||
|
||||
inner class ControlReceiver : BroadcastReceiver() {
|
||||
|
||||
@@ -155,6 +159,10 @@ class DownloadService : BaseService() {
|
||||
jobs.remove(cancelId)?.cancel()
|
||||
jobCount.value = jobs.size
|
||||
}
|
||||
ACTION_DOWNLOAD_RESUME -> {
|
||||
val cancelId = intent.getIntExtra(EXTRA_CANCEL_ID, 0)
|
||||
jobs[cancelId]?.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -173,6 +181,7 @@ class DownloadService : BaseService() {
|
||||
const val ACTION_DOWNLOAD_COMPLETE = "${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_COMPLETE"
|
||||
|
||||
private const val ACTION_DOWNLOAD_CANCEL = "${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_CANCEL"
|
||||
private const val ACTION_DOWNLOAD_RESUME = "${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_RESUME"
|
||||
|
||||
private const val EXTRA_MANGA = "manga"
|
||||
private const val EXTRA_CHAPTERS_IDS = "chapters_ids"
|
||||
@@ -219,6 +228,9 @@ class DownloadService : BaseService() {
|
||||
fun getCancelIntent(startId: Int) = Intent(ACTION_DOWNLOAD_CANCEL)
|
||||
.putExtra(EXTRA_CANCEL_ID, startId)
|
||||
|
||||
fun getResumeIntent(startId: Int) = Intent(ACTION_DOWNLOAD_RESUME)
|
||||
.putExtra(EXTRA_CANCEL_ID, startId)
|
||||
|
||||
fun getDownloadedManga(intent: Intent?): Manga? {
|
||||
if (intent?.action == ACTION_DOWNLOAD_COMPLETE) {
|
||||
return intent.getParcelableExtra<ParcelableManga>(EXTRA_MANGA)?.manga
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package org.koitharu.kotatsu.download.ui.service
|
||||
|
||||
import androidx.annotation.AnyThread
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.first
|
||||
|
||||
class PausingHandle {
|
||||
|
||||
private val paused = MutableStateFlow(false)
|
||||
|
||||
@get:AnyThread
|
||||
val isPaused: Boolean
|
||||
get() = paused.value
|
||||
|
||||
@AnyThread
|
||||
suspend fun awaitResumed() {
|
||||
paused.filter { !it }.first()
|
||||
}
|
||||
|
||||
@AnyThread
|
||||
fun pause() {
|
||||
paused.value = true
|
||||
}
|
||||
|
||||
@AnyThread
|
||||
fun resume() {
|
||||
paused.value = false
|
||||
}
|
||||
}
|
||||
@@ -6,16 +6,12 @@ import android.content.Context.ACTIVITY_SERVICE
|
||||
import android.content.SharedPreferences
|
||||
import android.content.pm.ResolveInfo
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkRequest
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.core.app.ActivityOptionsCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.coroutineScope
|
||||
import androidx.work.CoroutineWorker
|
||||
import kotlin.coroutines.resume
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.channels.trySendBlocking
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -24,7 +20,6 @@ import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
|
||||
val Context.connectivityManager: ConnectivityManager
|
||||
get() = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
@@ -32,28 +27,6 @@ val Context.connectivityManager: ConnectivityManager
|
||||
val Context.activityManager: ActivityManager?
|
||||
get() = getSystemService(ACTIVITY_SERVICE) as? ActivityManager
|
||||
|
||||
suspend fun ConnectivityManager.waitForNetwork(): Network {
|
||||
val request = NetworkRequest.Builder().build()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
// fast path
|
||||
activeNetwork?.let { return it }
|
||||
}
|
||||
return suspendCancellableCoroutine { cont ->
|
||||
val callback = object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) {
|
||||
unregisterNetworkCallback(this)
|
||||
if (cont.isActive) {
|
||||
cont.resume(network)
|
||||
}
|
||||
}
|
||||
}
|
||||
registerNetworkCallback(request, callback)
|
||||
cont.invokeOnCancellation {
|
||||
unregisterNetworkCallback(callback)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun String.toUriOrNull() = if (isEmpty()) null else Uri.parse(this)
|
||||
|
||||
suspend fun CoroutineWorker.trySetForeground(): Boolean = runCatching {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package org.koitharu.kotatsu.utils.progress
|
||||
|
||||
import androidx.annotation.AnyThread
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import org.koitharu.kotatsu.download.ui.service.PausingHandle
|
||||
|
||||
class PausingProgressJob<P>(
|
||||
job: Job,
|
||||
progress: StateFlow<P>,
|
||||
private val pausingHandle: PausingHandle,
|
||||
) : ProgressJob<P>(job, progress) {
|
||||
|
||||
@get:AnyThread
|
||||
val isPaused: Boolean
|
||||
get() = pausingHandle.isPaused
|
||||
|
||||
@AnyThread
|
||||
suspend fun awaitResumed() = pausingHandle.awaitResumed()
|
||||
|
||||
@AnyThread
|
||||
fun pause() = pausingHandle.pause()
|
||||
|
||||
@AnyThread
|
||||
fun resume() = pausingHandle.resume()
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
class ProgressJob<P>(
|
||||
open class ProgressJob<P>(
|
||||
private val job: Job,
|
||||
private val progress: StateFlow<P>,
|
||||
) : Job by job {
|
||||
|
||||
8
app/src/main/res/drawable/ic_restart_black.xml
Normal file
8
app/src/main/res/drawable/ic_restart_black.xml
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:height="24dp"
|
||||
android:width="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path android:fillColor="#000" android:pathData="M12,4C14.1,4 16.1,4.8 17.6,6.3C20.7,9.4 20.7,14.5 17.6,17.6C15.8,19.5 13.3,20.2 10.9,19.9L11.4,17.9C13.1,18.1 14.9,17.5 16.2,16.2C18.5,13.9 18.5,10.1 16.2,7.7C15.1,6.6 13.5,6 12,6V10.6L7,5.6L12,0.6V4M6.3,17.6C3.7,15 3.3,11 5.1,7.9L6.6,9.4C5.5,11.6 5.9,14.4 7.8,16.2C8.3,16.7 8.9,17.1 9.6,17.4L9,19.4C8,19 7.1,18.4 6.3,17.6Z" />
|
||||
</vector>
|
||||
Reference in New Issue
Block a user