mirror of
https://github.com/recloudstream/cloudstream.git
synced 2024-08-15 01:53:11 +00:00
Automatic backups (#592)
Co-authored-by: Cloudburst <18114966+C10udburst@users.noreply.github.com>
This commit is contained in:
parent
abbad1bc94
commit
91b195241e
8 changed files with 193 additions and 35 deletions
|
@ -1110,7 +1110,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
|
||||||
if (appVer != lastAppAutoBackup) {
|
if (appVer != lastAppAutoBackup) {
|
||||||
setKey("VERSION_NAME", BuildConfig.VERSION_NAME)
|
setKey("VERSION_NAME", BuildConfig.VERSION_NAME)
|
||||||
normalSafeApiCall {
|
normalSafeApiCall {
|
||||||
backup()
|
backup(this)
|
||||||
}
|
}
|
||||||
normalSafeApiCall {
|
normalSafeApiCall {
|
||||||
// Recompile oat on new version
|
// Recompile oat on new version
|
||||||
|
|
|
@ -0,0 +1,96 @@
|
||||||
|
package com.lagradost.cloudstream3.services
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.work.Constraints
|
||||||
|
import androidx.work.CoroutineWorker
|
||||||
|
import androidx.work.ExistingPeriodicWorkPolicy
|
||||||
|
import androidx.work.ForegroundInfo
|
||||||
|
import androidx.work.PeriodicWorkRequest
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import androidx.work.WorkerParameters
|
||||||
|
import com.lagradost.cloudstream3.R
|
||||||
|
import com.lagradost.cloudstream3.utils.AppUtils.createNotificationChannel
|
||||||
|
import com.lagradost.cloudstream3.utils.BackupUtils
|
||||||
|
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
const val BACKUP_CHANNEL_ID = "cloudstream3.backups"
|
||||||
|
const val BACKUP_WORK_NAME = "work_backup"
|
||||||
|
const val BACKUP_CHANNEL_NAME = "Backups"
|
||||||
|
const val BACKUP_CHANNEL_DESCRIPTION = "Notifications for background backups"
|
||||||
|
const val BACKUP_NOTIFICATION_ID = 938712898 // Random unique
|
||||||
|
|
||||||
|
class BackupWorkManager(val context: Context, workerParams: WorkerParameters) :
|
||||||
|
CoroutineWorker(context, workerParams) {
|
||||||
|
companion object {
|
||||||
|
fun enqueuePeriodicWork(context: Context?, intervalHours: Long) {
|
||||||
|
if (context == null) return
|
||||||
|
|
||||||
|
if (intervalHours == 0L) {
|
||||||
|
WorkManager.getInstance(context).cancelUniqueWork(BACKUP_WORK_NAME)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val constraints = Constraints.Builder()
|
||||||
|
.setRequiresStorageNotLow(true)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val periodicSyncDataWork =
|
||||||
|
PeriodicWorkRequest.Builder(
|
||||||
|
BackupWorkManager::class.java,
|
||||||
|
intervalHours,
|
||||||
|
TimeUnit.HOURS
|
||||||
|
)
|
||||||
|
.addTag(BACKUP_WORK_NAME)
|
||||||
|
.setConstraints(constraints)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
|
||||||
|
BACKUP_WORK_NAME,
|
||||||
|
ExistingPeriodicWorkPolicy.UPDATE,
|
||||||
|
periodicSyncDataWork
|
||||||
|
)
|
||||||
|
|
||||||
|
// Uncomment below for testing
|
||||||
|
|
||||||
|
// val oneTimeBackupWork =
|
||||||
|
// OneTimeWorkRequest.Builder(BackupWorkManager::class.java)
|
||||||
|
// .addTag(BACKUP_WORK_NAME)
|
||||||
|
// .setConstraints(constraints)
|
||||||
|
// .build()
|
||||||
|
//
|
||||||
|
// WorkManager.getInstance(context).enqueue(oneTimeBackupWork)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val backupNotificationBuilder =
|
||||||
|
NotificationCompat.Builder(context, BACKUP_CHANNEL_ID)
|
||||||
|
.setColorized(true)
|
||||||
|
.setOnlyAlertOnce(true)
|
||||||
|
.setSilent(true)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setContentTitle(context.getString(R.string.pref_category_backup))
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||||
|
.setColor(context.colorFromAttribute(R.attr.colorPrimary))
|
||||||
|
.setSmallIcon(R.drawable.ic_cloudstream_monochrome_big)
|
||||||
|
|
||||||
|
override suspend fun doWork(): Result {
|
||||||
|
context.createNotificationChannel(
|
||||||
|
BACKUP_CHANNEL_ID,
|
||||||
|
BACKUP_CHANNEL_NAME,
|
||||||
|
BACKUP_CHANNEL_DESCRIPTION
|
||||||
|
)
|
||||||
|
|
||||||
|
setForeground(
|
||||||
|
ForegroundInfo(
|
||||||
|
BACKUP_NOTIFICATION_ID,
|
||||||
|
backupNotificationBuilder.build()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
BackupUtils.backup(context)
|
||||||
|
|
||||||
|
return Result.success()
|
||||||
|
}
|
||||||
|
}
|
|
@ -19,14 +19,16 @@ import com.lagradost.cloudstream3.app
|
||||||
import com.lagradost.cloudstream3.databinding.LogcatBinding
|
import com.lagradost.cloudstream3.databinding.LogcatBinding
|
||||||
import com.lagradost.cloudstream3.mvvm.logError
|
import com.lagradost.cloudstream3.mvvm.logError
|
||||||
import com.lagradost.cloudstream3.network.initClient
|
import com.lagradost.cloudstream3.network.initClient
|
||||||
|
import com.lagradost.cloudstream3.services.BackupWorkManager
|
||||||
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getPref
|
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getPref
|
||||||
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setPaddingBottom
|
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setPaddingBottom
|
||||||
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar
|
import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar
|
||||||
import com.lagradost.cloudstream3.utils.BackupUtils.backup
|
import com.lagradost.cloudstream3.utils.BackupUtils
|
||||||
import com.lagradost.cloudstream3.utils.BackupUtils.restorePrompt
|
import com.lagradost.cloudstream3.utils.BackupUtils.restorePrompt
|
||||||
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
||||||
import com.lagradost.cloudstream3.utils.InAppUpdater.Companion.runAutoUpdate
|
import com.lagradost.cloudstream3.utils.InAppUpdater.Companion.runAutoUpdate
|
||||||
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog
|
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog
|
||||||
|
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe
|
import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard
|
import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard
|
||||||
import com.lagradost.cloudstream3.utils.VideoDownloadManager
|
import com.lagradost.cloudstream3.utils.VideoDownloadManager
|
||||||
|
@ -48,7 +50,30 @@ class SettingsUpdates : PreferenceFragmentCompat() {
|
||||||
//val settingsManager = PreferenceManager.getDefaultSharedPreferences(requireContext())
|
//val settingsManager = PreferenceManager.getDefaultSharedPreferences(requireContext())
|
||||||
|
|
||||||
getPref(R.string.backup_key)?.setOnPreferenceClickListener {
|
getPref(R.string.backup_key)?.setOnPreferenceClickListener {
|
||||||
activity?.backup()
|
BackupUtils.backup(activity)
|
||||||
|
return@setOnPreferenceClickListener true
|
||||||
|
}
|
||||||
|
|
||||||
|
getPref(R.string.automatic_backup_key)?.setOnPreferenceClickListener {
|
||||||
|
val settingsManager = PreferenceManager.getDefaultSharedPreferences(requireContext())
|
||||||
|
|
||||||
|
val prefNames = resources.getStringArray(R.array.periodic_work_names)
|
||||||
|
val prefValues = resources.getIntArray(R.array.periodic_work_values)
|
||||||
|
val current = settingsManager.getInt(getString(R.string.automatic_backup_key), 0)
|
||||||
|
|
||||||
|
activity?.showDialog(
|
||||||
|
prefNames.toList(),
|
||||||
|
prefValues.indexOf(current),
|
||||||
|
getString(R.string.backup_frequency),
|
||||||
|
true,
|
||||||
|
{}) { index ->
|
||||||
|
settingsManager.edit()
|
||||||
|
.putInt(getString(R.string.automatic_backup_key), prefValues[index]).apply()
|
||||||
|
BackupWorkManager.enqueuePeriodicWork(
|
||||||
|
context ?: AcraApplication.context,
|
||||||
|
prefValues[index].toLong()
|
||||||
|
)
|
||||||
|
}
|
||||||
return@setOnPreferenceClickListener true
|
return@setOnPreferenceClickListener true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -65,7 +90,7 @@ class SettingsUpdates : PreferenceFragmentCompat() {
|
||||||
val builder =
|
val builder =
|
||||||
AlertDialog.Builder(pref.context, R.style.AlertDialogCustom)
|
AlertDialog.Builder(pref.context, R.style.AlertDialogCustom)
|
||||||
|
|
||||||
val binding = LogcatBinding.inflate(layoutInflater,null,false )
|
val binding = LogcatBinding.inflate(layoutInflater, null, false)
|
||||||
builder.setView(binding.root)
|
builder.setView(binding.root)
|
||||||
|
|
||||||
val dialog = builder.create()
|
val dialog = builder.create()
|
||||||
|
@ -176,7 +201,8 @@ class SettingsUpdates : PreferenceFragmentCompat() {
|
||||||
val settingsManager = PreferenceManager.getDefaultSharedPreferences(it.context)
|
val settingsManager = PreferenceManager.getDefaultSharedPreferences(it.context)
|
||||||
|
|
||||||
val prefNames = resources.getStringArray(R.array.auto_download_plugin)
|
val prefNames = resources.getStringArray(R.array.auto_download_plugin)
|
||||||
val prefValues = enumValues<AutoDownloadMode>().sortedBy { x -> x.value }.map { x -> x.value }
|
val prefValues =
|
||||||
|
enumValues<AutoDownloadMode>().sortedBy { x -> x.value }.map { x -> x.value }
|
||||||
|
|
||||||
val current = settingsManager.getInt(getString(R.string.auto_download_plugins_key), 0)
|
val current = settingsManager.getInt(getString(R.string.auto_download_plugins_key), 0)
|
||||||
|
|
||||||
|
@ -186,7 +212,8 @@ class SettingsUpdates : PreferenceFragmentCompat() {
|
||||||
getString(R.string.automatic_plugin_download_mode_title),
|
getString(R.string.automatic_plugin_download_mode_title),
|
||||||
true,
|
true,
|
||||||
{}) {
|
{}) {
|
||||||
settingsManager.edit().putInt(getString(R.string.auto_download_plugins_key), prefValues[it]).apply()
|
settingsManager.edit()
|
||||||
|
.putInt(getString(R.string.auto_download_plugins_key), prefValues[it]).apply()
|
||||||
(context ?: AcraApplication.context)?.let { ctx -> app.initClient(ctx) }
|
(context ?: AcraApplication.context)?.let { ctx -> app.initClient(ctx) }
|
||||||
}
|
}
|
||||||
return@setOnPreferenceClickListener true
|
return@setOnPreferenceClickListener true
|
||||||
|
|
|
@ -10,6 +10,7 @@ import androidx.annotation.WorkerThread
|
||||||
import androidx.fragment.app.FragmentActivity
|
import androidx.fragment.app.FragmentActivity
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
import com.fasterxml.jackson.module.kotlin.readValue
|
import com.fasterxml.jackson.module.kotlin.readValue
|
||||||
|
import com.lagradost.cloudstream3.AcraApplication.Companion.getActivity
|
||||||
import com.lagradost.cloudstream3.CommonActivity.showToast
|
import com.lagradost.cloudstream3.CommonActivity.showToast
|
||||||
import com.lagradost.cloudstream3.R
|
import com.lagradost.cloudstream3.R
|
||||||
import com.lagradost.cloudstream3.mvvm.logError
|
import com.lagradost.cloudstream3.mvvm.logError
|
||||||
|
@ -90,9 +91,11 @@ object BackupUtils {
|
||||||
)
|
)
|
||||||
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
fun Context.getBackup(): BackupFile {
|
private fun getBackup(context: Context?): BackupFile? {
|
||||||
val allData = getSharedPrefs().all.filter { it.key.isTransferable() }
|
if (context == null) return null
|
||||||
val allSettings = getDefaultSharedPrefs().all.filter { it.key.isTransferable() }
|
|
||||||
|
val allData = context.getSharedPrefs().all.filter { it.key.isTransferable() }
|
||||||
|
val allSettings = context.getDefaultSharedPrefs().all.filter { it.key.isTransferable() }
|
||||||
|
|
||||||
val allDataSorted = BackupVars(
|
val allDataSorted = BackupVars(
|
||||||
allData.filter { it.value is Boolean } as? Map<String, Boolean>,
|
allData.filter { it.value is Boolean } as? Map<String, Boolean>,
|
||||||
|
@ -119,46 +122,50 @@ object BackupUtils {
|
||||||
}
|
}
|
||||||
|
|
||||||
@WorkerThread
|
@WorkerThread
|
||||||
fun Context.restore(
|
fun restore(
|
||||||
|
context: Context?,
|
||||||
backupFile: BackupFile,
|
backupFile: BackupFile,
|
||||||
restoreSettings: Boolean,
|
restoreSettings: Boolean,
|
||||||
restoreDataStore: Boolean
|
restoreDataStore: Boolean
|
||||||
) {
|
) {
|
||||||
|
if (context == null) return
|
||||||
if (restoreSettings) {
|
if (restoreSettings) {
|
||||||
restoreMap(backupFile.settings._Bool, true)
|
context.restoreMap(backupFile.settings._Bool, true)
|
||||||
restoreMap(backupFile.settings._Int, true)
|
context.restoreMap(backupFile.settings._Int, true)
|
||||||
restoreMap(backupFile.settings._String, true)
|
context.restoreMap(backupFile.settings._String, true)
|
||||||
restoreMap(backupFile.settings._Float, true)
|
context.restoreMap(backupFile.settings._Float, true)
|
||||||
restoreMap(backupFile.settings._Long, true)
|
context.restoreMap(backupFile.settings._Long, true)
|
||||||
restoreMap(backupFile.settings._StringSet, true)
|
context.restoreMap(backupFile.settings._StringSet, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (restoreDataStore) {
|
if (restoreDataStore) {
|
||||||
restoreMap(backupFile.datastore._Bool)
|
context.restoreMap(backupFile.datastore._Bool)
|
||||||
restoreMap(backupFile.datastore._Int)
|
context.restoreMap(backupFile.datastore._Int)
|
||||||
restoreMap(backupFile.datastore._String)
|
context.restoreMap(backupFile.datastore._String)
|
||||||
restoreMap(backupFile.datastore._Float)
|
context.restoreMap(backupFile.datastore._Float)
|
||||||
restoreMap(backupFile.datastore._Long)
|
context.restoreMap(backupFile.datastore._Long)
|
||||||
restoreMap(backupFile.datastore._StringSet)
|
context.restoreMap(backupFile.datastore._StringSet)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressLint("SimpleDateFormat")
|
@SuppressLint("SimpleDateFormat")
|
||||||
fun FragmentActivity.backup() = ioSafe {
|
fun backup(context: Context?) = ioSafe {
|
||||||
|
if (context == null) return@ioSafe
|
||||||
|
|
||||||
var fileStream: OutputStream? = null
|
var fileStream: OutputStream? = null
|
||||||
var printStream: PrintWriter? = null
|
var printStream: PrintWriter? = null
|
||||||
try {
|
try {
|
||||||
if (!checkWrite()) {
|
if (!context.checkWrite()) {
|
||||||
showToast(R.string.backup_failed, Toast.LENGTH_LONG)
|
showToast(R.string.backup_failed, Toast.LENGTH_LONG)
|
||||||
requestRW()
|
context.getActivity()?.requestRW()
|
||||||
return@ioSafe
|
return@ioSafe
|
||||||
}
|
}
|
||||||
|
|
||||||
val date = SimpleDateFormat("yyyy_MM_dd_HH_mm").format(Date(currentTimeMillis()))
|
val date = SimpleDateFormat("yyyy_MM_dd_HH_mm").format(Date(currentTimeMillis()))
|
||||||
val ext = "txt"
|
val ext = "txt"
|
||||||
val displayName = "CS3_Backup_${date}"
|
val displayName = "CS3_Backup_${date}"
|
||||||
val backupFile = getBackup()
|
val backupFile = getBackup(context)
|
||||||
val stream = setupStream(this@backup, displayName, null, ext, false)
|
val stream = setupStream(context, displayName, null, ext, false)
|
||||||
|
|
||||||
fileStream = stream.openNew()
|
fileStream = stream.openNew()
|
||||||
printStream = PrintWriter(fileStream)
|
printStream = PrintWriter(fileStream)
|
||||||
|
@ -198,7 +205,8 @@ object BackupUtils {
|
||||||
val restoredValue =
|
val restoredValue =
|
||||||
mapper.readValue<BackupFile>(input)
|
mapper.readValue<BackupFile>(input)
|
||||||
|
|
||||||
activity.restore(
|
restore(
|
||||||
|
activity,
|
||||||
restoredValue,
|
restoredValue,
|
||||||
restoreSettings = true,
|
restoreSettings = true,
|
||||||
restoreDataStore = true
|
restoreDataStore = true
|
||||||
|
|
|
@ -71,7 +71,7 @@ object UIHelper {
|
||||||
val Int.toDp: Int get() = (this / Resources.getSystem().displayMetrics.density).toInt()
|
val Int.toDp: Int get() = (this / Resources.getSystem().displayMetrics.density).toInt()
|
||||||
val Float.toDp: Float get() = (this / Resources.getSystem().displayMetrics.density)
|
val Float.toDp: Float get() = (this / Resources.getSystem().displayMetrics.density)
|
||||||
|
|
||||||
fun Activity.checkWrite(): Boolean {
|
fun Context.checkWrite(): Boolean {
|
||||||
return (ContextCompat.checkSelfPermission(
|
return (ContextCompat.checkSelfPermission(
|
||||||
this,
|
this,
|
||||||
Manifest.permission.WRITE_EXTERNAL_STORAGE
|
Manifest.permission.WRITE_EXTERNAL_STORAGE
|
||||||
|
|
|
@ -126,6 +126,26 @@
|
||||||
<item>30min</item>
|
<item>30min</item>
|
||||||
</array>
|
</array>
|
||||||
|
|
||||||
|
<string-array name="periodic_work_names">
|
||||||
|
<item>@string/none</item>
|
||||||
|
<item>3h</item>
|
||||||
|
<item>6h</item>
|
||||||
|
<item>12h</item>
|
||||||
|
<item>24h</item>
|
||||||
|
<item>3d</item>
|
||||||
|
<item>7d</item>
|
||||||
|
</string-array>
|
||||||
|
<!-- Values in hours -->
|
||||||
|
<integer-array name="periodic_work_values">
|
||||||
|
<item>0</item>
|
||||||
|
<item>3</item>
|
||||||
|
<item>6</item>
|
||||||
|
<item>12</item>
|
||||||
|
<item>24</item>
|
||||||
|
<item>72</item>
|
||||||
|
<item>168</item>
|
||||||
|
</integer-array>
|
||||||
|
|
||||||
<array name="video_buffer_length_values">
|
<array name="video_buffer_length_values">
|
||||||
<item>0</item>
|
<item>0</item>
|
||||||
<item>60</item>
|
<item>60</item>
|
||||||
|
|
|
@ -51,6 +51,7 @@
|
||||||
<string name="primary_color_key" translatable="false">primary_color_key</string>
|
<string name="primary_color_key" translatable="false">primary_color_key</string>
|
||||||
<string name="restore_key" translatable="false">restore_key</string>
|
<string name="restore_key" translatable="false">restore_key</string>
|
||||||
<string name="backup_key" translatable="false">backup_key</string>
|
<string name="backup_key" translatable="false">backup_key</string>
|
||||||
|
<string name="automatic_backup_key" translatable="false">automatic_backup_key</string>
|
||||||
<string name="prefer_media_type_key" translatable="false">prefer_media_type_key_2</string>
|
<string name="prefer_media_type_key" translatable="false">prefer_media_type_key_2</string>
|
||||||
<string name="app_theme_key" translatable="false">app_theme_key</string>
|
<string name="app_theme_key" translatable="false">app_theme_key</string>
|
||||||
<string name="episode_sync_enabled_key" translatable="false">episode_sync_enabled_key</string>
|
<string name="episode_sync_enabled_key" translatable="false">episode_sync_enabled_key</string>
|
||||||
|
@ -229,6 +230,7 @@
|
||||||
<string name="episode_sync_settings_des">Automatically sync your current episode progress</string>
|
<string name="episode_sync_settings_des">Automatically sync your current episode progress</string>
|
||||||
<string name="restore_settings">Restore data from backup</string>
|
<string name="restore_settings">Restore data from backup</string>
|
||||||
<string name="backup_settings">Back up data</string>
|
<string name="backup_settings">Back up data</string>
|
||||||
|
<string name="backup_frequency">Backup frequency</string>
|
||||||
<string name="restore_success">Loaded backup file</string>
|
<string name="restore_success">Loaded backup file</string>
|
||||||
<string name="restore_failed_format" formatted="true">Failed to restore data from file %s</string>
|
<string name="restore_failed_format" formatted="true">Failed to restore data from file %s</string>
|
||||||
<string name="backup_success">Data stored</string>
|
<string name="backup_success">Data stored</string>
|
||||||
|
|
|
@ -44,6 +44,11 @@
|
||||||
android:key="@string/backup_key"
|
android:key="@string/backup_key"
|
||||||
android:title="@string/backup_settings" />
|
android:title="@string/backup_settings" />
|
||||||
|
|
||||||
|
<Preference
|
||||||
|
android:icon="@drawable/baseline_save_as_24"
|
||||||
|
android:key="@string/automatic_backup_key"
|
||||||
|
android:title="@string/backup_frequency" />
|
||||||
|
|
||||||
<Preference
|
<Preference
|
||||||
android:icon="@drawable/baseline_restore_page_24"
|
android:icon="@drawable/baseline_restore_page_24"
|
||||||
android:key="@string/restore_key"
|
android:key="@string/restore_key"
|
||||||
|
|
Loading…
Reference in a new issue