diff --git a/app/src/main/java/dev/mi6e4ka/openstore/MainActivity.kt b/app/src/main/java/dev/mi6e4ka/openstore/MainActivity.kt index e1d0719..e5f49a4 100644 --- a/app/src/main/java/dev/mi6e4ka/openstore/MainActivity.kt +++ b/app/src/main/java/dev/mi6e4ka/openstore/MainActivity.kt @@ -29,6 +29,7 @@ import dev.mi6e4ka.openstore.ui.screen.results.ResultsScreen import dev.mi6e4ka.openstore.ui.screen.reviews.ReviewsScreen import dev.mi6e4ka.openstore.ui.screen.search.SearchScreen import dev.mi6e4ka.openstore.ui.screen.settings.SettingsScreen +import dev.mi6e4ka.openstore.ui.screen.updates.UpdatesScreen import dev.mi6e4ka.openstore.ui.theme.AppTheme class MainActivity : ComponentActivity() { @@ -116,6 +117,9 @@ class MainActivity : ComponentActivity() { val packageName = it.arguments?.getString("packageName") ?: "" ReviewsScreen(packageName = packageName, navController = navController) } + composable("updates") { + UpdatesScreen(navController = navController) + } composable("settings") { SettingsScreen(navController = navController) } 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/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/search/SearchScreen.kt b/app/src/main/java/dev/mi6e4ka/openstore/ui/screen/search/SearchScreen.kt index 58381ad..47f64f5 100644 --- a/app/src/main/java/dev/mi6e4ka/openstore/ui/screen/search/SearchScreen.kt +++ b/app/src/main/java/dev/mi6e4ka/openstore/ui/screen/search/SearchScreen.kt @@ -88,6 +88,12 @@ fun SearchScreen(navController: NavController = rememberNavController()) { topBar = { TopAppBar( title = {Text(stringResource(R.string.app_name))}, actions = { + IconButton(onClick = { navController.navigate("updates") }) { + Icon( + painter = painterResource(R.drawable.ic_update_24px), + contentDescription = stringResource(R.string.updates_title) + ) + } IconButton({navController.navigate("settings")}) { Icon(painterResource(R.drawable.ic_settings_24px), "settings") } diff --git a/app/src/main/java/dev/mi6e4ka/openstore/ui/screen/settings/SettingsScreen.kt b/app/src/main/java/dev/mi6e4ka/openstore/ui/screen/settings/SettingsScreen.kt index b919936..bfe4803 100644 --- a/app/src/main/java/dev/mi6e4ka/openstore/ui/screen/settings/SettingsScreen.kt +++ b/app/src/main/java/dev/mi6e4ka/openstore/ui/screen/settings/SettingsScreen.kt @@ -105,6 +105,10 @@ fun SettingsScreen( SettingsSectionSelect( title = "Google Play", value = "com.android.vending" + ), + SettingsSectionSelect( + title = "Manual Installed", + value = "com.android.packageinstaller" ) ) ), 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 new file mode 100644 index 0000000..9150b6d --- /dev/null +++ b/app/src/main/java/dev/mi6e4ka/openstore/ui/screen/updates/UpdatesScreen.kt @@ -0,0 +1,311 @@ +package dev.mi6e4ka.openstore.ui.screen.updates + +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.provider.Settings +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +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.internal.installer.ApkInstaller +import dev.mi6e4ka.openstore.ui.components.AppIcon + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun UpdatesScreen(navController: NavController) { + val context = LocalContext.current + val apkInstaller = remember { ApkInstaller(context) } + val viewModel: UpdatesViewModel = viewModel( + factory = UpdatesViewModelFactory(apkInstaller) + ) + val state by viewModel.state.collectAsState() + + LaunchedEffect(Unit) { + viewModel.initPreferences(context) + viewModel.loadAndCheckUpdates(context) + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.updates_title)) }, + navigationIcon = { + IconButton(onClick = { navController.popBackStack() }) { + Icon( + painter = painterResource(R.drawable.ic_arrow_back_24px), + contentDescription = "Back" + ) + } + }, + actions = { + IconButton( + onClick = { viewModel.checkForUpdates(context) }, + enabled = !state.isCheckingUpdates && !state.isLoading + ) { + Icon( + painter = painterResource(R.drawable.ic_refresh_24px), + contentDescription = stringResource(R.string.check_updates) + ) + } + } + ) + } + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + ) { + if (state.isLoading) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } else if (state.isCheckingUpdates) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + CircularProgressIndicator() + Spacer(modifier = Modifier.size(16.dp)) + Text(stringResource(R.string.checking_updates)) + } + } else if (state.appsWithUpdates.isEmpty()) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + UpdatesBanner(navController, state.installedApps.size) + Spacer(modifier = Modifier.weight(1f)) + + Icon( + painter = painterResource(R.drawable.ic_check_circle_24px), + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.size(16.dp)) + Text( + stringResource(R.string.all_apps_updated), + fontWeight = FontWeight.Bold, + fontSize = 18.sp + ) + + Spacer(modifier = Modifier.weight(1f)) + } + } else { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + item { + UpdatesBanner(navController, state.installedApps.size) + } + + item { + Text( + stringResource(R.string.available_updates, state.appsWithUpdates.size), + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(vertical = 8.dp) + ) + } + + items(state.appsWithUpdates, key = { it.packageName }) { app -> + UpdateAppCard( + app = app, + isDownloading = state.downloadingPackages.contains(app.packageName), + downloadProgress = state.downloadProgress[app.packageName] ?: 0, + onUpdateClick = { viewModel.downloadAndUpdateApp(context, app) }, + onCancelClick = { viewModel.cancelDownload(app.packageName) }, + onCardClick = { navController.navigate("app/${app.packageName}?platform=mobile") } + ) + } + } + } + } + } +} + +@Composable +fun UpdatesBanner(navController: NavController, checkAppsCount: Int) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(0.dp) + .height(80.dp) + .clip(RoundedCornerShape(12.dp)) + .clickable(onClick = { + navController.navigate("settings") + }), + //colors = CardDefaults.cardColors(containerColor = Color(109, 255, 30, 40)) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxHeight() + .fillMaxWidth() + .padding(horizontal = 24.dp) + ) { + Icon( + painterResource(R.drawable.ic_settings_24px), + contentDescription = null, + modifier = Modifier.size(32.dp), + ) + Column { + Text(stringResource(R.string.updates_app_count, checkAppsCount)) + Text(stringResource(R.string.updates_change_settings)) + } + } + } +} + +@Composable +fun UpdateAppCard( + app: AppWithUpdates, + isDownloading: Boolean, + downloadProgress: Int, + onUpdateClick: () -> Unit, + onCancelClick: () -> Unit, + onCardClick: () -> Unit +) { + Card( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable(onClick = onCardClick) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + if (app.iconUrl != null) { + AppIcon( + app.iconUrl, + modifier = Modifier + .size(56.dp) + .clip(RoundedCornerShape(12.dp)) + ) + } else { + Box( + modifier = Modifier + .size(56.dp) + .clip(RoundedCornerShape(12.dp)), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(R.drawable.ic_android_24px), + contentDescription = null, + modifier = Modifier.size(32.dp) + ) + } + } + + Column( + modifier = Modifier + .weight(1f) + .padding(horizontal = 12.dp) + ) { + Text( + text = app.appName, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + 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, + overflow = TextOverflow.Ellipsis + ) + if (isDownloading) { + Spacer(modifier = Modifier.size(4.dp)) + LinearProgressIndicator( + progress = { downloadProgress / 100f }, + modifier = Modifier.fillMaxWidth() + ) + } + } + + if (isDownloading) { + FilledTonalIconButton(onClick = onCancelClick) { + Icon( + painter = painterResource(R.drawable.ic_close_24px), + contentDescription = null + ) + } + } else { + FilledTonalButton(onClick = onUpdateClick) { + Text(stringResource(R.string.update)) + } + } + } + } +} 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 new file mode 100644 index 0000000..f089f4c --- /dev/null +++ b/app/src/main/java/dev/mi6e4ka/openstore/ui/screen/updates/UpdatesViewModel.kt @@ -0,0 +1,293 @@ +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.AppUpdateRequestEntry +import dev.mi6e4ka.openstore.di.AppModule +import dev.mi6e4ka.openstore.internal.installer.ApkInstaller +import kotlinx.coroutines.Dispatchers +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.withContext +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 error: String? = null, + val downloadingPackages: Set = emptySet(), + val downloadProgress: Map = emptyMap(), + val excludeGooglePlay: Boolean = true +) + +class UpdatesViewModel(private val apkInstaller: ApkInstaller) : ViewModel() { + private val _state = MutableStateFlow(UpdatesState()) + val state: StateFlow = _state.asStateFlow() + + private val cancelDownloads = mutableMapOf Unit>() + + companion object { + private const val PREFS_NAME = "updates_prefs" + private const val KEY_EXCLUDE_GOOGLE_PLAY = "exclude_google_play" + } + + fun initPreferences(context: Context) { + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + val excludeGooglePlay = prefs.getBoolean(KEY_EXCLUDE_GOOGLE_PLAY, true) + _state.update { it.copy(excludeGooglePlay = excludeGooglePlay) } + } + + fun toggleExcludeGooglePlay(context: Context) { + val newValue = !_state.value.excludeGooglePlay + _state.update { it.copy(excludeGooglePlay = newValue) } + + // Сохраняем в SharedPreferences + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .edit() + .putBoolean(KEY_EXCLUDE_GOOGLE_PLAY, newValue) + .apply() + + loadAndCheckUpdates(context) + } + + fun loadAndCheckUpdates(context: Context) { + viewModelScope.launch { + // Загружаем список приложений + _state.update { it.copy(isLoading = true, error = null) } + val installedApps = try { + withContext(Dispatchers.IO) { + getInstalledApps(context) + } + } catch (e: Exception) { + _state.update { it.copy(error = e.message, isLoading = false) } + return@launch + } + _state.update { it.copy(installedApps = installedApps, isLoading = false) } + + // Сразу запускаем проверку + checkForUpdatesInternal(installedApps) + } + } + + fun checkForUpdates(context: Context) { + loadAndCheckUpdates(context) + } + + 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 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, + appsWithUpdates = appsWithUpdates + ) + } + } + + fun downloadAndUpdateApp(context: Context, app: AppWithUpdates) { + if (app.appId == null) return + + viewModelScope.launch { + _state.update { + it.copy( + downloadingPackages = it.downloadingPackages + app.packageName, + downloadProgress = it.downloadProgress + (app.packageName to 0) + ) + } + + try { + val appFiles = withContext(Dispatchers.IO) { + AppModule.searchRepository.getAppFiles( + app.appId, + firstInstall = false, + supportedAbis = Build.SUPPORTED_ABIS.toList(), + withoutSplits = false, + deviceType = "mobile" + ) + } + + cancelDownloads[app.packageName] = apkInstaller.downloadFilesWithProgress( + appFiles, + onProgress = { percent, _, _ -> + _state.update { + it.copy(downloadProgress = it.downloadProgress + (app.packageName to percent)) + } + }, + onSuccess = { apkFiles -> + installApp(context, app.packageName, apkFiles) + }, + onError = { _ -> + _state.update { + it.copy( + downloadingPackages = it.downloadingPackages - app.packageName, + downloadProgress = it.downloadProgress - app.packageName + ) + } + }, + onCancel = { + _state.update { + it.copy( + downloadingPackages = it.downloadingPackages - app.packageName, + downloadProgress = it.downloadProgress - app.packageName + ) + } + } + ) + } catch (e: Exception) { + _state.update { + it.copy( + downloadingPackages = it.downloadingPackages - app.packageName, + downloadProgress = it.downloadProgress - app.packageName + ) + } + } + } + } + + private fun installApp(context: Context, packageName: String, apkFiles: List) { + viewModelScope.launch { + val installerCallback = object : PackageInstaller.SessionCallback() { + override fun onCreated(sessionId: Int) {} + override fun onBadgingChanged(sessionId: Int) {} + override fun onActiveChanged(sessionId: Int, active: Boolean) {} + override fun onProgressChanged(sessionId: Int, progress: Float) {} + override fun onFinished(sessionId: Int, success: Boolean) { + _state.update { + it.copy( + downloadingPackages = it.downloadingPackages - packageName, + downloadProgress = it.downloadProgress - packageName, + appsWithUpdates = if (success) { + it.appsWithUpdates.filter { app -> app.packageName != packageName } + } else it.appsWithUpdates + ) + } + if (success) { + apkInstaller.cleanApkFiles(apkFiles.map { it.name }) + } + } + } + + apkInstaller.installSplitApk(context, apkFiles, installerCallback, onError = { _ -> + _state.update { + it.copy( + downloadingPackages = it.downloadingPackages - packageName, + downloadProgress = it.downloadProgress - packageName + ) + } + }) + } + } + + fun cancelDownload(packageName: String) { + cancelDownloads[packageName]?.invoke() + cancelDownloads.remove(packageName) + } + + 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)) + } else { + @Suppress("DEPRECATION") + pm.getInstalledPackages(0) + } + + val prefs = context.getSharedPreferences("settings", Context.MODE_PRIVATE) + + val allowedSources = prefs.getString("updates_sources", "")?.split(";") ?: listOf() + val allowAllSources = prefs.getBoolean("updates_check_all", false) + + return packages + .filter { !isSystemApp(it) && (allowAllSources || allowedSources.contains(getInstallSource(pm, it.packageName))) } + .map { packageInfo -> + val versionCode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + packageInfo.longVersionCode + } else { + @Suppress("DEPRECATION") + packageInfo.versionCode.toLong() + } + println("package "+packageInfo.packageName+" from "+getInstallSource(pm, packageInfo.packageName)) + AppUpdateRequestEntry( + packageName = packageInfo.packageName, + appName = pm.getApplicationLabel(packageInfo.applicationInfo!!).toString(), + versionCode = versionCode, + versionName = packageInfo.versionName ?: "Unknown", + installSource = getInstallSource(pm, packageInfo.packageName) + ) + } + } + + private fun isSystemApp(packageInfo: PackageInfo): Boolean { + return (packageInfo.applicationInfo?.flags?.and(ApplicationInfo.FLAG_SYSTEM) ?: 0) != 0 + } + + private fun getInstallSource(pm: PackageManager, packageName: String): String { + val installer = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + try { + pm.getInstallSourceInfo(packageName).installingPackageName + } catch (e: Exception) { + null + } + } else { + @Suppress("DEPRECATION") + pm.getInstallerPackageName(packageName) + } + return installer ?: "null" + } +} diff --git a/app/src/main/java/dev/mi6e4ka/openstore/ui/screen/updates/UpdatesViewModelFactory.kt b/app/src/main/java/dev/mi6e4ka/openstore/ui/screen/updates/UpdatesViewModelFactory.kt new file mode 100644 index 0000000..e716802 --- /dev/null +++ b/app/src/main/java/dev/mi6e4ka/openstore/ui/screen/updates/UpdatesViewModelFactory.kt @@ -0,0 +1,18 @@ +package dev.mi6e4ka.openstore.ui.screen.updates + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import dev.mi6e4ka.openstore.internal.installer.ApkInstaller + +class UpdatesViewModelFactory( + private val apkInstaller: ApkInstaller +) : ViewModelProvider.Factory { + + override fun create(modelClass: Class): T { + if (modelClass.isAssignableFrom(UpdatesViewModel::class.java)) { + @Suppress("UNCHECKED_CAST") + return UpdatesViewModel(apkInstaller) as T + } + throw IllegalArgumentException("Unknown ViewModel class") + } +} diff --git a/app/src/main/res/drawable/ic_check_circle_24px.xml b/app/src/main/res/drawable/ic_check_circle_24px.xml new file mode 100644 index 0000000..219c474 --- /dev/null +++ b/app/src/main/res/drawable/ic_check_circle_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_refresh_24px.xml b/app/src/main/res/drawable/ic_refresh_24px.xml new file mode 100644 index 0000000..f45367f --- /dev/null +++ b/app/src/main/res/drawable/ic_refresh_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index d719943..d3d47af 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -41,4 +41,13 @@ Check for updates for all non-system applications !!! RuStore gets the full list of installed applications, it is not safe Save + Updates + Check for updates + Checking for updates… + All apps are up to date + Available updates: %1$d + Tap to check for updates + Exclude Google Play + Open settings + Applications checked: %1$d \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a43e75e..f9b0d4f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,5 +1,5 @@ - OpenStore + OpenStore О безопасности приложений Ошибка: %1$s Предоставлено %1$s @@ -42,4 +42,13 @@ Проверять обновления для всех не системных приложений !!! RuStore получает полный список установленных приложений, не безопасно Сохранить + Обновления + Проверить обновления + Проверка обновлений… + Все приложения обновлены + Доступно обновлений: %1$d + Нажмите для проверки обновлений + Исключить Google Play + Открыть настройки + Проверяется приложений: %1$d \ No newline at end of file