feat: batch check updates

This commit is contained in:
Michael 2026-01-24 02:20:12 +03:00 committed by mi6e4ka
parent 59f6d5935b
commit b17b1c5395
7 changed files with 152 additions and 144 deletions

View file

@ -2,10 +2,14 @@ package dev.mi6e4ka.openstore.data.api
import dev.mi6e4ka.openstore.data.model.AppCommentsResponse
import dev.mi6e4ka.openstore.data.model.AppRatingResponse
import dev.mi6e4ka.openstore.data.model.AppUpdateRequest
import dev.mi6e4ka.openstore.data.model.AppUpdateResponse
import dev.mi6e4ka.openstore.data.model.BatchItemDetailsRequest
import dev.mi6e4ka.openstore.data.model.DownloadLinkRequest
import dev.mi6e4ka.openstore.data.model.DownloadLinkResponse
import dev.mi6e4ka.openstore.data.model.ItemDetailsResponse
import dev.mi6e4ka.openstore.data.model.SearchResponse
import dev.mi6e4ka.openstore.data.model.ShortItemDetails
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Header
@ -45,4 +49,14 @@ interface ApiService {
@Query("pageSize") pageSize: Int,
@Query("sortBy") sortBy: String,
) : AppCommentsResponse
@POST("applicationData/newApps")
suspend fun getBatchUpdates(
@Body request: AppUpdateRequest
) : AppUpdateResponse
@POST("v2/showcase/store-app")
suspend fun getBatchItemDetails(
@Body request: BatchItemDetailsRequest
) : List<ShortItemDetails>
}

View file

@ -0,0 +1,31 @@
package dev.mi6e4ka.openstore.data.model
import com.google.gson.annotations.SerializedName
data class AppUpdateRequestEntry(
@SerializedName("packageName") val packageName: String,
@Transient val appName: String,
@SerializedName("versionCode") val versionCode: Long,
@Transient val versionName: String,
@Transient val installSource: String
)
data class AppUpdateRequest(
@SerializedName("content") val content: List<AppUpdateRequestEntry>
)
data class AppUpdateResponse(
@SerializedName("body") val body: AppUpdateResponseBody
)
data class AppUpdateResponseBody(
@SerializedName("content") val content: List<AppUpdateResponseEntry>
)
data class AppUpdateResponseEntry(
@SerializedName("appId") val appId: Int,
@SerializedName("packageName") val packageName: String,
@SerializedName("appName") val appName: String,
@SerializedName("updatedAt") val updatedAt: String,
@SerializedName("versionCode") val versionCode: Long
)

View file

@ -1,14 +0,0 @@
package dev.mi6e4ka.openstore.data.model
data class InstalledApp(
val packageName: String,
val appName: String,
val installedVersionCode: Long,
val installedVersionName: String,
val iconUrl: String? = null,
val latestVersionCode: Long? = null,
val latestVersionName: String? = null,
val hasUpdate: Boolean = false,
val appId: Int? = null,
val fileSize: Float? = null
)

View file

@ -23,6 +23,18 @@ data class ItemDetails(
@SerializedName("appVerUpdatedAt") val appVerUpdatedAt: String,
)
data class ShortItemDetails(
@SerializedName("appId") val appId: Int,
@SerializedName("appName") val appName: String,
@SerializedName("packageName") val packageName: String,
@SerializedName("iconUrl") val iconUrl: String,
@SerializedName("versionCode") val versionCode: Long,
)
data class BatchItemDetailsRequest(
@SerializedName("packageNames") val packageNames: List<String>,
)
data class Files(
@SerializedName("fileUrl") val fileUrl: String,
@SerializedName("ordinal") val ordinal: Int,

View file

@ -5,10 +5,16 @@ import androidx.paging.PagingConfig
import dev.mi6e4ka.openstore.data.api.ApiClient
import dev.mi6e4ka.openstore.data.model.AppCommentsComments
import dev.mi6e4ka.openstore.data.model.AppRatingResponseBody
import dev.mi6e4ka.openstore.data.model.AppUpdateRequest
import dev.mi6e4ka.openstore.data.model.AppUpdateRequestEntry
import dev.mi6e4ka.openstore.data.model.AppUpdateResponse
import dev.mi6e4ka.openstore.data.model.AppUpdateResponseEntry
import dev.mi6e4ka.openstore.data.model.BatchItemDetailsRequest
import dev.mi6e4ka.openstore.data.model.DownloadLinkRequest
import dev.mi6e4ka.openstore.data.model.DownloadLinkResponseItem
import dev.mi6e4ka.openstore.data.model.ItemDetails
import dev.mi6e4ka.openstore.data.model.SearchResult
import dev.mi6e4ka.openstore.data.model.ShortItemDetails
import dev.mi6e4ka.openstore.data.paging.SearchPagingSource
class SearchRepository {
@ -48,4 +54,12 @@ class SearchRepository {
suspend fun getAppComments(packageName: String, pageSize: Int, sortBy: String): List<AppCommentsComments> {
return apiService.getAppComments(packageName, pageSize = pageSize, sortBy = sortBy).body.content
}
suspend fun getBatchUpdates(apps: List<AppUpdateRequestEntry>): List<AppUpdateResponseEntry> {
return apiService.getBatchUpdates(AppUpdateRequest(content = apps)).body.content
}
suspend fun getBatchItemDetails(packageNames: List<String>) : List<ShortItemDetails> {
return apiService.getBatchItemDetails(request = BatchItemDetailsRequest(packageNames = packageNames))
}
}

View file

@ -45,7 +45,6 @@ import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import dev.mi6e4ka.openstore.R
import dev.mi6e4ka.openstore.data.model.InstalledApp
import dev.mi6e4ka.openstore.internal.installer.ApkInstaller
import dev.mi6e4ka.openstore.ui.components.AppIcon
@ -113,24 +112,8 @@ fun UpdatesScreen(navController: NavController) {
CircularProgressIndicator()
Spacer(modifier = Modifier.size(16.dp))
Text(stringResource(R.string.checking_updates))
Spacer(modifier = Modifier.size(8.dp))
Text(
"${state.checkedCount} / ${state.totalToCheck}",
color = MaterialTheme.colorScheme.secondary
)
Spacer(modifier = Modifier.size(8.dp))
LinearProgressIndicator(
progress = {
if (state.totalToCheck > 0) {
state.checkedCount.toFloat() / state.totalToCheck
} else 0f
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 32.dp)
)
}
} else if (state.appsWithUpdates.isEmpty() && state.checkedCount > 0) {
} else if (state.appsWithUpdates.isEmpty()) {
Column(
modifier = Modifier
.fillMaxSize()
@ -172,7 +155,7 @@ fun UpdatesScreen(navController: NavController) {
Spacer(modifier = Modifier.weight(1f))
}
} else if (state.appsWithUpdates.isNotEmpty()) {
} else {
LazyColumn(
modifier = Modifier
.fillMaxSize()
@ -197,7 +180,7 @@ fun UpdatesScreen(navController: NavController) {
)
}
}
item {
Text(
stringResource(R.string.available_updates, state.appsWithUpdates.size),
@ -205,7 +188,7 @@ fun UpdatesScreen(navController: NavController) {
modifier = Modifier.padding(vertical = 8.dp)
)
}
items(state.appsWithUpdates, key = { it.packageName }) { app ->
UpdateAppCard(
app = app,
@ -217,29 +200,6 @@ fun UpdatesScreen(navController: NavController) {
)
}
}
} else {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
painter = painterResource(R.drawable.ic_update_24px),
contentDescription = null,
modifier = Modifier.size(64.dp)
)
Spacer(modifier = Modifier.size(16.dp))
Text(
stringResource(R.string.check_updates_hint),
fontSize = 16.sp
)
Spacer(modifier = Modifier.size(16.dp))
Button(onClick = { viewModel.checkForUpdates(context) }) {
Text(stringResource(R.string.check_updates))
}
}
}
}
}
@ -247,7 +207,7 @@ fun UpdatesScreen(navController: NavController) {
@Composable
fun UpdateAppCard(
app: InstalledApp,
app: AppWithUpdates,
isDownloading: Boolean,
downloadProgress: Int,
onUpdateClick: () -> Unit,
@ -301,7 +261,14 @@ fun UpdateAppCard(
overflow = TextOverflow.Ellipsis
)
Text(
text = "${app.installedVersionName}${app.latestVersionName}",
text = "${app.installedVersionCode}${app.latestVersionCode}",
fontSize = 12.sp,
color = MaterialTheme.colorScheme.secondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = "from ${app.installSource}",
fontSize = 12.sp,
color = MaterialTheme.colorScheme.secondary,
maxLines = 1,

View file

@ -1,36 +1,43 @@
package dev.mi6e4ka.openstore.ui.screen.updates
import android.content.Context
import android.content.pm.ApplicationInfo
import android.content.pm.PackageInfo
import android.content.pm.PackageInstaller
import android.content.pm.PackageManager
import android.os.Build
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dev.mi6e4ka.openstore.data.model.InstalledApp
import dev.mi6e4ka.openstore.data.model.AppUpdateRequestEntry
import dev.mi6e4ka.openstore.di.AppModule
import dev.mi6e4ka.openstore.internal.installer.ApkInstaller
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import java.util.concurrent.atomic.AtomicInteger
import java.io.File
data class AppWithUpdates (
val packageName: String,
val appName: String,
val installedVersionCode: Long,
val installedVersionName: String,
val iconUrl: String? = null,
val latestVersionCode: Long? = null,
val latestVersionName: String? = null,
val appId: Int? = null,
val installSource: String
)
data class UpdatesState(
val isLoading: Boolean = false,
val isCheckingUpdates: Boolean = false,
val installedApps: List<InstalledApp> = emptyList(),
val appsWithUpdates: List<InstalledApp> = emptyList(),
val installedApps: List<AppUpdateRequestEntry> = emptyList(),
val appsWithUpdates: List<AppWithUpdates> = emptyList(),
val error: String? = null,
val checkedCount: Int = 0,
val totalToCheck: Int = 0,
val downloadingPackages: Set<String> = emptySet(),
val downloadProgress: Map<String, Int> = emptyMap(),
val excludeGooglePlay: Boolean = true
@ -86,21 +93,7 @@ class UpdatesViewModel(private val apkInstaller: ApkInstaller) : ViewModel() {
_state.update { it.copy(installedApps = installedApps, isLoading = false) }
// Сразу запускаем проверку
checkForUpdatesInternal(context, installedApps)
}
}
fun loadInstalledApps(context: Context) {
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
try {
val installedApps = withContext(Dispatchers.IO) {
getInstalledApps(context)
}
_state.update { it.copy(installedApps = installedApps, isLoading = false) }
} catch (e: Exception) {
_state.update { it.copy(error = e.message, isLoading = false) }
}
checkForUpdatesInternal(installedApps)
}
}
@ -109,31 +102,46 @@ class UpdatesViewModel(private val apkInstaller: ApkInstaller) : ViewModel() {
val installedApps = _state.value.installedApps.ifEmpty {
withContext(Dispatchers.IO) { getInstalledApps(context) }
}
checkForUpdatesInternal(context, installedApps)
checkForUpdatesInternal(installedApps)
}
}
private suspend fun checkForUpdatesInternal(context: Context, installedApps: List<InstalledApp>) {
_state.update { it.copy(isCheckingUpdates = true, checkedCount = 0, appsWithUpdates = emptyList(), totalToCheck = installedApps.size) }
val semaphore = Semaphore(5) // Уменьшил до 5 параллельных запросов
val checkedCounter = AtomicInteger(0)
val results = withContext(Dispatchers.IO) {
installedApps.map { app ->
async {
semaphore.withPermit {
val result = fetchDetailsWithRetry(app, maxRetries = 3)
val count = checkedCounter.incrementAndGet()
_state.update { it.copy(checkedCount = count) }
result
}
}
}.awaitAll()
private suspend fun checkForUpdatesInternal(installedApps: List<AppUpdateRequestEntry>) {
_state.update { it.copy(isCheckingUpdates = true, appsWithUpdates = emptyList()) }
val rawUpdates = AppModule.searchRepository.getBatchUpdates(installedApps)
if (rawUpdates.isEmpty()) {
_state.update {
it.copy(
isCheckingUpdates = false,
appsWithUpdates = emptyList()
)
}
return
}
val appsWithUpdates = results.filterNotNull()
val detailsUpdates = AppModule.searchRepository.getBatchItemDetails(rawUpdates.map {it.packageName} )
val detailsByPackageName = detailsUpdates.associateBy {it.packageName}
val installedAppsByPackageName = installedApps.associateBy {it.packageName}
val appsWithUpdates = rawUpdates.mapNotNull {
val details = detailsByPackageName[it.packageName] ?: return@mapNotNull null
val installedApp = installedAppsByPackageName[it.packageName] ?: return@mapNotNull null
AppWithUpdates(
packageName = it.packageName,
appName = it.appName,
installedVersionCode = installedApp.versionCode,
installedVersionName = installedApp.versionName,
iconUrl = details.iconUrl,
latestVersionCode = details.versionCode,
latestVersionName = "?",
appId = it.appId,
installSource = installedApp.installSource
)
}
_state.update {
it.copy(
isCheckingUpdates = false,
@ -141,32 +149,8 @@ class UpdatesViewModel(private val apkInstaller: ApkInstaller) : ViewModel() {
)
}
}
private suspend fun fetchDetailsWithRetry(app: InstalledApp, maxRetries: Int): InstalledApp? {
repeat(maxRetries) { attempt ->
try {
val details = AppModule.searchRepository.getItemDetails(app.packageName, "mobile")
if (details.versionCode > app.installedVersionCode) {
return app.copy(
iconUrl = details.iconUrl,
latestVersionCode = details.versionCode,
latestVersionName = details.versionName,
hasUpdate = true,
appId = details.appId,
fileSize = details.fileSize
)
}
return null // Приложение найдено, но обновления нет
} catch (e: Exception) {
if (attempt < maxRetries - 1) {
delay(1000L * (attempt + 1)) // Увеличил задержку: 1с, 2с, 3с
}
}
}
return null
}
fun downloadAndUpdateApp(context: Context, app: InstalledApp) {
fun downloadAndUpdateApp(context: Context, app: AppWithUpdates) {
if (app.appId == null) return
viewModelScope.launch {
@ -226,9 +210,9 @@ class UpdatesViewModel(private val apkInstaller: ApkInstaller) : ViewModel() {
}
}
private fun installApp(context: Context, packageName: String, apkFiles: List<java.io.File>) {
private fun installApp(context: Context, packageName: String, apkFiles: List<File>) {
viewModelScope.launch {
val installerCallback = object : android.content.pm.PackageInstaller.SessionCallback() {
val installerCallback = object : PackageInstaller.SessionCallback() {
override fun onCreated(sessionId: Int) {}
override fun onBadgingChanged(sessionId: Int) {}
override fun onActiveChanged(sessionId: Int, active: Boolean) {}
@ -265,7 +249,7 @@ class UpdatesViewModel(private val apkInstaller: ApkInstaller) : ViewModel() {
cancelDownloads.remove(packageName)
}
private fun getInstalledApps(context: Context): List<InstalledApp> {
private fun getInstalledApps(context: Context): List<AppUpdateRequestEntry> {
val pm = context.packageManager
val packages = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
pm.getInstalledPackages(PackageManager.PackageInfoFlags.of(0))
@ -277,7 +261,7 @@ class UpdatesViewModel(private val apkInstaller: ApkInstaller) : ViewModel() {
val excludeGooglePlay = _state.value.excludeGooglePlay
return packages
.filter { !isSystemApp(it) && (!excludeGooglePlay || !isFromGooglePlay(pm, it.packageName)) }
.filter { !isSystemApp(it) && !(excludeGooglePlay && getInstallSource(pm, it.packageName) == "com.android.vending") }
.map { packageInfo ->
val versionCode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
packageInfo.longVersionCode
@ -285,22 +269,22 @@ class UpdatesViewModel(private val apkInstaller: ApkInstaller) : ViewModel() {
@Suppress("DEPRECATION")
packageInfo.versionCode.toLong()
}
InstalledApp(
AppUpdateRequestEntry(
packageName = packageInfo.packageName,
appName = pm.getApplicationLabel(packageInfo.applicationInfo!!).toString(),
installedVersionCode = versionCode,
installedVersionName = packageInfo.versionName ?: "Unknown"
versionCode = versionCode,
versionName = packageInfo.versionName ?: "Unknown",
installSource = getInstallSource(pm, packageInfo.packageName)
)
}
.sortedBy { it.appName.lowercase() }
}
private fun isSystemApp(packageInfo: PackageInfo): Boolean {
return (packageInfo.applicationInfo?.flags?.and(android.content.pm.ApplicationInfo.FLAG_SYSTEM) ?: 0) != 0
return (packageInfo.applicationInfo?.flags?.and(ApplicationInfo.FLAG_SYSTEM) ?: 0) != 0
}
private fun isFromGooglePlay(pm: PackageManager, packageName: String): Boolean {
private fun getInstallSource(pm: PackageManager, packageName: String): String {
val installer = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
try {
pm.getInstallSourceInfo(packageName).installingPackageName
@ -311,6 +295,6 @@ class UpdatesViewModel(private val apkInstaller: ApkInstaller) : ViewModel() {
@Suppress("DEPRECATION")
pm.getInstallerPackageName(packageName)
}
return installer == "com.android.vending"
return installer ?: "null"
}
}