From b17b1c53957950bc219376c7875b73f51fb0be1b Mon Sep 17 00:00:00 2001 From: Michael Date: Sat, 24 Jan 2026 02:20:12 +0300 Subject: [PATCH] feat: batch check updates --- .../mi6e4ka/openstore/data/api/ApiService.kt | 14 ++ .../openstore/data/model/AppUpdates.kt | 31 ++++ .../openstore/data/model/InstalledApp.kt | 14 -- .../openstore/data/model/ItemDetails.kt | 12 ++ .../data/repository/SearchRepository.kt | 14 ++ .../ui/screen/updates/UpdatesScreen.kt | 59 ++----- .../ui/screen/updates/UpdatesViewModel.kt | 152 ++++++++---------- 7 files changed, 152 insertions(+), 144 deletions(-) create mode 100644 app/src/main/java/dev/mi6e4ka/openstore/data/model/AppUpdates.kt delete mode 100644 app/src/main/java/dev/mi6e4ka/openstore/data/model/InstalledApp.kt diff --git a/app/src/main/java/dev/mi6e4ka/openstore/data/api/ApiService.kt b/app/src/main/java/dev/mi6e4ka/openstore/data/api/ApiService.kt index a33dad0..e361fef 100644 --- a/app/src/main/java/dev/mi6e4ka/openstore/data/api/ApiService.kt +++ b/app/src/main/java/dev/mi6e4ka/openstore/data/api/ApiService.kt @@ -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 } \ No newline at end of file diff --git a/app/src/main/java/dev/mi6e4ka/openstore/data/model/AppUpdates.kt b/app/src/main/java/dev/mi6e4ka/openstore/data/model/AppUpdates.kt new file mode 100644 index 0000000..299fc2d --- /dev/null +++ b/app/src/main/java/dev/mi6e4ka/openstore/data/model/AppUpdates.kt @@ -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 +) + +data class AppUpdateResponse( + @SerializedName("body") val body: AppUpdateResponseBody +) + +data class AppUpdateResponseBody( + @SerializedName("content") val content: List +) + +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 +) \ No newline at end of file diff --git a/app/src/main/java/dev/mi6e4ka/openstore/data/model/InstalledApp.kt b/app/src/main/java/dev/mi6e4ka/openstore/data/model/InstalledApp.kt deleted file mode 100644 index 3b678a1..0000000 --- a/app/src/main/java/dev/mi6e4ka/openstore/data/model/InstalledApp.kt +++ /dev/null @@ -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 -) diff --git a/app/src/main/java/dev/mi6e4ka/openstore/data/model/ItemDetails.kt b/app/src/main/java/dev/mi6e4ka/openstore/data/model/ItemDetails.kt index cd94019..9edd7b2 100644 --- a/app/src/main/java/dev/mi6e4ka/openstore/data/model/ItemDetails.kt +++ b/app/src/main/java/dev/mi6e4ka/openstore/data/model/ItemDetails.kt @@ -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, +) + data class Files( @SerializedName("fileUrl") val fileUrl: String, @SerializedName("ordinal") val ordinal: Int, diff --git a/app/src/main/java/dev/mi6e4ka/openstore/data/repository/SearchRepository.kt b/app/src/main/java/dev/mi6e4ka/openstore/data/repository/SearchRepository.kt index 507ba7c..34a863f 100644 --- a/app/src/main/java/dev/mi6e4ka/openstore/data/repository/SearchRepository.kt +++ b/app/src/main/java/dev/mi6e4ka/openstore/data/repository/SearchRepository.kt @@ -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 { return apiService.getAppComments(packageName, pageSize = pageSize, sortBy = sortBy).body.content } + + suspend fun getBatchUpdates(apps: List): List { + return apiService.getBatchUpdates(AppUpdateRequest(content = apps)).body.content + } + + suspend fun getBatchItemDetails(packageNames: List) : List { + return apiService.getBatchItemDetails(request = BatchItemDetailsRequest(packageNames = packageNames)) + } } \ No newline at end of file diff --git a/app/src/main/java/dev/mi6e4ka/openstore/ui/screen/updates/UpdatesScreen.kt b/app/src/main/java/dev/mi6e4ka/openstore/ui/screen/updates/UpdatesScreen.kt index 7f45e50..8200117 100644 --- a/app/src/main/java/dev/mi6e4ka/openstore/ui/screen/updates/UpdatesScreen.kt +++ b/app/src/main/java/dev/mi6e4ka/openstore/ui/screen/updates/UpdatesScreen.kt @@ -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, diff --git a/app/src/main/java/dev/mi6e4ka/openstore/ui/screen/updates/UpdatesViewModel.kt b/app/src/main/java/dev/mi6e4ka/openstore/ui/screen/updates/UpdatesViewModel.kt index 80005cb..927e46b 100644 --- a/app/src/main/java/dev/mi6e4ka/openstore/ui/screen/updates/UpdatesViewModel.kt +++ b/app/src/main/java/dev/mi6e4ka/openstore/ui/screen/updates/UpdatesViewModel.kt @@ -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 = emptyList(), - val appsWithUpdates: List = emptyList(), + val installedApps: List = emptyList(), + val appsWithUpdates: List = emptyList(), val error: String? = null, - val checkedCount: Int = 0, - val totalToCheck: Int = 0, val downloadingPackages: Set = emptySet(), val downloadProgress: Map = 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) { - _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) { + _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) { + private fun installApp(context: Context, packageName: String, apkFiles: List) { 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 { + private fun getInstalledApps(context: Context): List { 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" } }