Schedule workers only on demand
This commit is contained in:
@@ -104,6 +104,7 @@ dependencies {
|
|||||||
//noinspection LifecycleAnnotationProcessorWithJava8
|
//noinspection LifecycleAnnotationProcessorWithJava8
|
||||||
kapt 'androidx.lifecycle:lifecycle-compiler:2.6.1'
|
kapt 'androidx.lifecycle:lifecycle-compiler:2.6.1'
|
||||||
|
|
||||||
|
// TODO https://issuetracker.google.com/issues/254846063
|
||||||
implementation 'androidx.work:work-runtime-ktx:2.8.1'
|
implementation 'androidx.work:work-runtime-ktx:2.8.1'
|
||||||
//noinspection GradleDependency
|
//noinspection GradleDependency
|
||||||
implementation('com.google.guava:guava:32.0.0-android') {
|
implementation('com.google.guava:guava:32.0.0-android') {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import org.koitharu.kotatsu.local.data.LocalMangaRepository
|
|||||||
import org.koitharu.kotatsu.local.data.PagesCache
|
import org.koitharu.kotatsu.local.data.PagesCache
|
||||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||||
import org.koitharu.kotatsu.reader.domain.PageLoader
|
import org.koitharu.kotatsu.reader.domain.PageLoader
|
||||||
|
import org.koitharu.kotatsu.settings.work.WorkScheduleManager
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltAndroidApp
|
@HiltAndroidApp
|
||||||
@@ -51,6 +52,9 @@ class KotatsuApp : Application(), Configuration.Provider {
|
|||||||
@Inject
|
@Inject
|
||||||
lateinit var appValidator: AppValidator
|
lateinit var appValidator: AppValidator
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var workScheduleManager: WorkScheduleManager
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
ACRA.errorReporter.putCustomData("isOriginalApp", appValidator.isOriginalApp.toString())
|
ACRA.errorReporter.putCustomData("isOriginalApp", appValidator.isOriginalApp.toString())
|
||||||
@@ -63,6 +67,7 @@ class KotatsuApp : Application(), Configuration.Provider {
|
|||||||
processLifecycleScope.launch(Dispatchers.Default) {
|
processLifecycleScope.launch(Dispatchers.Default) {
|
||||||
setupDatabaseObservers()
|
setupDatabaseObservers()
|
||||||
}
|
}
|
||||||
|
workScheduleManager.init()
|
||||||
WorkServiceStopHelper(applicationContext).setup()
|
WorkServiceStopHelper(applicationContext).setup()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,10 @@ class WorkManagerHelper(
|
|||||||
return workManagerImpl.getWorkInfoById(id).await()
|
return workManagerImpl.getWorkInfoById(id).await()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun getUniqueWorkInfoByName(name: String): List<WorkInfo> {
|
||||||
|
return workManagerImpl.getWorkInfosForUniqueWork(name).await().orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun updateWork(request: WorkRequest): WorkManager.UpdateResult {
|
suspend fun updateWork(request: WorkRequest): WorkManager.UpdateResult {
|
||||||
return workManagerImpl.updateWork(request).await()
|
return workManagerImpl.updateWork(request).await()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import androidx.work.ExistingWorkPolicy
|
|||||||
import androidx.work.OneTimeWorkRequestBuilder
|
import androidx.work.OneTimeWorkRequestBuilder
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
import androidx.work.WorkerParameters
|
import androidx.work.WorkerParameters
|
||||||
|
import androidx.work.await
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
import org.koitharu.kotatsu.local.data.LocalMangaRepository
|
import org.koitharu.kotatsu.local.data.LocalMangaRepository
|
||||||
@@ -33,7 +34,7 @@ class LocalStorageCleanupWorker @AssistedInject constructor(
|
|||||||
|
|
||||||
private const val TAG = "cleanup"
|
private const val TAG = "cleanup"
|
||||||
|
|
||||||
fun enqueue(context: Context) {
|
suspend fun enqueue(context: Context) {
|
||||||
val constraints = Constraints.Builder()
|
val constraints = Constraints.Builder()
|
||||||
.setRequiresBatteryNotLow(true)
|
.setRequiresBatteryNotLow(true)
|
||||||
.build()
|
.build()
|
||||||
@@ -42,7 +43,7 @@ class LocalStorageCleanupWorker @AssistedInject constructor(
|
|||||||
.addTag(TAG)
|
.addTag(TAG)
|
||||||
.setBackoffCriteria(BackoffPolicy.LINEAR, 1, TimeUnit.MINUTES)
|
.setBackoffCriteria(BackoffPolicy.LINEAR, 1, TimeUnit.MINUTES)
|
||||||
.build()
|
.build()
|
||||||
WorkManager.getInstance(context).enqueueUniqueWork(TAG, ExistingWorkPolicy.KEEP, request)
|
WorkManager.getInstance(context).enqueueUniqueWork(TAG, ExistingWorkPolicy.KEEP, request).await()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,8 +69,6 @@ import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionViewModel
|
|||||||
import org.koitharu.kotatsu.settings.newsources.NewSourcesDialogFragment
|
import org.koitharu.kotatsu.settings.newsources.NewSourcesDialogFragment
|
||||||
import org.koitharu.kotatsu.settings.onboard.OnboardDialogFragment
|
import org.koitharu.kotatsu.settings.onboard.OnboardDialogFragment
|
||||||
import org.koitharu.kotatsu.shelf.ui.ShelfFragment
|
import org.koitharu.kotatsu.shelf.ui.ShelfFragment
|
||||||
import org.koitharu.kotatsu.suggestions.ui.SuggestionsWorker
|
|
||||||
import org.koitharu.kotatsu.tracker.work.TrackWorker
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import com.google.android.material.R as materialR
|
import com.google.android.material.R as materialR
|
||||||
|
|
||||||
@@ -321,8 +319,6 @@ class MainActivity :
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
withContext(Dispatchers.Default) {
|
withContext(Dispatchers.Default) {
|
||||||
TrackWorker.setup(applicationContext)
|
|
||||||
SuggestionsWorker.setup(applicationContext)
|
|
||||||
LocalStorageCleanupWorker.enqueue(applicationContext)
|
LocalStorageCleanupWorker.enqueue(applicationContext)
|
||||||
}
|
}
|
||||||
withResumed {
|
withResumed {
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package org.koitharu.kotatsu.settings.work
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
|
||||||
|
interface PeriodicWorkScheduler {
|
||||||
|
|
||||||
|
suspend fun schedule(context: Context)
|
||||||
|
|
||||||
|
suspend fun unschedule(context: Context)
|
||||||
|
|
||||||
|
suspend fun isScheduled(context: Context): Boolean
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package org.koitharu.kotatsu.settings.work
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
|
||||||
|
import org.koitharu.kotatsu.suggestions.ui.SuggestionsWorker
|
||||||
|
import org.koitharu.kotatsu.tracker.work.TrackWorker
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class WorkScheduleManager @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
private val settings: AppSettings,
|
||||||
|
) : SharedPreferences.OnSharedPreferenceChangeListener {
|
||||||
|
|
||||||
|
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
|
||||||
|
when (key) {
|
||||||
|
AppSettings.KEY_TRACKER_ENABLED -> updateWorker(TrackWorker, settings.isTrackerEnabled)
|
||||||
|
AppSettings.KEY_SUGGESTIONS -> updateWorker(SuggestionsWorker, settings.isSuggestionsEnabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun init() {
|
||||||
|
settings.subscribe(this)
|
||||||
|
processLifecycleScope.launch(Dispatchers.Default) {
|
||||||
|
updateWorkerImpl(TrackWorker, settings.isTrackerEnabled)
|
||||||
|
updateWorkerImpl(SuggestionsWorker, settings.isSuggestionsEnabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateWorker(scheduler: PeriodicWorkScheduler, isEnabled: Boolean) {
|
||||||
|
processLifecycleScope.launch(Dispatchers.Default) {
|
||||||
|
updateWorkerImpl(scheduler, isEnabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun updateWorkerImpl(scheduler: PeriodicWorkScheduler, isEnabled: Boolean) {
|
||||||
|
if (scheduler.isScheduled(context) != isEnabled) {
|
||||||
|
if (isEnabled) {
|
||||||
|
scheduler.schedule(context)
|
||||||
|
} else {
|
||||||
|
scheduler.unschedule(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ import androidx.work.OutOfQuotaPolicy
|
|||||||
import androidx.work.PeriodicWorkRequestBuilder
|
import androidx.work.PeriodicWorkRequestBuilder
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
import androidx.work.WorkerParameters
|
import androidx.work.WorkerParameters
|
||||||
|
import androidx.work.await
|
||||||
import androidx.work.workDataOf
|
import androidx.work.workDataOf
|
||||||
import coil.ImageLoader
|
import coil.ImageLoader
|
||||||
import coil.request.ImageRequest
|
import coil.request.ImageRequest
|
||||||
@@ -38,6 +39,7 @@ import org.koitharu.kotatsu.R
|
|||||||
import org.koitharu.kotatsu.core.model.distinctById
|
import org.koitharu.kotatsu.core.model.distinctById
|
||||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
|
import org.koitharu.kotatsu.core.util.WorkManagerHelper
|
||||||
import org.koitharu.kotatsu.core.util.ext.almostEquals
|
import org.koitharu.kotatsu.core.util.ext.almostEquals
|
||||||
import org.koitharu.kotatsu.core.util.ext.asArrayList
|
import org.koitharu.kotatsu.core.util.ext.asArrayList
|
||||||
import org.koitharu.kotatsu.core.util.ext.flatten
|
import org.koitharu.kotatsu.core.util.ext.flatten
|
||||||
@@ -55,6 +57,7 @@ import org.koitharu.kotatsu.parsers.model.MangaTag
|
|||||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder
|
import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder
|
||||||
|
import org.koitharu.kotatsu.settings.work.PeriodicWorkScheduler
|
||||||
import org.koitharu.kotatsu.suggestions.domain.MangaSuggestion
|
import org.koitharu.kotatsu.suggestions.domain.MangaSuggestion
|
||||||
import org.koitharu.kotatsu.suggestions.domain.SuggestionRepository
|
import org.koitharu.kotatsu.suggestions.domain.SuggestionRepository
|
||||||
import org.koitharu.kotatsu.suggestions.domain.TagsBlacklist
|
import org.koitharu.kotatsu.suggestions.domain.TagsBlacklist
|
||||||
@@ -75,11 +78,11 @@ class SuggestionsWorker @AssistedInject constructor(
|
|||||||
) : CoroutineWorker(appContext, params) {
|
) : CoroutineWorker(appContext, params) {
|
||||||
|
|
||||||
override suspend fun doWork(): Result {
|
override suspend fun doWork(): Result {
|
||||||
|
trySetForeground()
|
||||||
if (!appSettings.isSuggestionsEnabled) {
|
if (!appSettings.isSuggestionsEnabled) {
|
||||||
suggestionRepository.clear()
|
suggestionRepository.clear()
|
||||||
return Result.success()
|
return Result.success()
|
||||||
}
|
}
|
||||||
trySetForeground()
|
|
||||||
val count = doWorkImpl()
|
val count = doWorkImpl()
|
||||||
val outputData = workDataOf(DATA_COUNT to count)
|
val outputData = workDataOf(DATA_COUNT to count)
|
||||||
return Result.success(outputData)
|
return Result.success(outputData)
|
||||||
@@ -303,7 +306,7 @@ class SuggestionsWorker @AssistedInject constructor(
|
|||||||
return -1
|
return -1
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object : PeriodicWorkScheduler {
|
||||||
|
|
||||||
private const val TAG = "suggestions"
|
private const val TAG = "suggestions"
|
||||||
private const val TAG_ONESHOT = "suggestions_oneshot"
|
private const val TAG_ONESHOT = "suggestions_oneshot"
|
||||||
@@ -324,7 +327,7 @@ class SuggestionsWorker @AssistedInject constructor(
|
|||||||
SortOrder.RATING,
|
SortOrder.RATING,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun setup(context: Context) {
|
override suspend fun schedule(context: Context) {
|
||||||
val constraints = Constraints.Builder()
|
val constraints = Constraints.Builder()
|
||||||
.setRequiredNetworkType(NetworkType.UNMETERED)
|
.setRequiredNetworkType(NetworkType.UNMETERED)
|
||||||
.setRequiresBatteryNotLow(true)
|
.setRequiresBatteryNotLow(true)
|
||||||
@@ -336,6 +339,19 @@ class SuggestionsWorker @AssistedInject constructor(
|
|||||||
.build()
|
.build()
|
||||||
WorkManager.getInstance(context)
|
WorkManager.getInstance(context)
|
||||||
.enqueueUniquePeriodicWork(TAG, ExistingPeriodicWorkPolicy.KEEP, request)
|
.enqueueUniquePeriodicWork(TAG, ExistingPeriodicWorkPolicy.KEEP, request)
|
||||||
|
.await()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun unschedule(context: Context) {
|
||||||
|
WorkManager.getInstance(context)
|
||||||
|
.cancelUniqueWork(TAG)
|
||||||
|
.await()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun isScheduled(context: Context): Boolean {
|
||||||
|
return WorkManagerHelper(WorkManager.getInstance(context))
|
||||||
|
.getUniqueWorkInfoByName(TAG)
|
||||||
|
.any { !it.state.isFinished }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun startNow(context: Context) {
|
fun startNow(context: Context) {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import androidx.work.WorkInfo
|
|||||||
import androidx.work.WorkManager
|
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 coil.ImageLoader
|
import coil.ImageLoader
|
||||||
import coil.request.ImageRequest
|
import coil.request.ImageRequest
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
@@ -42,12 +43,14 @@ import org.koitharu.kotatsu.R
|
|||||||
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.util.WorkManagerHelper
|
||||||
import org.koitharu.kotatsu.core.util.ext.toBitmapOrNull
|
import org.koitharu.kotatsu.core.util.ext.toBitmapOrNull
|
||||||
import org.koitharu.kotatsu.core.util.ext.trySetForeground
|
import org.koitharu.kotatsu.core.util.ext.trySetForeground
|
||||||
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
|
import org.koitharu.kotatsu.settings.work.PeriodicWorkScheduler
|
||||||
import org.koitharu.kotatsu.tracker.domain.Tracker
|
import org.koitharu.kotatsu.tracker.domain.Tracker
|
||||||
import org.koitharu.kotatsu.tracker.domain.model.MangaUpdates
|
import org.koitharu.kotatsu.tracker.domain.model.MangaUpdates
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
@@ -67,6 +70,7 @@ class TrackWorker @AssistedInject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun doWork(): Result {
|
override suspend fun doWork(): Result {
|
||||||
|
trySetForeground()
|
||||||
logger.log("doWork()")
|
logger.log("doWork()")
|
||||||
try {
|
try {
|
||||||
return doWorkImpl()
|
return doWorkImpl()
|
||||||
@@ -85,7 +89,6 @@ class TrackWorker @AssistedInject constructor(
|
|||||||
if (!settings.isTrackerEnabled) {
|
if (!settings.isTrackerEnabled) {
|
||||||
return Result.success(workDataOf(0, 0))
|
return Result.success(workDataOf(0, 0))
|
||||||
}
|
}
|
||||||
trySetForeground()
|
|
||||||
val tracks = tracker.getAllTracks()
|
val tracks = tracker.getAllTracks()
|
||||||
logger.log("Total ${tracks.size} tracks")
|
logger.log("Total ${tracks.size} tracks")
|
||||||
if (tracks.isEmpty()) {
|
if (tracks.isEmpty()) {
|
||||||
@@ -234,7 +237,7 @@ class TrackWorker @AssistedInject constructor(
|
|||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object : PeriodicWorkScheduler {
|
||||||
|
|
||||||
private const val WORKER_CHANNEL_ID = "track_worker"
|
private const val WORKER_CHANNEL_ID = "track_worker"
|
||||||
private const val WORKER_NOTIFICATION_ID = 35
|
private const val WORKER_NOTIFICATION_ID = 35
|
||||||
@@ -244,14 +247,28 @@ class TrackWorker @AssistedInject constructor(
|
|||||||
private const val DATA_KEY_SUCCESS = "success"
|
private const val DATA_KEY_SUCCESS = "success"
|
||||||
private const val DATA_KEY_FAILED = "failed"
|
private const val DATA_KEY_FAILED = "failed"
|
||||||
|
|
||||||
fun setup(context: Context) {
|
override suspend fun schedule(context: Context) {
|
||||||
val constraints = Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()
|
val constraints = Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()
|
||||||
val request = PeriodicWorkRequestBuilder<TrackWorker>(4, TimeUnit.HOURS)
|
val request = PeriodicWorkRequestBuilder<TrackWorker>(4, TimeUnit.HOURS)
|
||||||
.setConstraints(constraints)
|
.setConstraints(constraints)
|
||||||
.addTag(TAG)
|
.addTag(TAG)
|
||||||
.setBackoffCriteria(BackoffPolicy.LINEAR, 30, TimeUnit.MINUTES)
|
.setBackoffCriteria(BackoffPolicy.LINEAR, 30, TimeUnit.MINUTES)
|
||||||
.build()
|
.build()
|
||||||
WorkManager.getInstance(context).enqueueUniquePeriodicWork(TAG, ExistingPeriodicWorkPolicy.KEEP, request)
|
WorkManager.getInstance(context)
|
||||||
|
.enqueueUniquePeriodicWork(TAG, ExistingPeriodicWorkPolicy.KEEP, request)
|
||||||
|
.await()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun unschedule(context: Context) {
|
||||||
|
WorkManager.getInstance(context)
|
||||||
|
.cancelUniqueWork(TAG)
|
||||||
|
.await()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun isScheduled(context: Context): Boolean {
|
||||||
|
return WorkManagerHelper(WorkManager.getInstance(context))
|
||||||
|
.getUniqueWorkInfoByName(TAG)
|
||||||
|
.any { !it.state.isFinished }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun startNow(context: Context) {
|
fun startNow(context: Context) {
|
||||||
|
|||||||
@@ -56,7 +56,7 @@
|
|||||||
app:layout_constraintEnd_toEndOf="parent"
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
app:layout_constraintTop_toBottomOf="@id/barrier_top"
|
app:layout_constraintTop_toBottomOf="@id/barrier_top"
|
||||||
app:trackColor="?colorPrimaryContainer"
|
app:trackColor="?android:colorBackground"
|
||||||
tools:progress="25" />
|
tools:progress="25" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
|
|||||||
Reference in New Issue
Block a user