Include reader tap settings into backups
This commit is contained in:
@@ -3,7 +3,6 @@ package org.koitharu.kotatsu.alternatives.domain
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.channelFlow
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
@@ -40,13 +39,16 @@ class AlternativesUseCase @Inject constructor(
|
||||
searchHelper(manga.title, SearchKind.TITLE)?.manga
|
||||
}
|
||||
}.getOrNull()
|
||||
list?.forEach { send(it) }
|
||||
list?.forEach {
|
||||
launch {
|
||||
val details = runCatchingCancellable {
|
||||
mangaRepositoryFactory.create(it.source).getDetails(it)
|
||||
}.getOrDefault(it)
|
||||
send(details)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}.map {
|
||||
runCatchingCancellable {
|
||||
mangaRepositoryFactory.create(it.source).getDetails(it)
|
||||
}.getOrDefault(it)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ class BackupEntry(
|
||||
CATEGORIES("categories"),
|
||||
FAVOURITES("favourites"),
|
||||
SETTINGS("settings"),
|
||||
SETTINGS_READER_GRID("reader_grid"),
|
||||
BOOKMARKS("bookmarks"),
|
||||
SOURCES("sources"),
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import org.koitharu.kotatsu.parsers.util.json.asTypedList
|
||||
import org.koitharu.kotatsu.parsers.util.json.getLongOrDefault
|
||||
import org.koitharu.kotatsu.parsers.util.json.mapJSON
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.reader.data.TapGridSettings
|
||||
import java.util.Date
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -20,6 +21,7 @@ private const val PAGE_SIZE = 10
|
||||
class BackupRepository @Inject constructor(
|
||||
private val db: MangaDatabase,
|
||||
private val settings: AppSettings,
|
||||
private val tapGridSettings: TapGridSettings,
|
||||
) {
|
||||
|
||||
suspend fun dumpHistory(): BackupEntry {
|
||||
@@ -105,6 +107,14 @@ class BackupRepository @Inject constructor(
|
||||
return entry
|
||||
}
|
||||
|
||||
fun dumpReaderGridSettings(): BackupEntry {
|
||||
val entry = BackupEntry(BackupEntry.Name.SETTINGS_READER_GRID, JSONArray())
|
||||
val settingsDump = tapGridSettings.getAllValues()
|
||||
val json = JsonSerializer(settingsDump).toJson()
|
||||
entry.data.put(json)
|
||||
return entry
|
||||
}
|
||||
|
||||
suspend fun dumpSources(): BackupEntry {
|
||||
val entry = BackupEntry(BackupEntry.Name.SOURCES, JSONArray())
|
||||
val all = db.getSourcesDao().findAll()
|
||||
@@ -229,4 +239,14 @@ class BackupRepository @Inject constructor(
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
fun restoreReaderGridSettings(entry: BackupEntry): CompositeResult {
|
||||
val result = CompositeResult()
|
||||
for (item in entry.data.asTypedList<JSONObject>()) {
|
||||
result += runCatchingCancellable {
|
||||
tapGridSettings.upsertAll(JsonDeserializer(item).toMap())
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,13 +15,13 @@ import androidx.core.os.LocaleListCompat
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import androidx.preference.PreferenceManager
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import org.json.JSONArray
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.ZoomMode
|
||||
import org.koitharu.kotatsu.core.network.DoHProvider
|
||||
import org.koitharu.kotatsu.core.util.ext.connectivityManager
|
||||
import org.koitharu.kotatsu.core.util.ext.getEnumValue
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.putAll
|
||||
import org.koitharu.kotatsu.core.util.ext.putEnumValue
|
||||
import org.koitharu.kotatsu.core.util.ext.takeIfReadable
|
||||
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
|
||||
@@ -569,20 +569,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
|
||||
fun getAllValues(): Map<String, *> = prefs.all
|
||||
|
||||
fun upsertAll(m: Map<String, *>) {
|
||||
prefs.edit {
|
||||
m.forEach { e ->
|
||||
when (val v = e.value) {
|
||||
is Boolean -> putBoolean(e.key, v)
|
||||
is Int -> putInt(e.key, v)
|
||||
is Long -> putLong(e.key, v)
|
||||
is Float -> putFloat(e.key, v)
|
||||
is String -> putString(e.key, v)
|
||||
is JSONArray -> putStringSet(e.key, v.toStringSet())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fun upsertAll(m: Map<String, *>) = prefs.edit { putAll(m) }
|
||||
|
||||
private fun isBackgroundNetworkRestricted(): Boolean {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
@@ -592,15 +579,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun JSONArray.toStringSet(): Set<String> {
|
||||
val len = length()
|
||||
val result = ArraySet<String>(len)
|
||||
for (i in 0 until len) {
|
||||
result.add(getString(i))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
const val TRACK_HISTORY = "history"
|
||||
|
||||
@@ -13,19 +13,16 @@ import android.content.Context.POWER_SERVICE
|
||||
import android.content.ContextWrapper
|
||||
import android.content.Intent
|
||||
import android.content.OperationApplicationException
|
||||
import android.content.SharedPreferences
|
||||
import android.content.SyncResult
|
||||
import android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
import android.content.pm.ResolveInfo
|
||||
import android.database.SQLException
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import android.net.ConnectivityManager
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import android.provider.Settings
|
||||
import android.view.ViewPropertyAnimator
|
||||
import android.view.Window
|
||||
import android.webkit.CookieManager
|
||||
import android.webkit.WebView
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
@@ -37,7 +34,6 @@ import androidx.appcompat.app.AppCompatDialog
|
||||
import androidx.core.app.ActivityOptionsCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.ColorUtils
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.fragment.app.Fragment
|
||||
@@ -46,15 +42,8 @@ import androidx.lifecycle.coroutineScope
|
||||
import androidx.webkit.WebViewCompat
|
||||
import androidx.webkit.WebViewFeature
|
||||
import androidx.work.CoroutineWorker
|
||||
import com.google.android.material.elevation.ElevationOverlayProvider
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.channels.trySendBlocking
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import okio.IOException
|
||||
@@ -68,7 +57,6 @@ import org.xmlpull.v1.XmlPullParser
|
||||
import org.xmlpull.v1.XmlPullParserException
|
||||
import java.io.File
|
||||
import kotlin.math.roundToLong
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
val Context.activityManager: ActivityManager?
|
||||
get() = getSystemService(ACTIVITY_SERVICE) as? ActivityManager
|
||||
@@ -101,25 +89,6 @@ fun <I> ActivityResultLauncher<I>.tryLaunch(
|
||||
e.printStackTraceDebug()
|
||||
}.isSuccess
|
||||
|
||||
fun SharedPreferences.observe(): Flow<String?> = callbackFlow {
|
||||
val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
|
||||
trySendBlocking(key)
|
||||
}
|
||||
registerOnSharedPreferenceChangeListener(listener)
|
||||
awaitClose {
|
||||
unregisterOnSharedPreferenceChangeListener(listener)
|
||||
}
|
||||
}
|
||||
|
||||
fun <T> SharedPreferences.observe(key: String, valueProducer: suspend () -> T): Flow<T> = flow {
|
||||
emit(valueProducer())
|
||||
observe().collect { upstreamKey ->
|
||||
if (upstreamKey == key) {
|
||||
emit(valueProducer())
|
||||
}
|
||||
}
|
||||
}.distinctUntilChanged()
|
||||
|
||||
fun Lifecycle.postDelayed(delay: Long, runnable: Runnable) {
|
||||
coroutineScope.launch {
|
||||
delay(delay)
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
package org.koitharu.kotatsu.core.util.ext
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import androidx.collection.ArraySet
|
||||
import androidx.preference.ListPreference
|
||||
import androidx.preference.MultiSelectListPreference
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.channels.trySendBlocking
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import org.json.JSONArray
|
||||
|
||||
fun ListPreference.setDefaultValueCompat(defaultValue: String) {
|
||||
if (value == null) {
|
||||
@@ -28,3 +36,44 @@ fun <E : Enum<E>> SharedPreferences.getEnumValue(key: String, defaultValue: E):
|
||||
fun <E : Enum<E>> SharedPreferences.Editor.putEnumValue(key: String, value: E?) {
|
||||
putString(key, value?.name)
|
||||
}
|
||||
|
||||
fun SharedPreferences.observe(): Flow<String?> = callbackFlow {
|
||||
val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
|
||||
trySendBlocking(key)
|
||||
}
|
||||
registerOnSharedPreferenceChangeListener(listener)
|
||||
awaitClose {
|
||||
unregisterOnSharedPreferenceChangeListener(listener)
|
||||
}
|
||||
}
|
||||
|
||||
fun <T> SharedPreferences.observe(key: String, valueProducer: suspend () -> T): Flow<T> = flow {
|
||||
emit(valueProducer())
|
||||
observe().collect { upstreamKey ->
|
||||
if (upstreamKey == key) {
|
||||
emit(valueProducer())
|
||||
}
|
||||
}
|
||||
}.distinctUntilChanged()
|
||||
|
||||
fun SharedPreferences.Editor.putAll(values: Map<String, *>) {
|
||||
values.forEach { e ->
|
||||
when (val v = e.value) {
|
||||
is Boolean -> putBoolean(e.key, v)
|
||||
is Int -> putInt(e.key, v)
|
||||
is Long -> putLong(e.key, v)
|
||||
is Float -> putFloat(e.key, v)
|
||||
is String -> putString(e.key, v)
|
||||
is JSONArray -> putStringSet(e.key, v.toStringSet())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun JSONArray.toStringSet(): Set<String> {
|
||||
val len = length()
|
||||
val result = ArraySet<String>(len)
|
||||
for (i in 0 until len) {
|
||||
result.add(getString(i))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -14,6 +14,9 @@ data class Progress(
|
||||
val isFull: Boolean
|
||||
get() = progress == total
|
||||
|
||||
val isIndeterminate: Boolean
|
||||
get() = total < 0
|
||||
|
||||
override fun compareTo(other: Progress): Int = if (total == other.total) {
|
||||
progress.compareTo(other.progress)
|
||||
} else {
|
||||
@@ -44,4 +47,9 @@ data class Progress(
|
||||
)
|
||||
|
||||
fun percentSting() = (percent * 100f).toInt().toString()
|
||||
|
||||
companion object {
|
||||
|
||||
val INDETERMINATE = Progress(0, -1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,16 +3,19 @@ package org.koitharu.kotatsu.reader.data
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.core.content.edit
|
||||
import dagger.Reusable
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import org.koitharu.kotatsu.core.util.ext.getEnumValue
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.putAll
|
||||
import org.koitharu.kotatsu.core.util.ext.putEnumValue
|
||||
import org.koitharu.kotatsu.reader.domain.TapGridArea
|
||||
import org.koitharu.kotatsu.reader.ui.tapgrid.TapAction
|
||||
import javax.inject.Inject
|
||||
|
||||
@Reusable
|
||||
class TapGridSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
|
||||
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
@@ -43,6 +46,10 @@ class TapGridSettings @Inject constructor(@ApplicationContext context: Context)
|
||||
|
||||
fun observe() = prefs.observe().flowOn(Dispatchers.IO)
|
||||
|
||||
fun getAllValues(): Map<String, *> = prefs.all
|
||||
|
||||
fun upsertAll(m: Map<String, *>) = prefs.edit { putAll(m) }
|
||||
|
||||
private fun initPrefs(withDefaultValues: Boolean) {
|
||||
prefs.edit {
|
||||
clear()
|
||||
|
||||
@@ -15,6 +15,7 @@ import org.koitharu.kotatsu.core.backup.BackupZipOutput
|
||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||
import org.koitharu.kotatsu.core.exceptions.BadBackupFormatException
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.reader.data.TapGridSettings
|
||||
import java.io.File
|
||||
import java.io.FileDescriptor
|
||||
import java.io.FileInputStream
|
||||
@@ -38,7 +39,14 @@ class AppBackupAgent : BackupAgent() {
|
||||
override fun onFullBackup(data: FullBackupDataOutput) {
|
||||
super.onFullBackup(data)
|
||||
val file =
|
||||
createBackupFile(this, BackupRepository(MangaDatabase(applicationContext), AppSettings(applicationContext)))
|
||||
createBackupFile(
|
||||
this,
|
||||
BackupRepository(
|
||||
MangaDatabase(context = applicationContext),
|
||||
AppSettings(applicationContext),
|
||||
TapGridSettings(applicationContext),
|
||||
),
|
||||
)
|
||||
try {
|
||||
fullBackupFile(file, data)
|
||||
} finally {
|
||||
@@ -58,7 +66,11 @@ class AppBackupAgent : BackupAgent() {
|
||||
restoreBackupFile(
|
||||
data.fileDescriptor,
|
||||
size,
|
||||
BackupRepository(MangaDatabase(applicationContext), AppSettings(applicationContext)),
|
||||
BackupRepository(
|
||||
db = MangaDatabase(applicationContext),
|
||||
settings = AppSettings(applicationContext),
|
||||
tapGridSettings = TapGridSettings(applicationContext),
|
||||
),
|
||||
)
|
||||
destination.delete()
|
||||
} else {
|
||||
@@ -76,6 +88,7 @@ class AppBackupAgent : BackupAgent() {
|
||||
backup.put(repository.dumpBookmarks())
|
||||
backup.put(repository.dumpSources())
|
||||
backup.put(repository.dumpSettings())
|
||||
backup.put(repository.dumpReaderGridSettings())
|
||||
backup.finish()
|
||||
backup.file
|
||||
}
|
||||
@@ -103,6 +116,7 @@ class AppBackupAgent : BackupAgent() {
|
||||
backup.getEntry(BackupEntry.Name.BOOKMARKS)?.let { repository.restoreBookmarks(it) }
|
||||
backup.getEntry(BackupEntry.Name.SOURCES)?.let { repository.restoreSources(it) }
|
||||
backup.getEntry(BackupEntry.Name.SETTINGS)?.let { repository.restoreSettings(it) }
|
||||
backup.getEntry(BackupEntry.Name.SETTINGS_READER_GRID)?.let { repository.restoreReaderGridSettings(it) }
|
||||
}
|
||||
} finally {
|
||||
backup.close()
|
||||
|
||||
@@ -16,10 +16,10 @@ import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||
import org.koitharu.kotatsu.core.util.ext.tryLaunch
|
||||
import org.koitharu.kotatsu.core.util.progress.Progress
|
||||
import org.koitharu.kotatsu.databinding.DialogProgressBinding
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@AndroidEntryPoint
|
||||
class BackupDialogFragment : AlertDialogFragment<DialogProgressBinding>() {
|
||||
@@ -68,13 +68,14 @@ class BackupDialogFragment : AlertDialogFragment<DialogProgressBinding>() {
|
||||
dismiss()
|
||||
}
|
||||
|
||||
private fun onProgressChanged(value: Float) {
|
||||
private fun onProgressChanged(value: Progress) {
|
||||
with(requireViewBinding().progressBar) {
|
||||
isVisible = true
|
||||
val wasIndeterminate = isIndeterminate
|
||||
isIndeterminate = value < 0
|
||||
if (value >= 0) {
|
||||
setProgressCompat((value * max).roundToInt(), !wasIndeterminate)
|
||||
isIndeterminate = value.isIndeterminate
|
||||
if (!value.isIndeterminate) {
|
||||
max = value.total
|
||||
setProgressCompat(value.progress, !wasIndeterminate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ data class BackupEntryModel(
|
||||
BackupEntry.Name.CATEGORIES -> R.string.favourites_categories
|
||||
BackupEntry.Name.FAVOURITES -> R.string.favourites
|
||||
BackupEntry.Name.SETTINGS -> R.string.settings
|
||||
BackupEntry.Name.SETTINGS_READER_GRID -> R.string.reader_actions
|
||||
BackupEntry.Name.BOOKMARKS -> R.string.bookmarks
|
||||
BackupEntry.Name.SOURCES -> R.string.remote_sources
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import org.koitharu.kotatsu.core.backup.BackupZipOutput
|
||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
|
||||
import org.koitharu.kotatsu.core.util.ext.call
|
||||
import org.koitharu.kotatsu.core.util.progress.Progress
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -18,35 +19,37 @@ class BackupViewModel @Inject constructor(
|
||||
@ApplicationContext context: Context,
|
||||
) : BaseViewModel() {
|
||||
|
||||
val progress = MutableStateFlow(-1f)
|
||||
val progress = MutableStateFlow(Progress.INDETERMINATE)
|
||||
val onBackupDone = MutableEventFlow<File>()
|
||||
|
||||
init {
|
||||
launchLoadingJob {
|
||||
val file = BackupZipOutput.createTemp(context).use { backup ->
|
||||
val step = 1f / 6f
|
||||
progress.value = Progress(0, 7)
|
||||
backup.put(repository.createIndex())
|
||||
|
||||
progress.value = 0f
|
||||
backup.put(repository.dumpHistory())
|
||||
progress.value = progress.value.inc()
|
||||
|
||||
progress.value += step
|
||||
backup.put(repository.dumpCategories())
|
||||
progress.value = progress.value.inc()
|
||||
|
||||
progress.value += step
|
||||
backup.put(repository.dumpFavourites())
|
||||
progress.value = progress.value.inc()
|
||||
|
||||
progress.value += step
|
||||
backup.put(repository.dumpBookmarks())
|
||||
progress.value = progress.value.inc()
|
||||
|
||||
progress.value += step
|
||||
backup.put(repository.dumpSources())
|
||||
progress.value = progress.value.inc()
|
||||
|
||||
progress.value += step
|
||||
backup.put(repository.dumpSettings())
|
||||
progress.value = progress.value.inc()
|
||||
|
||||
backup.put(repository.dumpReaderGridSettings())
|
||||
progress.value = progress.value.inc()
|
||||
|
||||
backup.finish()
|
||||
progress.value = 1f
|
||||
backup.file
|
||||
}
|
||||
onBackupDone.call(file)
|
||||
|
||||
@@ -43,6 +43,7 @@ class PeriodicalBackupService : CoroutineIntentService() {
|
||||
backup.put(repository.dumpBookmarks())
|
||||
backup.put(repository.dumpSources())
|
||||
backup.put(repository.dumpSettings())
|
||||
backup.put(repository.dumpReaderGridSettings())
|
||||
backup.finish()
|
||||
}
|
||||
externalBackupStorage.put(output.file)
|
||||
|
||||
@@ -164,6 +164,15 @@ class RestoreService : CoroutineIntentService() {
|
||||
|
||||
notify()
|
||||
|
||||
if (BackupEntry.Name.SETTINGS_READER_GRID in entries) {
|
||||
input.getEntry(BackupEntry.Name.SETTINGS_READER_GRID)?.let {
|
||||
result += repository.restoreReaderGridSettings(it)
|
||||
}
|
||||
progress++
|
||||
}
|
||||
|
||||
notify()
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
@@ -82,6 +82,7 @@
|
||||
android:layout_marginEnd="@dimen/margin_normal"
|
||||
android:ellipsize="end"
|
||||
android:gravity="center_vertical|start"
|
||||
android:maxLines="3"
|
||||
android:textAppearance="?attr/textAppearanceBodyLarge"
|
||||
app:layout_constraintBottom_toBottomOf="@id/guideline"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
|
||||
Reference in New Issue
Block a user