DownloadWorkerUtil: Use room database to store downloads
Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
This commit is contained in:
@@ -22,26 +22,39 @@ package com.aurora.store
|
||||
|
||||
import android.app.Application
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.hilt.work.HiltWorkerFactory
|
||||
import androidx.work.Configuration
|
||||
import com.aurora.extensions.isPAndAbove
|
||||
import com.aurora.gplayapi.data.models.App
|
||||
import com.aurora.store.data.downloader.DownloadManager
|
||||
import com.aurora.store.data.receiver.PackageManagerReceiver
|
||||
import com.aurora.store.data.service.NotificationService
|
||||
import com.aurora.store.data.work.DownloadWorker
|
||||
import com.aurora.store.util.CommonUtil
|
||||
import com.aurora.store.util.DownloadWorkerUtil
|
||||
import com.aurora.store.util.NotificationUtil
|
||||
import com.aurora.store.util.PackageUtil
|
||||
import com.tonyodev.fetch2.Fetch
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import javax.inject.Inject
|
||||
import org.lsposed.hiddenapibypass.HiddenApiBypass
|
||||
|
||||
@HiltAndroidApp
|
||||
class AuroraApplication : Application() {
|
||||
class AuroraApplication : Application(), Configuration.Provider {
|
||||
|
||||
@Inject
|
||||
lateinit var workerFactory: HiltWorkerFactory
|
||||
|
||||
@Inject
|
||||
lateinit var downloadWorkerUtil: DownloadWorkerUtil
|
||||
|
||||
override val workManagerConfiguration: Configuration
|
||||
get() = Configuration.Builder()
|
||||
.setMinimumLoggingLevel(android.util.Log.INFO)
|
||||
.setWorkerFactory(workerFactory)
|
||||
.build()
|
||||
|
||||
private lateinit var fetch: Fetch
|
||||
|
||||
companion object{
|
||||
val enqueuedDownloads = mutableSetOf<App>()
|
||||
companion object {
|
||||
val enqueuedInstalls: MutableSet<String> = mutableSetOf()
|
||||
}
|
||||
|
||||
@@ -61,7 +74,7 @@ class AuroraApplication : Application() {
|
||||
fetch = DownloadManager.with(this).fetch
|
||||
|
||||
// Initialize DownloadWorker to observe and trigger downloads
|
||||
DownloadWorker.initDownloadWorker(applicationContext)
|
||||
downloadWorkerUtil.init()
|
||||
|
||||
//Register broadcast receiver for package install/uninstall
|
||||
ContextCompat.registerReceiver(
|
||||
|
||||
@@ -72,7 +72,6 @@ import com.aurora.store.view.ui.sheets.SelfUpdateSheet
|
||||
import com.aurora.store.viewmodel.MainViewModel
|
||||
import com.google.android.material.bottomnavigation.BottomNavigationView
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@AndroidEntryPoint
|
||||
@@ -96,7 +95,6 @@ class MainActivity : AppCompatActivity() {
|
||||
R.id.updatesFragment
|
||||
)
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
applyThemeAccent()
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
@@ -59,9 +59,9 @@ object RequestBuilder {
|
||||
fun buildRequest(context: Context, app: App, file: File): Request {
|
||||
val fileName = when (file.type) {
|
||||
File.FileType.BASE,
|
||||
File.FileType.SPLIT -> PathUtil.getApkDownloadFile(context, app, file)
|
||||
File.FileType.SPLIT -> PathUtil.getApkDownloadFile(context, app.packageName, app.versionCode, file)
|
||||
File.FileType.OBB,
|
||||
File.FileType.PATCH -> PathUtil.getObbDownloadFile(app, file)
|
||||
File.FileType.PATCH -> PathUtil.getObbDownloadFile(app.packageName, file)
|
||||
}
|
||||
return Request(file.url, fileName).apply {
|
||||
attachMetaData(context, app)
|
||||
|
||||
@@ -6,5 +6,9 @@ enum class DownloadStatus {
|
||||
CANCELLED,
|
||||
COMPLETED,
|
||||
QUEUED,
|
||||
UNAVAILABLE
|
||||
UNAVAILABLE;
|
||||
|
||||
companion object {
|
||||
val finished = listOf(FAILED, CANCELLED, COMPLETED)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.aurora.store.data.room
|
||||
|
||||
import androidx.room.Database
|
||||
import androidx.room.RoomDatabase
|
||||
import com.aurora.store.data.room.download.Download
|
||||
import com.aurora.store.data.room.download.DownloadDao
|
||||
|
||||
@Database(entities = [Download::class], version = 1, exportSchema = false)
|
||||
abstract class AuroraDatabase : RoomDatabase() {
|
||||
abstract fun downloadDao(): DownloadDao
|
||||
}
|
||||
31
app/src/main/java/com/aurora/store/data/room/RoomModule.kt
Normal file
31
app/src/main/java/com/aurora/store/data/room/RoomModule.kt
Normal file
@@ -0,0 +1,31 @@
|
||||
package com.aurora.store.data.room
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
import com.aurora.store.data.room.download.DownloadDao
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object RoomModule {
|
||||
|
||||
private const val DATABASE = "aurora_database"
|
||||
|
||||
@Singleton
|
||||
@Provides
|
||||
fun providesRoomInstance(@ApplicationContext context: Context): AuroraDatabase {
|
||||
return Room.databaseBuilder(context, AuroraDatabase::class.java, DATABASE)
|
||||
.fallbackToDestructiveMigration()
|
||||
.build()
|
||||
}
|
||||
|
||||
@Provides
|
||||
fun providesDownloadDao(auroraDatabase: AuroraDatabase): DownloadDao {
|
||||
return auroraDatabase.downloadDao()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.aurora.store.data.room.download
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import com.aurora.gplayapi.data.models.App
|
||||
import com.aurora.store.data.model.DownloadStatus
|
||||
|
||||
@Entity(tableName = "download")
|
||||
data class Download(
|
||||
@PrimaryKey val packageName: String,
|
||||
val versionCode: Int,
|
||||
val offerType: Int,
|
||||
val isInstalled: Boolean,
|
||||
val displayName: String,
|
||||
val iconURL: String,
|
||||
val size: Long,
|
||||
val id: Int,
|
||||
var status: DownloadStatus,
|
||||
var progress: Int,
|
||||
var speed: Long,
|
||||
var timeRemaining: Long,
|
||||
var totalFiles: Int,
|
||||
var downloadedFiles: Int
|
||||
) {
|
||||
val isFinished get() = status in DownloadStatus.finished
|
||||
|
||||
companion object {
|
||||
fun fromApp(app: App): Download {
|
||||
return Download(
|
||||
app.packageName,
|
||||
app.versionCode,
|
||||
app.offerType,
|
||||
app.isInstalled,
|
||||
app.displayName,
|
||||
app.iconArtwork.url,
|
||||
app.size,
|
||||
app.id,
|
||||
DownloadStatus.QUEUED,
|
||||
0,
|
||||
0L,
|
||||
0L,
|
||||
0,
|
||||
0
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.aurora.store.data.room.download
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Update
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface DownloadDao {
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insert(download: Download)
|
||||
|
||||
@Update
|
||||
suspend fun update(download: Download)
|
||||
|
||||
@Query("SELECT * FROM download")
|
||||
fun downloads(): Flow<List<Download>>
|
||||
|
||||
@Query("SELECT * FROM download WHERE packageName = :packageName")
|
||||
suspend fun getDownload(packageName: String): Download?
|
||||
|
||||
@Query("DELETE FROM download WHERE packageName = :packageName")
|
||||
suspend fun delete(packageName: String)
|
||||
}
|
||||
@@ -6,147 +6,54 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
|
||||
import android.util.Log
|
||||
import androidx.hilt.work.HiltWorker
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.Data
|
||||
import androidx.work.ExistingWorkPolicy.KEEP
|
||||
import androidx.work.ForegroundInfo
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.OutOfQuotaPolicy
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import com.aurora.Constants
|
||||
import com.aurora.extensions.copyTo
|
||||
import com.aurora.extensions.isQAndAbove
|
||||
import com.aurora.gplayapi.data.models.App
|
||||
import com.aurora.gplayapi.helpers.PurchaseHelper
|
||||
import com.aurora.store.AuroraApplication
|
||||
import com.aurora.store.data.model.DownloadInfo
|
||||
import com.aurora.store.data.model.DownloadStatus
|
||||
import com.aurora.store.data.model.Request
|
||||
import com.aurora.store.data.network.HttpClient
|
||||
import com.aurora.store.data.providers.AuthProvider
|
||||
import com.aurora.store.data.receiver.InstallReceiver
|
||||
import com.aurora.store.data.room.download.Download
|
||||
import com.aurora.store.data.room.download.DownloadDao
|
||||
import com.aurora.store.util.DownloadWorkerUtil
|
||||
import com.aurora.store.util.NotificationUtil
|
||||
import com.aurora.store.util.PathUtil
|
||||
import com.google.gson.Gson
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.net.URL
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlin.io.path.ExperimentalPathApi
|
||||
import kotlin.io.path.createDirectories
|
||||
import kotlin.io.path.deleteRecursively
|
||||
import kotlin.properties.Delegates
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import kotlin.time.toJavaDuration
|
||||
import com.aurora.gplayapi.data.models.File as GPlayFile
|
||||
|
||||
class DownloadWorker(private val appContext: Context, workerParams: WorkerParameters) :
|
||||
CoroutineWorker(appContext, workerParams) {
|
||||
@HiltWorker
|
||||
class DownloadWorker @AssistedInject constructor(
|
||||
private val downloadDao: DownloadDao,
|
||||
@Assisted private val appContext: Context,
|
||||
@Assisted workerParams: WorkerParameters
|
||||
) : CoroutineWorker(appContext, workerParams) {
|
||||
|
||||
companion object {
|
||||
const val DOWNLOAD_WORKER = "DOWNLOAD_WORKER"
|
||||
|
||||
const val DOWNLOAD_PROGRESS = "DOWNLOAD_PROGRESS"
|
||||
const val DOWNLOAD_TIME = "DOWNLOAD_TIME"
|
||||
const val DOWNLOAD_SPEED = "DOWNLOAD_SPEED"
|
||||
|
||||
private const val DOWNLOAD_APP = "DOWNLOAD_APP"
|
||||
private const val DOWNLOAD_UPDATE = "DOWNLOAD_UPDATE"
|
||||
|
||||
private const val notificationID = 200
|
||||
private val cleanupStates = listOf(
|
||||
WorkInfo.State.CANCELLED,
|
||||
WorkInfo.State.FAILED
|
||||
)
|
||||
|
||||
fun initDownloadWorker(applicationContext: Context) {
|
||||
GlobalScope.launch {
|
||||
WorkManager.getInstance(applicationContext)
|
||||
.getWorkInfosByTagFlow(DOWNLOAD_WORKER)
|
||||
.collectLatest { downloadsList ->
|
||||
try {
|
||||
if (downloadsList.all { it.state.isFinished }) {
|
||||
// Do cleanup for last download if required
|
||||
downloadsList.getOrNull(0)?.let { workInfo ->
|
||||
if (workInfo.state in cleanupStates) {
|
||||
onFailure(
|
||||
applicationContext,
|
||||
AuroraApplication.enqueuedDownloads.first()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Check and trigger download if enqueue list is not empty
|
||||
if (AuroraApplication.enqueuedDownloads.isNotEmpty()) {
|
||||
val app = AuroraApplication.enqueuedDownloads.first()
|
||||
Log.i(DOWNLOAD_WORKER, "Downloading ${app.packageName}")
|
||||
downloadApp(applicationContext, app)
|
||||
}
|
||||
}
|
||||
} catch (exception: Exception) {
|
||||
Log.i(DOWNLOAD_WORKER, "Failed to download enqueued apps", exception)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
fun isEnqueued(packageName: String): Boolean {
|
||||
return AuroraApplication.enqueuedDownloads.any { it.packageName == packageName }
|
||||
}
|
||||
|
||||
fun enqueueApp(context: Context, app: App) {
|
||||
if (AuroraApplication.enqueuedDownloads.isEmpty()) downloadApp(context, app)
|
||||
AuroraApplication.enqueuedDownloads.add(app)
|
||||
}
|
||||
|
||||
fun cancelDownload(context: Context, app: App) {
|
||||
WorkManager.getInstance(context).cancelAllWorkByTag(app.packageName)
|
||||
}
|
||||
|
||||
fun cancelAll(context: Context, downloads: Boolean = true, updates: Boolean = true) {
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
|
||||
if (downloads) workManager.cancelAllWorkByTag(DOWNLOAD_APP)
|
||||
if (updates) workManager.cancelAllWorkByTag(DOWNLOAD_UPDATE)
|
||||
}
|
||||
|
||||
private fun downloadApp(context: Context, app: App) {
|
||||
val work = OneTimeWorkRequestBuilder<DownloadWorker>()
|
||||
.addTag(DOWNLOAD_WORKER)
|
||||
.addTag(app.packageName)
|
||||
.addTag(app.versionCode.toString())
|
||||
.addTag(if (app.isInstalled) DOWNLOAD_UPDATE else DOWNLOAD_APP)
|
||||
.setExpedited(OutOfQuotaPolicy.DROP_WORK_REQUEST)
|
||||
.keepResultsForAtLeast(7.days.toJavaDuration())
|
||||
.build()
|
||||
|
||||
// Ensure all app downloads are unique to preserve individual records
|
||||
WorkManager.getInstance(context)
|
||||
.enqueueUniqueWork("${DOWNLOAD_WORKER}/${app.packageName}", KEEP, work)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalPathApi::class)
|
||||
private fun onFailure(context: Context, app: App) {
|
||||
Log.i(DOWNLOAD_WORKER, "Cleaning up!")
|
||||
PathUtil.getAppDownloadDir(context, app.packageName, app.versionCode)
|
||||
.deleteRecursively()
|
||||
with (context.getSystemService(Service.NOTIFICATION_SERVICE) as NotificationManager) {
|
||||
cancel(notificationID)
|
||||
}
|
||||
AuroraApplication.enqueuedDownloads.remove(app)
|
||||
}
|
||||
}
|
||||
|
||||
private lateinit var app: App
|
||||
private lateinit var download: Download
|
||||
private lateinit var notificationManager: NotificationManager
|
||||
private var downloading = false
|
||||
|
||||
private val NOTIFICATION_ID = 200
|
||||
|
||||
private var totalBytes by Delegates.notNull<Long>()
|
||||
private var totalProgress = 0
|
||||
private var downloadedBytes = 0L
|
||||
@@ -154,6 +61,18 @@ class DownloadWorker(private val appContext: Context, workerParams: WorkerParame
|
||||
private val TAG = DownloadWorker::class.java.simpleName
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
// Try to parse input data into a valid app
|
||||
try {
|
||||
val downloadData = inputData.getString(DownloadWorkerUtil.DOWNLOAD_DATA)
|
||||
download = Gson().fromJson(downloadData, Download::class.java)
|
||||
} catch (exception: Exception) {
|
||||
Log.e(TAG, "Failed to parse download data", exception)
|
||||
return Result.failure()
|
||||
}
|
||||
|
||||
// Set work/service to foreground on < Android 12.0
|
||||
setForeground(getForegroundInfo())
|
||||
|
||||
// Purchase the app (free apps needs to be purchased too)
|
||||
val authData = AuthProvider.with(appContext).getAuthData()
|
||||
val purchaseHelper = PurchaseHelper(authData)
|
||||
@@ -162,46 +81,40 @@ class DownloadWorker(private val appContext: Context, workerParams: WorkerParame
|
||||
notificationManager =
|
||||
appContext.getSystemService(Service.NOTIFICATION_SERVICE) as NotificationManager
|
||||
|
||||
// Try to parse input data into a valid app
|
||||
try {
|
||||
app = AuroraApplication.enqueuedDownloads.first()
|
||||
} catch (exception: Exception) {
|
||||
Log.e(TAG, "No apps enqueued for downloads", exception)
|
||||
return Result.failure()
|
||||
}
|
||||
|
||||
// Set work/service to foreground on < Android 12.0
|
||||
setForeground(getForegroundInfo())
|
||||
|
||||
// Bail out if file list is empty
|
||||
val files = purchaseHelper.purchase(app.packageName, app.versionCode, app.offerType)
|
||||
val files =
|
||||
purchaseHelper.purchase(download.packageName, download.versionCode, download.offerType)
|
||||
if (files.isEmpty()) {
|
||||
Log.i(TAG, "Nothing to download!")
|
||||
notifyStatus(DownloadStatus.COMPLETED)
|
||||
return Result.success()
|
||||
notifyStatus(DownloadStatus.FAILED)
|
||||
return Result.failure()
|
||||
}
|
||||
|
||||
// Download and verify all files exists
|
||||
totalBytes = files.sumOf { it.size }
|
||||
|
||||
PathUtil.getAppDownloadDir(appContext, app.packageName, app.versionCode).createDirectories()
|
||||
PathUtil.getAppDownloadDir(appContext, download.packageName, download.versionCode)
|
||||
.createDirectories()
|
||||
if (files.any { it.type == GPlayFile.FileType.OBB || it.type == GPlayFile.FileType.PATCH }) {
|
||||
PathUtil.getObbDownloadDir(app.packageName).createDirectories()
|
||||
PathUtil.getObbDownloadDir(download.packageName).createDirectories()
|
||||
}
|
||||
|
||||
val requestList = getDownloadRequest(files)
|
||||
download.totalFiles = requestList.size
|
||||
requestList.forEach { request ->
|
||||
downloading = true
|
||||
runCatching { downloadFile(request) }
|
||||
runCatching { downloadFile(request); download.downloadedFiles++ }
|
||||
.onSuccess { downloading = false }
|
||||
.onFailure {
|
||||
Log.e(TAG, "Failed to download ${app.packageName}", it)
|
||||
Log.e(TAG, "Failed to download ${download.packageName}", it)
|
||||
downloading = false
|
||||
onFailure()
|
||||
return Result.failure()
|
||||
}
|
||||
while (downloading) {
|
||||
delay(1000)
|
||||
if (isStopped) {
|
||||
onFailure()
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -211,30 +124,44 @@ class DownloadWorker(private val appContext: Context, workerParams: WorkerParame
|
||||
|
||||
// Mark download as completed
|
||||
notifyStatus(DownloadStatus.COMPLETED)
|
||||
Log.i(TAG, "Finished downloading ${app.packageName}")
|
||||
Log.i(TAG, "Finished downloading ${download.packageName}")
|
||||
|
||||
// Notify for installation
|
||||
Intent(appContext, InstallReceiver::class.java).also {
|
||||
it.action = InstallReceiver.ACTION_INSTALL_APP
|
||||
it.putExtra(Constants.STRING_APP, app.packageName)
|
||||
it.putExtra(Constants.STRING_VERSION, app.versionCode)
|
||||
it.putExtra(Constants.STRING_APP, download.packageName)
|
||||
it.putExtra(Constants.STRING_VERSION, download.versionCode)
|
||||
appContext.sendBroadcast(it)
|
||||
}
|
||||
|
||||
// Remove the app from the list
|
||||
AuroraApplication.enqueuedDownloads.remove(app)
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalPathApi::class)
|
||||
private suspend fun onFailure() {
|
||||
withContext(NonCancellable) {
|
||||
Log.i(TAG, "Cleaning up!")
|
||||
notifyStatus(DownloadStatus.FAILED)
|
||||
PathUtil.getAppDownloadDir(appContext, download.packageName, download.versionCode)
|
||||
.deleteRecursively()
|
||||
with(appContext.getSystemService(Service.NOTIFICATION_SERVICE) as NotificationManager) {
|
||||
cancel(NOTIFICATION_ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getDownloadRequest(files: List<GPlayFile>): List<Request> {
|
||||
val downloadList = mutableListOf<Request>()
|
||||
files.filter { it.url.isNotBlank() }.forEach {
|
||||
val filePath = when (it.type) {
|
||||
GPlayFile.FileType.BASE,
|
||||
GPlayFile.FileType.SPLIT -> PathUtil.getApkDownloadFile(appContext, app, it)
|
||||
GPlayFile.FileType.BASE, GPlayFile.FileType.SPLIT -> {
|
||||
PathUtil.getApkDownloadFile(
|
||||
appContext, download.packageName, download.versionCode, it
|
||||
)
|
||||
}
|
||||
|
||||
GPlayFile.FileType.OBB,
|
||||
GPlayFile.FileType.PATCH -> PathUtil.getObbDownloadFile(app, it)
|
||||
GPlayFile.FileType.OBB, GPlayFile.FileType.PATCH -> {
|
||||
PathUtil.getObbDownloadFile(download.packageName, it)
|
||||
}
|
||||
}
|
||||
downloadList.add(Request(it.url, filePath, it.size))
|
||||
}
|
||||
@@ -281,37 +208,38 @@ class DownloadWorker(private val appContext: Context, workerParams: WorkerParame
|
||||
downloadedBytes += downloadInfo.bytesCopied
|
||||
}
|
||||
|
||||
val data = Data.Builder()
|
||||
.putInt(DOWNLOAD_PROGRESS, progress)
|
||||
.putLong(DOWNLOAD_SPEED, downloadInfo.speed)
|
||||
.putLong(DOWNLOAD_TIME, bytesRemaining / speed * 1000)
|
||||
.build()
|
||||
download.apply {
|
||||
this.status = DownloadStatus.DOWNLOADING
|
||||
this.progress = progress
|
||||
this.speed = downloadInfo.speed
|
||||
this.timeRemaining = bytesRemaining / speed * 1000
|
||||
}
|
||||
downloadDao.update(download)
|
||||
|
||||
setProgress(data)
|
||||
notifyStatus(DownloadStatus.DOWNLOADING, progress, notificationID)
|
||||
notifyStatus(DownloadStatus.DOWNLOADING, NOTIFICATION_ID)
|
||||
totalProgress = progress
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getForegroundInfo(): ForegroundInfo {
|
||||
val notification = NotificationUtil.getDownloadNotification(
|
||||
appContext,
|
||||
app,
|
||||
DownloadStatus.QUEUED,
|
||||
0,
|
||||
id
|
||||
)
|
||||
val notification = NotificationUtil.getDownloadNotification(appContext, download, id)
|
||||
return if (isQAndAbove()) {
|
||||
ForegroundInfo(notificationID, notification, FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
||||
ForegroundInfo(NOTIFICATION_ID, notification, FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
||||
} else {
|
||||
ForegroundInfo(notificationID, notification)
|
||||
ForegroundInfo(NOTIFICATION_ID, notification)
|
||||
}
|
||||
}
|
||||
|
||||
private fun notifyStatus(status: DownloadStatus, progress: Int = 100, dID: Int = -1) {
|
||||
val notification =
|
||||
NotificationUtil.getDownloadNotification(appContext, app, status, progress, id)
|
||||
notificationManager.notify(if (dID != -1) dID else app.packageName.hashCode(), notification)
|
||||
private suspend fun notifyStatus(status: DownloadStatus, dID: Int = -1) {
|
||||
// Update database for all status except downloading which is handled onProgress
|
||||
if (status != DownloadStatus.DOWNLOADING) {
|
||||
download.status = status
|
||||
downloadDao.update(download)
|
||||
}
|
||||
|
||||
val notification = NotificationUtil.getDownloadNotification(appContext, download, id)
|
||||
val notificationID = if (dID != -1) dID else download.packageName.hashCode()
|
||||
notificationManager.notify(notificationID, notification)
|
||||
}
|
||||
}
|
||||
|
||||
103
app/src/main/java/com/aurora/store/util/DownloadWorkerUtil.kt
Normal file
103
app/src/main/java/com/aurora/store/util/DownloadWorkerUtil.kt
Normal file
@@ -0,0 +1,103 @@
|
||||
package com.aurora.store.util
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.work.Data
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.OutOfQuotaPolicy
|
||||
import androidx.work.WorkManager
|
||||
import com.aurora.gplayapi.data.models.App
|
||||
import com.aurora.store.data.model.DownloadStatus
|
||||
import com.aurora.store.data.room.download.Download
|
||||
import com.aurora.store.data.room.download.DownloadDao
|
||||
import com.aurora.store.data.work.DownloadWorker
|
||||
import com.google.gson.Gson
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
class DownloadWorkerUtil @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val downloadDao: DownloadDao,
|
||||
private val gson: Gson
|
||||
) {
|
||||
|
||||
companion object {
|
||||
const val DOWNLOAD_WORKER = "DOWNLOAD_WORKER"
|
||||
const val DOWNLOAD_DATA = "DOWNLOAD_DATA"
|
||||
|
||||
private const val DOWNLOAD_APP = "DOWNLOAD_APP"
|
||||
private const val DOWNLOAD_UPDATE = "DOWNLOAD_UPDATE"
|
||||
private const val PACKAGE_NAME = "PACKAGE_NAME"
|
||||
private const val VERSION_CODE = "VERSION_CODE"
|
||||
}
|
||||
|
||||
val downloadsList = downloadDao.downloads()
|
||||
.stateIn(GlobalScope, SharingStarted.WhileSubscribed(), emptyList())
|
||||
|
||||
fun init() {
|
||||
// Run cleanup for last finished download and drop it database
|
||||
GlobalScope.launch {
|
||||
downloadDao.downloads()
|
||||
.collectLatest { list ->
|
||||
// Check and trigger next download in queue, if any
|
||||
if (!list.any { it.status == DownloadStatus.DOWNLOADING }) {
|
||||
val enqueuedDownloads = list.filter { it.status == DownloadStatus.QUEUED }
|
||||
enqueuedDownloads.firstOrNull()?.let {
|
||||
try {
|
||||
Log.i(DOWNLOAD_WORKER, "Downloading ${it.packageName}")
|
||||
trigger(it)
|
||||
} catch (exception: Exception) {
|
||||
Log.i(DOWNLOAD_WORKER, "Failed to download app", exception)
|
||||
downloadDao.delete(it.packageName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun enqueueApp(app: App) {
|
||||
downloadDao.insert(Download.fromApp(app))
|
||||
}
|
||||
|
||||
fun cancelDownload(packageName: String) {
|
||||
WorkManager.getInstance(context).cancelAllWorkByTag("$PACKAGE_NAME:$packageName")
|
||||
}
|
||||
|
||||
fun cancelAll(context: Context, downloads: Boolean = true, updates: Boolean = true) {
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
|
||||
if (downloads) workManager.cancelAllWorkByTag(DOWNLOAD_APP)
|
||||
if (updates) workManager.cancelAllWorkByTag(DOWNLOAD_UPDATE)
|
||||
}
|
||||
|
||||
private fun trigger(download: Download) {
|
||||
val inputData = Data.Builder()
|
||||
.putString(DOWNLOAD_DATA, gson.toJson(download))
|
||||
.build()
|
||||
|
||||
val work = OneTimeWorkRequestBuilder<DownloadWorker>()
|
||||
.addTag(DOWNLOAD_WORKER)
|
||||
.addTag("$PACKAGE_NAME:${download.packageName}")
|
||||
.addTag("$VERSION_CODE:${download.versionCode}")
|
||||
.addTag(if (download.isInstalled) DOWNLOAD_UPDATE else DOWNLOAD_APP)
|
||||
.setExpedited(OutOfQuotaPolicy.DROP_WORK_REQUEST)
|
||||
.setInputData(inputData)
|
||||
.build()
|
||||
|
||||
// Ensure all app downloads are unique to preserve individual records
|
||||
WorkManager.getInstance(context)
|
||||
.enqueueUniqueWork(
|
||||
"${DOWNLOAD_WORKER}/${download.packageName}",
|
||||
ExistingWorkPolicy.KEEP, work
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import com.aurora.extensions.getStyledAttributeColor
|
||||
import com.aurora.extensions.isMAndAbove
|
||||
import com.aurora.gplayapi.data.models.App
|
||||
import com.aurora.store.MainActivity
|
||||
import com.aurora.store.data.room.download.Download as AuroraDownload
|
||||
import com.aurora.store.R
|
||||
import com.aurora.store.data.activity.InstallActivity
|
||||
import com.aurora.store.data.model.DownloadStatus
|
||||
@@ -180,7 +181,7 @@ object NotificationUtil {
|
||||
|
||||
Status.COMPLETED -> if (fetchGroup.groupDownloadProgress == 100) {
|
||||
builder.setAutoCancel(true)
|
||||
builder.setContentIntent(getContentIntentForDetails(context, app))
|
||||
builder.setContentIntent(getContentIntentForDetails(context, app.packageName))
|
||||
builder.setStyle(progressBigText)
|
||||
}
|
||||
|
||||
@@ -200,17 +201,15 @@ object NotificationUtil {
|
||||
|
||||
fun getDownloadNotification(
|
||||
context: Context,
|
||||
app: App,
|
||||
status: DownloadStatus,
|
||||
progress: Int,
|
||||
download: AuroraDownload,
|
||||
workID: UUID
|
||||
): Notification {
|
||||
val builder = NotificationCompat.Builder(context, Constants.NOTIFICATION_CHANNEL_GENERAL)
|
||||
builder.setContentTitle(app.displayName)
|
||||
builder.setContentTitle(download.displayName)
|
||||
builder.color = ContextCompat.getColor(context, R.color.colorAccent)
|
||||
builder.setContentIntent(getContentIntentForDownloads(context))
|
||||
|
||||
when (status) {
|
||||
when (download.status) {
|
||||
DownloadStatus.CANCELLED -> {
|
||||
builder.setSmallIcon(R.drawable.ic_download_cancel)
|
||||
builder.setContentText(context.getString(R.string.download_canceled))
|
||||
@@ -225,17 +224,17 @@ object NotificationUtil {
|
||||
builder.setCategory(Notification.CATEGORY_ERROR)
|
||||
}
|
||||
|
||||
DownloadStatus.COMPLETED -> if (progress == 100) {
|
||||
DownloadStatus.COMPLETED -> if (download.progress == 100) {
|
||||
builder.setSmallIcon(android.R.drawable.stat_sys_download_done)
|
||||
builder.setContentText(context.getString(R.string.download_completed))
|
||||
builder.setAutoCancel(true)
|
||||
builder.setCategory(Notification.CATEGORY_STATUS)
|
||||
builder.setContentIntent(getContentIntentForDetails(context, app))
|
||||
builder.setContentIntent(getContentIntentForDetails(context, download.packageName))
|
||||
builder.addAction(
|
||||
NotificationCompat.Action.Builder(
|
||||
R.drawable.ic_install,
|
||||
context.getString(R.string.action_install),
|
||||
getInstallIntent(context, app.packageName, app.versionCode)
|
||||
getInstallIntent(context, download.packageName, download.versionCode)
|
||||
).build()
|
||||
)
|
||||
}
|
||||
@@ -243,15 +242,20 @@ object NotificationUtil {
|
||||
DownloadStatus.DOWNLOADING, DownloadStatus.QUEUED -> {
|
||||
builder.setSmallIcon(android.R.drawable.stat_sys_download)
|
||||
builder.setContentText(
|
||||
if (progress == 0) {
|
||||
if (download.progress == 0) {
|
||||
context.getString(R.string.download_queued)
|
||||
} else {
|
||||
context.getString(R.string.alt_download_progress)
|
||||
context.getString(
|
||||
R.string.download_progress,
|
||||
download.downloadedFiles,
|
||||
download.totalFiles,
|
||||
CommonUtil.humanReadableByteSpeed(download.speed, true)
|
||||
)
|
||||
}
|
||||
)
|
||||
builder.setOngoing(true)
|
||||
builder.setCategory(Notification.CATEGORY_PROGRESS)
|
||||
builder.setProgress(100, progress, progress == 0)
|
||||
builder.setProgress(100, download.progress, download.progress == 0)
|
||||
builder.foregroundServiceBehavior = NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE
|
||||
builder.addAction(
|
||||
NotificationCompat.Action.Builder(
|
||||
@@ -274,7 +278,7 @@ object NotificationUtil {
|
||||
setSmallIcon(R.drawable.ic_install)
|
||||
setContentTitle(app.displayName)
|
||||
setContentText(content)
|
||||
setContentIntent(getContentIntentForDetails(context, app))
|
||||
setContentIntent(getContentIntentForDetails(context, app.packageName))
|
||||
setSubText(app.packageName)
|
||||
}
|
||||
return builder.build()
|
||||
@@ -307,12 +311,12 @@ object NotificationUtil {
|
||||
return PendingIntent.getBroadcast(context, groupId, intent, flags)
|
||||
}
|
||||
|
||||
private fun getContentIntentForDetails(context: Context, app: App?): PendingIntent {
|
||||
private fun getContentIntentForDetails(context: Context, packageName: String): PendingIntent {
|
||||
return NavDeepLinkBuilder(context)
|
||||
.setGraph(R.navigation.mobile_navigation)
|
||||
.setDestination(R.id.appDetailsFragment)
|
||||
.setComponentName(MainActivity::class.java)
|
||||
.setArguments(bundleOf("packageName" to app!!.packageName))
|
||||
.setArguments(bundleOf("packageName" to packageName))
|
||||
.createPendingIntent()
|
||||
}
|
||||
|
||||
|
||||
@@ -58,8 +58,13 @@ object PathUtil {
|
||||
return Path(getPackageDirectory(context, packageName), versionCode.toString())
|
||||
}
|
||||
|
||||
fun getApkDownloadFile(context: Context, app: App, file: File): String {
|
||||
return getVersionDirectory(context, app.packageName, app.versionCode) + "/${file.name}"
|
||||
fun getApkDownloadFile(
|
||||
context: Context,
|
||||
packageName: String,
|
||||
versionCode: Int,
|
||||
file: File
|
||||
): String {
|
||||
return getVersionDirectory(context, packageName, versionCode) + "/${file.name}"
|
||||
}
|
||||
|
||||
fun getApkDownloadFile(context: Context, packageName: String, versionCode: Int): String {
|
||||
@@ -84,9 +89,9 @@ object PathUtil {
|
||||
return "${getExternalPath(context)}/Exports/"
|
||||
}
|
||||
|
||||
private fun getObbDownloadPath(app: App): String {
|
||||
private fun getObbDownloadPath(packageName: String): String {
|
||||
return Environment.getExternalStorageDirectory()
|
||||
.toString() + "/Android/obb/" + app.packageName
|
||||
.toString() + "/Android/obb/" + packageName
|
||||
}
|
||||
|
||||
fun getObbDownloadDir(packageName: String): Path {
|
||||
@@ -97,8 +102,8 @@ object PathUtil {
|
||||
)
|
||||
}
|
||||
|
||||
fun getObbDownloadFile(app: App, file: File): String {
|
||||
val obbDir = getObbDownloadPath(app)
|
||||
fun getObbDownloadFile(packageName: String, file: File): String {
|
||||
val obbDir = getObbDownloadPath(packageName)
|
||||
return "$obbDir/${file.name}"
|
||||
}
|
||||
|
||||
|
||||
@@ -41,8 +41,6 @@ import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import coil.load
|
||||
import coil.transform.RoundedCornersTransformation
|
||||
import com.airbnb.epoxy.EpoxyRecyclerView
|
||||
@@ -70,7 +68,6 @@ import com.aurora.store.data.installer.AppInstaller
|
||||
import com.aurora.store.data.installer.RootInstaller
|
||||
import com.aurora.store.data.model.DownloadStatus
|
||||
import com.aurora.store.data.providers.AuthProvider
|
||||
import com.aurora.store.data.work.DownloadWorker
|
||||
import com.aurora.store.databinding.FragmentDetailsBinding
|
||||
import com.aurora.store.databinding.LayoutDetailsBetaBinding
|
||||
import com.aurora.store.databinding.LayoutDetailsDescriptionBinding
|
||||
@@ -101,6 +98,8 @@ import org.greenrobot.eventbus.Subscribe
|
||||
import org.greenrobot.eventbus.ThreadMode
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlin.io.path.pathString
|
||||
|
||||
@AndroidEntryPoint
|
||||
@@ -119,7 +118,7 @@ class AppDetailsFragment : BaseFragment(R.layout.fragment_details) {
|
||||
private val startForStorageManagerResult =
|
||||
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
||||
if (isRAndAbove() && Environment.isExternalStorageManager()) {
|
||||
DownloadWorker.enqueueApp(requireContext(), app)
|
||||
viewModel.download(app)
|
||||
} else {
|
||||
toast(R.string.permissions_denied)
|
||||
}
|
||||
@@ -127,7 +126,7 @@ class AppDetailsFragment : BaseFragment(R.layout.fragment_details) {
|
||||
private val startForPermissions =
|
||||
registerForActivityResult(ActivityResultContracts.RequestPermission()) {
|
||||
if (it) {
|
||||
DownloadWorker.enqueueApp(requireContext(), app)
|
||||
viewModel.download(app)
|
||||
} else {
|
||||
toast(R.string.permissions_denied)
|
||||
}
|
||||
@@ -239,35 +238,30 @@ class AppDetailsFragment : BaseFragment(R.layout.fragment_details) {
|
||||
|
||||
// Downloads
|
||||
binding.layoutDetailsInstall.imgCancel.setOnClickListener {
|
||||
DownloadWorker.cancelDownload(it.context, app)
|
||||
viewModel.cancelDownload(app)
|
||||
if (downloadStatus != DownloadStatus.DOWNLOADING) flip(0)
|
||||
}
|
||||
|
||||
val uniqueWorkName = "${DownloadWorker.DOWNLOAD_WORKER}/${app.packageName}"
|
||||
WorkManager.getInstance(view.context)
|
||||
.getWorkInfosForUniqueWorkLiveData(uniqueWorkName)
|
||||
.observe(viewLifecycleOwner) { workList ->
|
||||
workList.getOrNull(0)?.let {
|
||||
if (it.state.isFinished) flip(0) else flip(1)
|
||||
when (it.state) {
|
||||
WorkInfo.State.ENQUEUED,
|
||||
WorkInfo.State.RUNNING -> {
|
||||
downloadStatus = DownloadStatus.DOWNLOADING
|
||||
updateProgress(
|
||||
it.progress.getInt(DownloadWorker.DOWNLOAD_PROGRESS, 0),
|
||||
it.progress.getLong(DownloadWorker.DOWNLOAD_SPEED, -1),
|
||||
it.progress.getLong(DownloadWorker.DOWNLOAD_TIME, -1)
|
||||
)
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
viewModel.downloadsList
|
||||
.filter { list -> list.any { it.packageName == app.packageName } }
|
||||
.collectLatest { downloadsList ->
|
||||
val download = downloadsList.find { it.packageName == app.packageName }
|
||||
download?.let {
|
||||
downloadStatus = it.status
|
||||
|
||||
if (it.isFinished) flip(0) else flip(1)
|
||||
when (it.status) {
|
||||
DownloadStatus.QUEUED,
|
||||
DownloadStatus.DOWNLOADING -> {
|
||||
updateProgress(it.progress, it.speed, it.timeRemaining)
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
|
||||
WorkInfo.State.FAILED -> downloadStatus = DownloadStatus.FAILED
|
||||
WorkInfo.State.CANCELLED -> downloadStatus = DownloadStatus.CANCELLED
|
||||
WorkInfo.State.SUCCEEDED -> downloadStatus = DownloadStatus.COMPLETED
|
||||
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reviews
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
@@ -587,6 +581,7 @@ class AppDetailsFragment : BaseFragment(R.layout.fragment_details) {
|
||||
}
|
||||
|
||||
else -> {
|
||||
flip(1)
|
||||
purchase()
|
||||
}
|
||||
}
|
||||
@@ -604,7 +599,7 @@ class AppDetailsFragment : BaseFragment(R.layout.fragment_details) {
|
||||
Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION)
|
||||
)
|
||||
} else {
|
||||
DownloadWorker.enqueueApp(requireContext(), app)
|
||||
viewModel.download(app)
|
||||
}
|
||||
} else {
|
||||
if (ContextCompat.checkSelfPermission(
|
||||
@@ -612,13 +607,13 @@ class AppDetailsFragment : BaseFragment(R.layout.fragment_details) {
|
||||
Manifest.permission.WRITE_EXTERNAL_STORAGE
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
DownloadWorker.enqueueApp(requireContext(), app)
|
||||
viewModel.download(app)
|
||||
} else {
|
||||
startForPermissions.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
DownloadWorker.enqueueApp(requireContext(), app)
|
||||
viewModel.download(app)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -679,8 +674,7 @@ class AppDetailsFragment : BaseFragment(R.layout.fragment_details) {
|
||||
binding.layoutDetailsToolbar.toolbar.invalidateMenu()
|
||||
}
|
||||
} else {
|
||||
val downloading = downloadStatus == DownloadStatus.DOWNLOADING
|
||||
if (DownloadWorker.isEnqueued(app.packageName) && !downloading) {
|
||||
if (downloadStatus == DownloadStatus.QUEUED) {
|
||||
flip(1)
|
||||
} else if (app.isFree) {
|
||||
btn.setText(R.string.action_install)
|
||||
|
||||
@@ -14,15 +14,21 @@ import com.aurora.store.data.model.ExodusReport
|
||||
import com.aurora.store.data.model.Report
|
||||
import com.aurora.store.data.network.HttpClient
|
||||
import com.aurora.store.data.providers.AuthProvider
|
||||
import com.aurora.store.util.DownloadWorkerUtil
|
||||
import com.google.gson.GsonBuilder
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import org.json.JSONObject
|
||||
import java.lang.reflect.Modifier
|
||||
import javax.inject.Inject
|
||||
|
||||
class AppDetailsViewModel : ViewModel() {
|
||||
@HiltViewModel
|
||||
class AppDetailsViewModel @Inject constructor(
|
||||
private val downloadWorkerUtil: DownloadWorkerUtil
|
||||
) : ViewModel() {
|
||||
|
||||
private val TAG = AppDetailsViewModel::class.java.simpleName
|
||||
|
||||
@@ -44,6 +50,8 @@ class AppDetailsViewModel : ViewModel() {
|
||||
private val _testingProgramStatus = MutableSharedFlow<TestingProgramStatus?>()
|
||||
val testingProgramStatus = _testingProgramStatus.asSharedFlow()
|
||||
|
||||
val downloadsList get() = downloadWorkerUtil.downloadsList
|
||||
|
||||
fun fetchAppDetails(context: Context, packageName: String) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
@@ -129,6 +137,14 @@ class AppDetailsViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
fun download(app: App) {
|
||||
viewModelScope.launch { downloadWorkerUtil.enqueueApp(app) }
|
||||
}
|
||||
|
||||
fun cancelDownload(app: App) {
|
||||
viewModelScope.launch { downloadWorkerUtil.cancelDownload(app.packageName) }
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun install(context: Context, packageName: String, files: List<Any>) {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user