Sync on demand
This commit is contained in:
@@ -13,6 +13,9 @@
|
||||
<uses-permission android:name="android.permission.MANAGE_ACCOUNTS" />
|
||||
<uses-permission android:name="android.permission.AUTHENTICATE_ACCOUNTS" />
|
||||
<uses-permission android:name="android.permission.USE_CREDENTIALS" />
|
||||
<uses-permission android:name="android.permission.READ_SYNC_STATS" />
|
||||
<uses-permission android:name="android.permission.READ_SYNC_SETTINGS" />
|
||||
<uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" />
|
||||
|
||||
<application
|
||||
android:name="org.koitharu.kotatsu.KotatsuApp"
|
||||
|
||||
@@ -30,7 +30,6 @@ import org.koitharu.kotatsu.settings.settingsModule
|
||||
import org.koitharu.kotatsu.suggestions.suggestionsModule
|
||||
import org.koitharu.kotatsu.sync.syncModule
|
||||
import org.koitharu.kotatsu.tracker.trackerModule
|
||||
import org.koitharu.kotatsu.widget.WidgetUpdater
|
||||
import org.koitharu.kotatsu.widget.appWidgetModule
|
||||
|
||||
class KotatsuApp : Application() {
|
||||
@@ -44,9 +43,6 @@ class KotatsuApp : Application() {
|
||||
Thread.setDefaultUncaughtExceptionHandler(AppCrashHandler(applicationContext))
|
||||
AppCompatDelegate.setDefaultNightMode(get<AppSettings>().theme)
|
||||
registerActivityLifecycleCallbacks(get<AppProtectHelper>())
|
||||
val widgetUpdater = WidgetUpdater(applicationContext)
|
||||
widgetUpdater.subscribeToFavourites(get())
|
||||
widgetUpdater.subscribeToHistory(get())
|
||||
}
|
||||
|
||||
private fun initKoin() {
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
package org.koitharu.kotatsu.core.db
|
||||
|
||||
import androidx.room.InvalidationTracker
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.dsl.module
|
||||
|
||||
val databaseModule
|
||||
get() = module {
|
||||
single { MangaDatabase(androidContext()) }
|
||||
single {
|
||||
MangaDatabase(androidContext()).also { db ->
|
||||
getAll<InvalidationTracker.Observer>().forEach {
|
||||
db.invalidationTracker.addObserver(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import com.google.android.material.navigation.NavigationView
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.yield
|
||||
import org.koin.android.ext.android.get
|
||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||
import org.koitharu.kotatsu.R
|
||||
@@ -56,6 +57,7 @@ import org.koitharu.kotatsu.settings.newsources.NewSourcesDialogFragment
|
||||
import org.koitharu.kotatsu.settings.onboard.OnboardDialogFragment
|
||||
import org.koitharu.kotatsu.suggestions.ui.SuggestionsFragment
|
||||
import org.koitharu.kotatsu.suggestions.ui.SuggestionsWorker
|
||||
import org.koitharu.kotatsu.sync.domain.SyncController
|
||||
import org.koitharu.kotatsu.tracker.ui.FeedFragment
|
||||
import org.koitharu.kotatsu.tracker.work.TrackWorker
|
||||
import org.koitharu.kotatsu.utils.VoiceInputContract
|
||||
@@ -440,6 +442,8 @@ class MainActivity :
|
||||
!settings.isSourcesSelected -> OnboardDialogFragment.showWelcome(supportFragmentManager)
|
||||
settings.newSources.isNotEmpty() -> NewSourcesDialogFragment.show(supportFragmentManager)
|
||||
}
|
||||
yield()
|
||||
get<SyncController>().requestFullSync()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package org.koitharu.kotatsu.settings
|
||||
|
||||
import android.accounts.AccountManager
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Bundle
|
||||
@@ -7,7 +9,10 @@ import android.provider.Settings
|
||||
import android.view.View
|
||||
import androidx.preference.ListPreference
|
||||
import androidx.preference.Preference
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koin.android.ext.android.inject
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
|
||||
@@ -17,6 +22,8 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.local.data.LocalStorageManager
|
||||
import org.koitharu.kotatsu.parsers.util.names
|
||||
import org.koitharu.kotatsu.settings.utils.SliderPreference
|
||||
import org.koitharu.kotatsu.sync.domain.AUTHORITY_FAVOURITES
|
||||
import org.koitharu.kotatsu.sync.domain.AUTHORITY_HISTORY
|
||||
import org.koitharu.kotatsu.utils.ext.getStorageName
|
||||
import org.koitharu.kotatsu.utils.ext.setDefaultValueCompat
|
||||
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
|
||||
@@ -60,6 +67,11 @@ class ContentSettingsFragment :
|
||||
settings.subscribe(this)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
bindSyncSummary()
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
settings.unsubscribe(this)
|
||||
super.onDestroyView()
|
||||
@@ -78,10 +90,6 @@ class ContentSettingsFragment :
|
||||
AppSettings.KEY_SOURCES_HIDDEN -> {
|
||||
bindRemoteSourcesSummary()
|
||||
}
|
||||
AppSettings.KEY_SYNC -> {
|
||||
val intent = Intent(Settings.ACTION_SYNC_SETTINGS)
|
||||
startActivity(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +104,23 @@ class ContentSettingsFragment :
|
||||
.show()
|
||||
true
|
||||
}
|
||||
AppSettings.KEY_SYNC -> {
|
||||
val am = AccountManager.get(requireContext())
|
||||
val accountType = getString(R.string.account_type_sync)
|
||||
if (am.getAccountsByType(accountType).firstOrNull() == null) {
|
||||
am.addAccount(accountType, accountType, null, null, requireActivity(), null, null)
|
||||
} else {
|
||||
val intent = Intent(Settings.ACTION_SYNC_SETTINGS)
|
||||
intent.putExtra(Settings.EXTRA_ACCOUNT_TYPES, arrayOf(accountType))
|
||||
intent.putExtra(Settings.EXTRA_AUTHORITIES, arrayOf(AUTHORITY_HISTORY, AUTHORITY_FAVOURITES))
|
||||
try {
|
||||
startActivity(intent)
|
||||
} catch (_: ActivityNotFoundException) {
|
||||
Snackbar.make(listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
else -> super.onPreferenceTreeClick(preference)
|
||||
}
|
||||
}
|
||||
@@ -119,4 +144,16 @@ class ContentSettingsFragment :
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindSyncSummary() {
|
||||
viewLifecycleScope.launch {
|
||||
val account = withContext(Dispatchers.Default) {
|
||||
val type = getString(R.string.account_type_sync)
|
||||
AccountManager.get(requireContext()).getAccountsByType(type).firstOrNull()
|
||||
}
|
||||
findPreference<Preference>(AppSettings.KEY_SYNC)?.run {
|
||||
summary = account?.name ?: getString(R.string.sync_title)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,19 @@
|
||||
package org.koitharu.kotatsu.sync
|
||||
|
||||
import androidx.room.InvalidationTracker
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.androidx.viewmodel.dsl.viewModel
|
||||
import org.koin.dsl.bind
|
||||
import org.koin.dsl.module
|
||||
import org.koitharu.kotatsu.sync.data.SyncAuthApi
|
||||
import org.koitharu.kotatsu.sync.domain.SyncController
|
||||
import org.koitharu.kotatsu.sync.ui.SyncAuthViewModel
|
||||
|
||||
val syncModule
|
||||
get() = module {
|
||||
|
||||
single { SyncController(androidContext()) } bind InvalidationTracker.Observer::class
|
||||
|
||||
factory { SyncAuthApi(androidContext(), get()) }
|
||||
|
||||
viewModel { SyncAuthViewModel(get()) }
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package org.koitharu.kotatsu.sync.domain
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import androidx.room.InvalidationTracker
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.db.TABLE_FAVOURITES
|
||||
import org.koitharu.kotatsu.core.db.TABLE_FAVOURITE_CATEGORIES
|
||||
import org.koitharu.kotatsu.core.db.TABLE_HISTORY
|
||||
import org.koitharu.kotatsu.utils.ext.processLifecycleScope
|
||||
|
||||
class SyncController(
|
||||
context: Context,
|
||||
) : InvalidationTracker.Observer(arrayOf(TABLE_HISTORY, TABLE_FAVOURITES, TABLE_FAVOURITE_CATEGORIES)) {
|
||||
|
||||
private val am = AccountManager.get(context)
|
||||
private val accountType = context.getString(R.string.account_type_sync)
|
||||
|
||||
override fun onInvalidated(tables: MutableSet<String>) {
|
||||
requestSync(
|
||||
favourites = TABLE_FAVOURITES in tables || TABLE_FAVOURITE_CATEGORIES in tables,
|
||||
history = TABLE_HISTORY in tables,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun requestFullSync() = withContext(Dispatchers.Default) {
|
||||
requestSyncImpl(favourites = true, history = true)
|
||||
}
|
||||
|
||||
private fun requestSync(favourites: Boolean, history: Boolean) = processLifecycleScope.launch(Dispatchers.Default) {
|
||||
requestSyncImpl(favourites, history)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun requestSyncImpl(favourites: Boolean, history: Boolean) {
|
||||
if (!favourites && !history) {
|
||||
return
|
||||
}
|
||||
val account = peekAccount() ?: return
|
||||
if (!ContentResolver.getMasterSyncAutomatically()) {
|
||||
return
|
||||
}
|
||||
// TODO limit frequency
|
||||
if (favourites) {
|
||||
requestSyncForAuthority(account, AUTHORITY_FAVOURITES)
|
||||
}
|
||||
if (history) {
|
||||
requestSyncForAuthority(account, AUTHORITY_HISTORY)
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestSyncForAuthority(account: Account, authority: String) {
|
||||
if (
|
||||
ContentResolver.getSyncAutomatically(account, AUTHORITY_FAVOURITES) &&
|
||||
!ContentResolver.isSyncActive(account, authority) &&
|
||||
!ContentResolver.isSyncPending(account, authority)
|
||||
) {
|
||||
ContentResolver.requestSync(account, authority, Bundle.EMPTY)
|
||||
}
|
||||
}
|
||||
|
||||
private fun peekAccount(): Account? {
|
||||
return am.getAccountsByType(accountType).firstOrNull()
|
||||
}
|
||||
}
|
||||
@@ -13,17 +13,17 @@ import org.json.JSONObject
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.db.*
|
||||
import org.koitharu.kotatsu.parsers.util.json.mapJSONTo
|
||||
import org.koitharu.kotatsu.parsers.util.parseJson
|
||||
import org.koitharu.kotatsu.sync.data.SyncAuthApi
|
||||
import org.koitharu.kotatsu.sync.data.SyncAuthenticator
|
||||
import org.koitharu.kotatsu.sync.data.SyncInterceptor
|
||||
import org.koitharu.kotatsu.utils.GZipInterceptor
|
||||
import org.koitharu.kotatsu.utils.ext.parseJsonOrNull
|
||||
import org.koitharu.kotatsu.utils.ext.toContentValues
|
||||
import org.koitharu.kotatsu.utils.ext.toJson
|
||||
import org.koitharu.kotatsu.utils.ext.toRequestBody
|
||||
|
||||
private const val AUTHORITY_HISTORY = "org.koitharu.kotatsu.history"
|
||||
private const val AUTHORITY_FAVOURITES = "org.koitharu.kotatsu.favourites"
|
||||
const val AUTHORITY_HISTORY = "org.koitharu.kotatsu.history"
|
||||
const val AUTHORITY_FAVOURITES = "org.koitharu.kotatsu.favourites"
|
||||
|
||||
private const val FIELD_TIMESTAMP = "timestamp"
|
||||
|
||||
@@ -53,7 +53,7 @@ class SyncHelper(
|
||||
.url("$baseUrl/resource/$TABLE_FAVOURITES")
|
||||
.post(data.toRequestBody())
|
||||
.build()
|
||||
val response = httpClient.newCall(request).execute().parseJson()
|
||||
val response = httpClient.newCall(request).execute().parseJsonOrNull() ?: return
|
||||
val timestamp = response.getLong(FIELD_TIMESTAMP)
|
||||
val categoriesResult = upsertFavouriteCategories(response.getJSONArray(TABLE_FAVOURITE_CATEGORIES), timestamp)
|
||||
syncResult.stats.numDeletes += categoriesResult.first().count?.toLong() ?: 0L
|
||||
@@ -71,7 +71,7 @@ class SyncHelper(
|
||||
.url("$baseUrl/resource/$TABLE_HISTORY")
|
||||
.post(data.toRequestBody())
|
||||
.build()
|
||||
val response = httpClient.newCall(request).execute().parseJson()
|
||||
val response = httpClient.newCall(request).execute().parseJsonOrNull() ?: return
|
||||
val result = upsertHistory(
|
||||
json = response.getJSONArray(TABLE_HISTORY),
|
||||
timestamp = response.getLong(FIELD_TIMESTAMP),
|
||||
|
||||
@@ -9,7 +9,7 @@ import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.text.TextUtils
|
||||
|
||||
class SyncAuthenticator(private val context: Context) : AbstractAccountAuthenticator(context) {
|
||||
class SyncAccountAuthenticator(private val context: Context) : AbstractAccountAuthenticator(context) {
|
||||
|
||||
override fun editProperties(response: AccountAuthenticatorResponse?, accountType: String?): Bundle? = null
|
||||
|
||||
@@ -52,7 +52,6 @@ class SyncAuthenticator(private val context: Context) : AbstractAccountAuthentic
|
||||
} else {
|
||||
val intent = Intent(context, SyncAuthActivity::class.java)
|
||||
intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response)
|
||||
// intent.putExtra(SyncAuthActivity.EXTRA_TOKEN_TYPE, authTokenType)
|
||||
val bundle = Bundle()
|
||||
if (options != null) {
|
||||
bundle.putAll(options)
|
||||
@@ -6,11 +6,11 @@ import android.os.IBinder
|
||||
|
||||
class SyncAuthenticatorService : Service() {
|
||||
|
||||
private lateinit var authenticator: SyncAuthenticator
|
||||
private lateinit var authenticator: SyncAccountAuthenticator
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
authenticator = SyncAuthenticator(this)
|
||||
authenticator = SyncAccountAuthenticator(this)
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? {
|
||||
|
||||
@@ -2,8 +2,19 @@ package org.koitharu.kotatsu.utils.ext
|
||||
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import org.json.JSONObject
|
||||
import org.koitharu.kotatsu.parsers.util.parseJson
|
||||
import java.net.HttpURLConnection
|
||||
|
||||
private val TYPE_JSON = "application/json".toMediaType()
|
||||
|
||||
fun JSONObject.toRequestBody() = toString().toRequestBody(TYPE_JSON)
|
||||
fun JSONObject.toRequestBody() = toString().toRequestBody(TYPE_JSON)
|
||||
|
||||
fun Response.parseJsonOrNull(): JSONObject? {
|
||||
return if (code == HttpURLConnection.HTTP_NO_CONTENT) {
|
||||
null
|
||||
} else {
|
||||
parseJson()
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,15 @@
|
||||
package org.koitharu.kotatsu.widget
|
||||
|
||||
import androidx.room.InvalidationTracker
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.androidx.viewmodel.dsl.viewModel
|
||||
import org.koin.dsl.module
|
||||
import org.koitharu.kotatsu.widget.shelf.ShelfConfigViewModel
|
||||
|
||||
val appWidgetModule
|
||||
get() = module {
|
||||
|
||||
single<InvalidationTracker.Observer> { WidgetUpdater(androidContext()) }
|
||||
|
||||
viewModel { ShelfConfigViewModel(get()) }
|
||||
}
|
||||
@@ -4,36 +4,26 @@ import android.appwidget.AppWidgetManager
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.retry
|
||||
import kotlinx.coroutines.plus
|
||||
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
|
||||
import org.koitharu.kotatsu.history.domain.HistoryRepository
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import org.koitharu.kotatsu.utils.ext.processLifecycleScope
|
||||
import androidx.room.InvalidationTracker
|
||||
import org.koitharu.kotatsu.core.db.TABLE_FAVOURITES
|
||||
import org.koitharu.kotatsu.core.db.TABLE_HISTORY
|
||||
import org.koitharu.kotatsu.widget.recent.RecentWidgetProvider
|
||||
import org.koitharu.kotatsu.widget.shelf.ShelfWidgetProvider
|
||||
|
||||
class WidgetUpdater(private val context: Context) {
|
||||
class WidgetUpdater(
|
||||
private val context: Context
|
||||
) : InvalidationTracker.Observer(TABLE_HISTORY, TABLE_FAVOURITES) {
|
||||
|
||||
fun subscribeToFavourites(repository: FavouritesRepository) {
|
||||
repository.observeAll(SortOrder.NEWEST)
|
||||
.onEach { updateWidget(ShelfWidgetProvider::class.java) }
|
||||
.retry { error -> error !is CancellationException }
|
||||
.launchIn(processLifecycleScope + Dispatchers.Default)
|
||||
override fun onInvalidated(tables: MutableSet<String>) {
|
||||
if (TABLE_HISTORY in tables) {
|
||||
updateWidgets(RecentWidgetProvider::class.java)
|
||||
}
|
||||
if (TABLE_FAVOURITES in tables) {
|
||||
updateWidgets(ShelfWidgetProvider::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
fun subscribeToHistory(repository: HistoryRepository) {
|
||||
repository.observeAll()
|
||||
.onEach { updateWidget(RecentWidgetProvider::class.java) }
|
||||
.retry { error -> error !is CancellationException }
|
||||
.launchIn(processLifecycleScope + Dispatchers.Default)
|
||||
}
|
||||
|
||||
private fun updateWidget(cls: Class<*>) {
|
||||
private fun updateWidgets(cls: Class<*>) {
|
||||
val intent = Intent(context, cls)
|
||||
intent.action = AppWidgetManager.ACTION_APPWIDGET_UPDATE
|
||||
val ids = AppWidgetManager.getInstance(context)
|
||||
|
||||
@@ -107,7 +107,7 @@
|
||||
android:layout_alignParentEnd="true"
|
||||
android:layout_marginTop="12dp"
|
||||
android:gravity="center_horizontal"
|
||||
android:text="Enter your email to continue"
|
||||
android:text="@string/enter_email_text"
|
||||
android:textAppearance="?textAppearanceSubtitle1" />
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
@@ -119,6 +119,7 @@
|
||||
android:layout_alignParentStart="true"
|
||||
android:layout_alignParentEnd="true"
|
||||
android:layout_marginTop="30dp"
|
||||
app:endIconMode="password_toggle"
|
||||
app:errorIconDrawable="@null"
|
||||
app:helperText="You can sign in into an existing account or create a new one"
|
||||
app:hintEnabled="false">
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<string name="url_forpda" translatable="false">https://4pda.to/forum/index.php?showtopic=697669</string>
|
||||
<string name="url_weblate" translatable="false">https://hosted.weblate.org/engage/kotatsu</string>
|
||||
<string name="account_type_sync" translatable="false">org.kotatsu.sync</string>
|
||||
<string name="url_sync_server" translatable="false">http://95.216.215.49:8080</string>
|
||||
<string name="url_sync_server" translatable="false">http://95.216.215.49:8055</string>
|
||||
<string-array name="values_theme" translatable="false">
|
||||
<item>-1</item>
|
||||
<item>1</item>
|
||||
|
||||
@@ -303,4 +303,5 @@
|
||||
<string name="default_mode">Default mode</string>
|
||||
<string name="detect_reader_mode">Autodetect reader mode</string>
|
||||
<string name="detect_reader_mode_summary">Automatically detect if manga is webtoon</string>
|
||||
<string name="enter_email_text">Enter your email to continue</string>
|
||||
</resources>
|
||||
@@ -44,7 +44,8 @@
|
||||
android:key="sync"
|
||||
android:persistent="false"
|
||||
android:summary="@string/sync_title"
|
||||
android:title="@string/sync" />
|
||||
android:title="@string/sync"
|
||||
app:allowDividerAbove="true" />
|
||||
|
||||
<PreferenceScreen
|
||||
android:fragment="org.koitharu.kotatsu.settings.backup.BackupSettingsFragment"
|
||||
|
||||
@@ -1,10 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<PreferenceScreen
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<Preference
|
||||
android:persistent="false"
|
||||
android:summary="Preference stub"
|
||||
android:title="TODO" />
|
||||
|
||||
</PreferenceScreen>
|
||||
<PreferenceScreen />
|
||||
Reference in New Issue
Block a user