Option to automatically download new chapters (close #425, close #602, close #955)

master
Koitharu 2 years ago
parent 98bd79f0be
commit 9b24c507c5
Signed by: Koitharu
GPG Key ID: 676DEE768C17A9D7

@ -160,6 +160,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isTrackerNsfwDisabled: Boolean val isTrackerNsfwDisabled: Boolean
get() = prefs.getBoolean(KEY_TRACKER_NO_NSFW, false) get() = prefs.getBoolean(KEY_TRACKER_NO_NSFW, false)
val trackerDownloadStrategy: TrackerDownloadStrategy
get() = prefs.getEnumValue(KEY_TRACKER_DOWNLOAD, TrackerDownloadStrategy.DISABLED)
var notificationSound: Uri var notificationSound: Uri
get() = prefs.getString(KEY_NOTIFICATIONS_SOUND, null)?.toUriOrNull() get() = prefs.getString(KEY_NOTIFICATIONS_SOUND, null)?.toUriOrNull()
?: Settings.System.DEFAULT_NOTIFICATION_URI ?: Settings.System.DEFAULT_NOTIFICATION_URI
@ -600,6 +603,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_TRACK_WARNING = "track_warning" const val KEY_TRACK_WARNING = "track_warning"
const val KEY_TRACKER_NOTIFICATIONS = "tracker_notifications" const val KEY_TRACKER_NOTIFICATIONS = "tracker_notifications"
const val KEY_TRACKER_NO_NSFW = "tracker_no_nsfw" const val KEY_TRACKER_NO_NSFW = "tracker_no_nsfw"
const val KEY_TRACKER_DOWNLOAD = "tracker_download"
const val KEY_NOTIFICATIONS_SETTINGS = "notifications_settings" const val KEY_NOTIFICATIONS_SETTINGS = "notifications_settings"
const val KEY_NOTIFICATIONS_SOUND = "notifications_sound" const val KEY_NOTIFICATIONS_SOUND = "notifications_sound"
const val KEY_NOTIFICATIONS_VIBRATE = "notifications_vibrate" const val KEY_NOTIFICATIONS_VIBRATE = "notifications_vibrate"

@ -1,11 +1,13 @@
package org.koitharu.kotatsu.core.prefs package org.koitharu.kotatsu.core.prefs
import androidx.annotation.Keep
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.annotation.StyleRes import androidx.annotation.StyleRes
import com.google.android.material.color.DynamicColors import com.google.android.material.color.DynamicColors
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.parsers.util.find import org.koitharu.kotatsu.parsers.util.find
@Keep
enum class ColorScheme( enum class ColorScheme(
@StyleRes val styleResId: Int, @StyleRes val styleResId: Int,
@StringRes val titleResId: Int, @StringRes val titleResId: Int,

@ -1,5 +1,8 @@
package org.koitharu.kotatsu.core.prefs package org.koitharu.kotatsu.core.prefs
import androidx.annotation.Keep
@Keep
enum class DownloadFormat { enum class DownloadFormat {
AUTOMATIC, AUTOMATIC,

@ -1,5 +1,8 @@
package org.koitharu.kotatsu.core.prefs package org.koitharu.kotatsu.core.prefs
import androidx.annotation.Keep
@Keep
enum class ListMode { enum class ListMode {
LIST, DETAILED_LIST, GRID; LIST, DETAILED_LIST, GRID;

@ -2,9 +2,11 @@ package org.koitharu.kotatsu.core.prefs
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import androidx.annotation.IdRes import androidx.annotation.IdRes
import androidx.annotation.Keep
import androidx.annotation.StringRes import androidx.annotation.StringRes
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
@Keep
enum class NavItem( enum class NavItem(
@IdRes val id: Int, @IdRes val id: Int,
@StringRes val title: Int, @StringRes val title: Int,

@ -1,7 +1,9 @@
package org.koitharu.kotatsu.core.prefs package org.koitharu.kotatsu.core.prefs
import android.net.ConnectivityManager import android.net.ConnectivityManager
import androidx.annotation.Keep
@Keep
enum class NetworkPolicy( enum class NetworkPolicy(
private val key: Int, private val key: Int,
) { ) {

@ -1,5 +1,8 @@
package org.koitharu.kotatsu.core.prefs package org.koitharu.kotatsu.core.prefs
import androidx.annotation.Keep
@Keep
enum class ProgressIndicatorMode { enum class ProgressIndicatorMode {
NONE, PERCENT_READ, PERCENT_LEFT, CHAPTERS_READ, CHAPTERS_LEFT; NONE, PERCENT_READ, PERCENT_LEFT, CHAPTERS_READ, CHAPTERS_LEFT;

@ -1,5 +1,8 @@
package org.koitharu.kotatsu.core.prefs package org.koitharu.kotatsu.core.prefs
import androidx.annotation.Keep
@Keep
enum class ReaderAnimation { enum class ReaderAnimation {
// Do not rename this // Do not rename this

@ -2,11 +2,13 @@ package org.koitharu.kotatsu.core.prefs
import android.content.Context import android.content.Context
import android.view.ContextThemeWrapper import android.view.ContextThemeWrapper
import androidx.annotation.Keep
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.toDrawable import androidx.core.graphics.drawable.toDrawable
import org.koitharu.kotatsu.core.util.ext.getThemeDrawable import org.koitharu.kotatsu.core.util.ext.getThemeDrawable
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
@Keep
enum class ReaderBackground { enum class ReaderBackground {
DEFAULT, LIGHT, DARK, WHITE, BLACK; DEFAULT, LIGHT, DARK, WHITE, BLACK;

@ -1,5 +1,8 @@
package org.koitharu.kotatsu.core.prefs package org.koitharu.kotatsu.core.prefs
import androidx.annotation.Keep
@Keep
enum class ReaderMode(val id: Int) { enum class ReaderMode(val id: Int) {
STANDARD(1), STANDARD(1),

@ -1,5 +1,8 @@
package org.koitharu.kotatsu.core.prefs package org.koitharu.kotatsu.core.prefs
import androidx.annotation.Keep
@Keep
enum class ScreenshotsPolicy { enum class ScreenshotsPolicy {
// Do not rename this // Do not rename this

@ -1,8 +1,10 @@
package org.koitharu.kotatsu.core.prefs package org.koitharu.kotatsu.core.prefs
import androidx.annotation.Keep
import androidx.annotation.StringRes import androidx.annotation.StringRes
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
@Keep
enum class SearchSuggestionType( enum class SearchSuggestionType(
@StringRes val titleResId: Int, @StringRes val titleResId: Int,
) { ) {

@ -0,0 +1,9 @@
package org.koitharu.kotatsu.core.prefs
import androidx.annotation.Keep
@Keep
enum class TrackerDownloadStrategy {
DISABLED, DOWNLOADED;
}

@ -166,8 +166,9 @@ abstract class ChaptersPagesViewModel(
fun download(chaptersIds: Set<Long>?) { fun download(chaptersIds: Set<Long>?) {
launchJob(Dispatchers.Default) { launchJob(Dispatchers.Default) {
downloadScheduler.schedule( downloadScheduler.schedule(
requireManga(), manga = requireManga(),
chaptersIds, chaptersIds = chaptersIds,
isSilent = false,
) )
onDownloadStarted.call(Unit) onDownloadStarted.call(Unit)
} }

@ -37,7 +37,8 @@ import org.koitharu.kotatsu.search.ui.MangaListActivity
import java.util.UUID import java.util.UUID
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
private const val CHANNEL_ID = "download" private const val CHANNEL_ID_DEFAULT = "download"
private const val CHANNEL_ID_SILENT = "download_bg"
private const val GROUP_ID = "downloads" private const val GROUP_ID = "downloads"
class DownloadNotificationFactory @AssistedInject constructor( class DownloadNotificationFactory @AssistedInject constructor(
@ -45,10 +46,11 @@ class DownloadNotificationFactory @AssistedInject constructor(
private val workManager: WorkManager, private val workManager: WorkManager,
private val coil: ImageLoader, private val coil: ImageLoader,
@Assisted private val uuid: UUID, @Assisted private val uuid: UUID,
@Assisted val isSilent: Boolean,
) { ) {
private val covers = HashMap<Manga, Drawable>() private val covers = HashMap<Manga, Drawable>()
private val builder = NotificationCompat.Builder(context, CHANNEL_ID) private val builder = NotificationCompat.Builder(context, if (isSilent) CHANNEL_ID_SILENT else CHANNEL_ID_DEFAULT)
private val mutex = Mutex() private val mutex = Mutex()
private val coverWidth = context.resources.getDimensionPixelSize( private val coverWidth = context.resources.getDimensionPixelSize(
@ -106,14 +108,18 @@ class DownloadNotificationFactory @AssistedInject constructor(
} }
init { init {
createChannel() createChannels()
builder.setOnlyAlertOnce(true) builder.setOnlyAlertOnce(true)
builder.setDefaults(0) builder.setDefaults(0)
builder.foregroundServiceBehavior = NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE builder.foregroundServiceBehavior = if (isSilent) {
NotificationCompat.FOREGROUND_SERVICE_DEFERRED
} else {
NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE
}
builder.setSilent(true) builder.setSilent(true)
builder.setGroup(GROUP_ID) builder.setGroup(GROUP_ID)
builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN) builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
builder.priority = NotificationCompat.PRIORITY_DEFAULT builder.priority = if (isSilent) NotificationCompat.PRIORITY_MIN else NotificationCompat.PRIORITY_DEFAULT
} }
suspend fun create(state: DownloadState?): Notification = mutex.withLock { suspend fun create(state: DownloadState?): Notification = mutex.withLock {
@ -283,20 +289,30 @@ class DownloadNotificationFactory @AssistedInject constructor(
}.getOrNull() }.getOrNull()
} }
private fun createChannel() { private fun createChannels() {
val manager = NotificationManagerCompat.from(context) val manager = NotificationManagerCompat.from(context)
val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_LOW) manager.createNotificationChannel(
.setName(context.getString(R.string.downloads)) NotificationChannelCompat.Builder(CHANNEL_ID_DEFAULT, NotificationManagerCompat.IMPORTANCE_LOW)
.setVibrationEnabled(false) .setName(context.getString(R.string.downloads))
.setLightsEnabled(false) .setVibrationEnabled(false)
.setSound(null, null) .setLightsEnabled(false)
.build() .setSound(null, null)
manager.createNotificationChannel(channel) .build(),
)
manager.createNotificationChannel(
NotificationChannelCompat.Builder(CHANNEL_ID_SILENT, NotificationManagerCompat.IMPORTANCE_MIN)
.setName(context.getString(R.string.downloads_background))
.setVibrationEnabled(false)
.setLightsEnabled(false)
.setSound(null, null)
.setShowBadge(false)
.build(),
)
} }
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
fun create(uuid: UUID): DownloadNotificationFactory fun create(uuid: UUID, isSilent: Boolean): DownloadNotificationFactory
} }
} }

@ -104,7 +104,10 @@ class DownloadWorker @AssistedInject constructor(
notificationFactoryFactory: DownloadNotificationFactory.Factory, notificationFactoryFactory: DownloadNotificationFactory.Factory,
) : CoroutineWorker(appContext, params) { ) : CoroutineWorker(appContext, params) {
private val notificationFactory = notificationFactoryFactory.create(params.id) private val notificationFactory = notificationFactoryFactory.create(
uuid = params.id,
isSilent = params.inputData.getBoolean(IS_SILENT, false),
)
private val notificationManager = appContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager private val notificationManager = appContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
private val slowdownDispatcher = DownloadSlowdownDispatcher(mangaRepositoryFactory, SLOWDOWN_DELAY) private val slowdownDispatcher = DownloadSlowdownDispatcher(mangaRepositoryFactory, SLOWDOWN_DELAY)
@ -120,8 +123,7 @@ class DownloadWorker @AssistedInject constructor(
setForeground(getForegroundInfo()) setForeground(getForegroundInfo())
val mangaId = inputData.getLong(MANGA_ID, 0L) val mangaId = inputData.getLong(MANGA_ID, 0L)
val manga = mangaDataRepository.findMangaById(mangaId) ?: return Result.failure() val manga = mangaDataRepository.findMangaById(mangaId) ?: return Result.failure()
lastPublishedState = DownloadState(manga, isIndeterminate = true) publishState(DownloadState(manga = manga, isIndeterminate = true).also { lastPublishedState = it })
publishState(DownloadState(manga, isIndeterminate = true))
val chaptersIds = inputData.getLongArray(CHAPTERS_IDS)?.takeUnless { it.isEmpty() } val chaptersIds = inputData.getLongArray(CHAPTERS_IDS)?.takeUnless { it.isEmpty() }
val downloadedIds = getDoneChapters(manga) val downloadedIds = getDoneChapters(manga)
return try { return try {
@ -380,7 +382,9 @@ class DownloadWorker @AssistedInject constructor(
} }
val notification = notificationFactory.create(state) val notification = notificationFactory.create(state)
if (state.isFinalState) { if (state.isFinalState) {
notificationManager.notify(id.toString(), id.hashCode(), notification) if (!notificationFactory.isSilent) {
notificationManager.notify(id.toString(), id.hashCode(), notification)
}
} else if (notificationThrottler.throttle()) { } else if (notificationThrottler.throttle()) {
notificationManager.notify(id.hashCode(), notification) notificationManager.notify(id.hashCode(), notification)
} else { } else {
@ -426,10 +430,11 @@ class DownloadWorker @AssistedInject constructor(
private val settings: AppSettings, private val settings: AppSettings,
) { ) {
suspend fun schedule(manga: Manga, chaptersIds: Collection<Long>?) { suspend fun schedule(manga: Manga, chaptersIds: Collection<Long>?, isSilent: Boolean) {
dataRepository.storeManga(manga) dataRepository.storeManga(manga)
val data = Data.Builder() val data = Data.Builder()
.putLong(MANGA_ID, manga.id) .putLong(MANGA_ID, manga.id)
.putBoolean(IS_SILENT, isSilent)
if (!chaptersIds.isNullOrEmpty()) { if (!chaptersIds.isNullOrEmpty()) {
data.putLongArray(CHAPTERS_IDS, chaptersIds.toLongArray()) data.putLongArray(CHAPTERS_IDS, chaptersIds.toLongArray())
} }
@ -549,6 +554,7 @@ class DownloadWorker @AssistedInject constructor(
const val SLOWDOWN_DELAY = 200L const val SLOWDOWN_DELAY = 200L
const val MANGA_ID = "manga_id" const val MANGA_ID = "manga_id"
const val CHAPTERS_IDS = "chapters" const val CHAPTERS_IDS = "chapters"
const val IS_SILENT = "silent"
const val TAG = "download" const val TAG = "download"
} }
} }

@ -11,13 +11,17 @@ import android.view.View
import androidx.core.text.buildSpannedString import androidx.core.text.buildSpannedString
import androidx.core.text.inSpans import androidx.core.text.inSpans
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.preference.ListPreference
import androidx.preference.MultiSelectListPreference import androidx.preference.MultiSelectListPreference
import androidx.preference.Preference import androidx.preference.Preference
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.TrackerDownloadStrategy
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.setDefaultValueCompat
import org.koitharu.kotatsu.parsers.util.names
import org.koitharu.kotatsu.settings.tracker.categories.TrackerCategoriesConfigSheet import org.koitharu.kotatsu.settings.tracker.categories.TrackerCategoriesConfigSheet
import org.koitharu.kotatsu.settings.utils.DozeHelper import org.koitharu.kotatsu.settings.utils.DozeHelper
import org.koitharu.kotatsu.settings.utils.MultiSummaryProvider import org.koitharu.kotatsu.settings.utils.MultiSummaryProvider
@ -50,6 +54,10 @@ class TrackerSettingsFragment :
} }
} }
} }
findPreference<ListPreference>(AppSettings.KEY_TRACKER_DOWNLOAD)?.run {
entryValues = TrackerDownloadStrategy.entries.names()
setDefaultValueCompat(TrackerDownloadStrategy.DISABLED.name)
}
dozeHelper.updatePreference() dozeHelper.updatePreference()
updateCategoriesEnabled() updateCategoriesEnabled()
} }

@ -25,6 +25,7 @@ import androidx.work.WorkManager
import androidx.work.WorkQuery import androidx.work.WorkQuery
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import androidx.work.await import androidx.work.await
import dagger.Lazy
import dagger.Reusable import dagger.Reusable
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
@ -47,10 +48,14 @@ import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.logs.FileLogger import org.koitharu.kotatsu.core.logs.FileLogger
import org.koitharu.kotatsu.core.logs.TrackerLogger import org.koitharu.kotatsu.core.logs.TrackerLogger
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.TrackerDownloadStrategy
import org.koitharu.kotatsu.core.util.ext.awaitUniqueWorkInfoByName import org.koitharu.kotatsu.core.util.ext.awaitUniqueWorkInfoByName
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
import org.koitharu.kotatsu.core.util.ext.onEachIndexed import org.koitharu.kotatsu.core.util.ext.onEachIndexed
import org.koitharu.kotatsu.core.util.ext.trySetForeground import org.koitharu.kotatsu.core.util.ext.trySetForeground
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.parsers.util.toIntUp import org.koitharu.kotatsu.parsers.util.toIntUp
import org.koitharu.kotatsu.settings.SettingsActivity import org.koitharu.kotatsu.settings.SettingsActivity
@ -76,6 +81,8 @@ class TrackWorker @AssistedInject constructor(
private val checkNewChaptersUseCase: CheckNewChaptersUseCase, private val checkNewChaptersUseCase: CheckNewChaptersUseCase,
private val workManager: WorkManager, private val workManager: WorkManager,
@TrackerLogger private val logger: FileLogger, @TrackerLogger private val logger: FileLogger,
private val localRepositoryLazy: Lazy<LocalMangaRepository>,
private val downloadSchedulerLazy: Lazy<DownloadWorker.Scheduler>,
) : CoroutineWorker(context, workerParams) { ) : CoroutineWorker(context, workerParams) {
private val notificationManager by lazy { NotificationManagerCompat.from(applicationContext) } private val notificationManager by lazy { NotificationManagerCompat.from(applicationContext) }
@ -144,12 +151,16 @@ class TrackWorker @AssistedInject constructor(
if (applicationContext.checkNotificationPermission(WORKER_CHANNEL_ID)) { if (applicationContext.checkNotificationPermission(WORKER_CHANNEL_ID)) {
notificationManager.notify(WORKER_NOTIFICATION_ID, createWorkerNotification(tracks.size, index + 1)) notificationManager.notify(WORKER_NOTIFICATION_ID, createWorkerNotification(tracks.size, index + 1))
} }
if (it is MangaUpdates.Failure) { when (it) {
val e = it.error is MangaUpdates.Failure -> {
logger.log("checkUpdatesAsync", e) val e = it.error
if (e is CloudFlareProtectedException) { logger.log("checkUpdatesAsync", e)
CaptchaNotifier(applicationContext).notify(e) if (e is CloudFlareProtectedException) {
CaptchaNotifier(applicationContext).notify(e)
}
} }
is MangaUpdates.Success -> processDownload(it)
} }
}.mapNotNull { }.mapNotNull {
when (it) { when (it) {
@ -237,6 +248,25 @@ class TrackWorker @AssistedInject constructor(
} }
}.build() }.build()
private suspend fun processDownload(mangaUpdates: MangaUpdates.Success) {
if (!mangaUpdates.isValid || mangaUpdates.newChapters.isEmpty()) {
return
}
when (settings.trackerDownloadStrategy) {
TrackerDownloadStrategy.DISABLED -> Unit
TrackerDownloadStrategy.DOWNLOADED -> {
val localManga = localRepositoryLazy.get().findSavedManga(mangaUpdates.manga)
if (localManga != null) {
downloadSchedulerLazy.get().schedule(
manga = mangaUpdates.manga,
chaptersIds = mangaUpdates.newChapters.mapToSet { it.id },
isSilent = true,
)
}
}
}
}
@Reusable @Reusable
class Scheduler @Inject constructor( class Scheduler @Inject constructor(
private val workManager: WorkManager, private val workManager: WorkManager,

@ -109,4 +109,8 @@
<item>@string/chapters_read</item> <item>@string/chapters_read</item>
<item>@string/chapters_left</item> <item>@string/chapters_left</item>
</string-array> </string-array>
<string-array name="tracker_download_strategies" translatable="false">
<item>@string/never</item>
<item>@string/manga_with_downloaded_chapters</item>
</string-array>
</resources> </resources>

@ -698,4 +698,7 @@
<string name="scrobbler_auth_intro">Sign in to set up integration with %s. This will allow you to track your manga reading progress and status</string> <string name="scrobbler_auth_intro">Sign in to set up integration with %s. This will allow you to track your manga reading progress and status</string>
<string name="unstable_feature">Unstable feature</string> <string name="unstable_feature">Unstable feature</string>
<string name="unstable_feature_summary">This function is experimental. Please make sure you have a backup to avoid data loss</string> <string name="unstable_feature_summary">This function is experimental. Please make sure you have a backup to avoid data loss</string>
<string name="downloads_background">Background downloads</string>
<string name="download_new_chapters">Download new chapters</string>
<string name="manga_with_downloaded_chapters">Manga with downloaded chapters</string>
</resources> </resources>

@ -52,6 +52,13 @@
android:summary="@string/disable_nsfw_notifications_summary" android:summary="@string/disable_nsfw_notifications_summary"
android:title="@string/disable_nsfw_notifications" /> android:title="@string/disable_nsfw_notifications" />
<ListPreference
android:dependency="tracker_enabled"
android:entries="@array/tracker_download_strategies"
android:key="tracker_download"
android:title="@string/download_new_chapters"
app:useSimpleSummaryProvider="true" />
<Preference <Preference
android:dependency="tracker_enabled" android:dependency="tracker_enabled"
android:key="ignore_dose" android:key="ignore_dose"

Loading…
Cancel
Save