From b7e1a12ccb64b6ab8439fa80f369151686619d11 Mon Sep 17 00:00:00 2001 From: Ibrahim Al-Alali Date: Fri, 24 Apr 2026 08:03:13 +0200 Subject: [PATCH 1/3] added play store flavor (gplay) and foss flavor added play store in app update in the gplay flavor --- app/build.gradle.kts | 14 ++ .../update/AppUpdateControllerFactory.kt | 6 + .../update/NoOpAppUpdateController.kt | 16 ++ .../update/AppUpdateControllerFactory.kt | 6 + .../update/GPlayAppUpdateController.kt | 173 ++++++++++++++++++ .../BasicRootCheckerApplication.kt | 4 + .../iboalali/basicrootchecker/MainActivity.kt | 2 + .../basicrootchecker/analytics/Analytics.kt | 23 +++ .../basicrootchecker/ui/main/MainScreen.kt | 25 +++ .../basicrootchecker/ui/main/MainViewModel.kt | 19 ++ .../basicrootchecker/ui/main/UpdateCard.kt | 152 +++++++++++++++ .../update/AppUpdateController.kt | 16 ++ .../basicrootchecker/update/AppUpdateEvent.kt | 9 + .../util/PlayStoreListingPreviews.kt | 2 + app/src/main/res/values-ar/strings.xml | 10 + app/src/main/res/values-de/strings.xml | 10 + app/src/main/res/values/strings.xml | 10 + gradle/libs.versions.toml | 2 + 18 files changed, 499 insertions(+) create mode 100644 app/src/foss/java/com/iboalali/basicrootchecker/update/AppUpdateControllerFactory.kt create mode 100644 app/src/foss/java/com/iboalali/basicrootchecker/update/NoOpAppUpdateController.kt create mode 100644 app/src/gplay/java/com/iboalali/basicrootchecker/update/AppUpdateControllerFactory.kt create mode 100644 app/src/gplay/java/com/iboalali/basicrootchecker/update/GPlayAppUpdateController.kt create mode 100644 app/src/main/java/com/iboalali/basicrootchecker/ui/main/UpdateCard.kt create mode 100644 app/src/main/java/com/iboalali/basicrootchecker/update/AppUpdateController.kt create mode 100644 app/src/main/java/com/iboalali/basicrootchecker/update/AppUpdateEvent.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 440246ad..452c7423 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -30,6 +30,17 @@ android { } } + flavorDimensions += "distribution" + productFlavors { + create("gplay") { + dimension = "distribution" + isDefault = true + } + create("foss") { + dimension = "distribution" + } + } + buildFeatures { compose = true buildConfig = true @@ -74,4 +85,7 @@ dependencies { implementation(libs.boehrsi.devicemarketingnames) implementation(libs.telemetrydeck.sdk) implementation(libs.topjohnwu.libsu.core) + + // In-app updates (gplay flavor only) + "gplayImplementation"(libs.google.play.app.update.ktx) } diff --git a/app/src/foss/java/com/iboalali/basicrootchecker/update/AppUpdateControllerFactory.kt b/app/src/foss/java/com/iboalali/basicrootchecker/update/AppUpdateControllerFactory.kt new file mode 100644 index 00000000..13b037d5 --- /dev/null +++ b/app/src/foss/java/com/iboalali/basicrootchecker/update/AppUpdateControllerFactory.kt @@ -0,0 +1,6 @@ +package com.iboalali.basicrootchecker.update + +import android.content.Context + +@Suppress("UNUSED_PARAMETER") +fun createAppUpdateController(context: Context): AppUpdateController = NoOpAppUpdateController diff --git a/app/src/foss/java/com/iboalali/basicrootchecker/update/NoOpAppUpdateController.kt b/app/src/foss/java/com/iboalali/basicrootchecker/update/NoOpAppUpdateController.kt new file mode 100644 index 00000000..b75edcbd --- /dev/null +++ b/app/src/foss/java/com/iboalali/basicrootchecker/update/NoOpAppUpdateController.kt @@ -0,0 +1,16 @@ +package com.iboalali.basicrootchecker.update + +import androidx.activity.ComponentActivity +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +object NoOpAppUpdateController : AppUpdateController { + override val events: StateFlow = + MutableStateFlow(AppUpdateEvent.None).asStateFlow() + + override fun attach(activity: ComponentActivity) = Unit + override fun checkForUpdate() = Unit + override fun startFlexibleFlow() = Unit + override fun completeUpdate() = Unit +} diff --git a/app/src/gplay/java/com/iboalali/basicrootchecker/update/AppUpdateControllerFactory.kt b/app/src/gplay/java/com/iboalali/basicrootchecker/update/AppUpdateControllerFactory.kt new file mode 100644 index 00000000..4d30a14b --- /dev/null +++ b/app/src/gplay/java/com/iboalali/basicrootchecker/update/AppUpdateControllerFactory.kt @@ -0,0 +1,6 @@ +package com.iboalali.basicrootchecker.update + +import android.content.Context + +fun createAppUpdateController(context: Context): AppUpdateController = + GPlayAppUpdateController(context.applicationContext) diff --git a/app/src/gplay/java/com/iboalali/basicrootchecker/update/GPlayAppUpdateController.kt b/app/src/gplay/java/com/iboalali/basicrootchecker/update/GPlayAppUpdateController.kt new file mode 100644 index 00000000..ab9545cc --- /dev/null +++ b/app/src/gplay/java/com/iboalali/basicrootchecker/update/GPlayAppUpdateController.kt @@ -0,0 +1,173 @@ +package com.iboalali.basicrootchecker.update + +import android.app.Activity +import android.content.Context +import android.content.IntentSender +import android.util.Log +import androidx.activity.ComponentActivity +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.IntentSenderRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import com.google.android.play.core.appupdate.AppUpdateInfo +import com.google.android.play.core.appupdate.AppUpdateManager +import com.google.android.play.core.appupdate.AppUpdateManagerFactory +import com.google.android.play.core.appupdate.AppUpdateOptions +import com.google.android.play.core.install.InstallStateUpdatedListener +import com.google.android.play.core.install.model.AppUpdateType +import com.google.android.play.core.install.model.InstallErrorCode +import com.google.android.play.core.install.model.InstallStatus +import com.google.android.play.core.install.model.UpdateAvailability +import com.iboalali.basicrootchecker.analytics.Analytics +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +private const val TAG = "GPlayAppUpdate" +private const val STALENESS_DAYS_THRESHOLD = 1 + +class GPlayAppUpdateController(context: Context) : AppUpdateController { + + private val appUpdateManager: AppUpdateManager = AppUpdateManagerFactory.create(context) + + private val _events = MutableStateFlow(AppUpdateEvent.None) + override val events: StateFlow = _events.asStateFlow() + + private var activity: ComponentActivity? = null + private var launcher: ActivityResultLauncher? = null + private var latestUpdateInfo: AppUpdateInfo? = null + + private val installStateListener = InstallStateUpdatedListener { state -> + when (state.installStatus()) { + InstallStatus.DOWNLOADING -> { + _events.value = AppUpdateEvent.Downloading( + state.bytesDownloaded(), + state.totalBytesToDownload(), + ) + } + InstallStatus.DOWNLOADED -> { + if (_events.value !is AppUpdateEvent.Downloaded) { + Analytics.trackUpdateDownloaded() + } + _events.value = AppUpdateEvent.Downloaded + } + InstallStatus.FAILED -> { + val code = state.installErrorCode() + _events.value = AppUpdateEvent.Failed(code) + Analytics.trackUpdateFailed(formatInstallError(code)) + } + else -> Unit + } + } + + private val lifecycleObserver = object : DefaultLifecycleObserver { + override fun onStart(owner: LifecycleOwner) { + appUpdateManager.registerListener(installStateListener) + } + + override fun onResume(owner: LifecycleOwner) { + checkForUpdate() + } + + override fun onStop(owner: LifecycleOwner) { + appUpdateManager.unregisterListener(installStateListener) + } + + override fun onDestroy(owner: LifecycleOwner) { + detach() + } + } + + override fun attach(activity: ComponentActivity) { + if (this.activity === activity) return + if (this.activity != null) detach() + + this.activity = activity + launcher = activity.registerForActivityResult( + ActivityResultContracts.StartIntentSenderForResult() + ) { result -> + if (result.resultCode != Activity.RESULT_OK) { + Log.w(TAG, "Update flow cancelled or failed: resultCode=${result.resultCode}") + } + } + activity.lifecycle.addObserver(lifecycleObserver) + } + + private fun detach() { + activity?.lifecycle?.removeObserver(lifecycleObserver) + runCatching { appUpdateManager.unregisterListener(installStateListener) } + activity = null + launcher = null + latestUpdateInfo = null + } + + override fun checkForUpdate() { + appUpdateManager.appUpdateInfo + .addOnSuccessListener { info -> + latestUpdateInfo = info + val current = _events.value + if (current is AppUpdateEvent.Downloading) return@addOnSuccessListener + + if (info.installStatus() == InstallStatus.DOWNLOADED) { + _events.value = AppUpdateEvent.Downloaded + return@addOnSuccessListener + } + + val available = info.updateAvailability() == UpdateAvailability.UPDATE_AVAILABLE + val flexibleAllowed = info.isUpdateTypeAllowed(AppUpdateType.FLEXIBLE) + val stale = (info.clientVersionStalenessDays() ?: -1) >= STALENESS_DAYS_THRESHOLD + + if (available && flexibleAllowed && stale) { + if (current !is AppUpdateEvent.Available) { + Analytics.trackUpdateAvailable() + } + _events.value = AppUpdateEvent.Available + } else if (current is AppUpdateEvent.Available) { + _events.value = AppUpdateEvent.None + } + } + .addOnFailureListener { e -> + Log.w(TAG, "requestAppUpdateInfo failed", e) + } + } + + override fun startFlexibleFlow() { + val info = latestUpdateInfo ?: return + val l = launcher ?: return + try { + val started = appUpdateManager.startUpdateFlowForResult( + info, + l, + AppUpdateOptions.newBuilder(AppUpdateType.FLEXIBLE).build(), + ) + if (started) { + Analytics.trackUpdateStarted() + _events.value = AppUpdateEvent.Downloading(0, 0) + } + } catch (e: IntentSender.SendIntentException) { + Log.w(TAG, "startUpdateFlowForResult failed", e) + } + } + + override fun completeUpdate() { + appUpdateManager.completeUpdate() + } + + private fun formatInstallError(code: Int): String { + val name = when (code) { + InstallErrorCode.NO_ERROR -> "NO_ERROR" + InstallErrorCode.ERROR_UNKNOWN -> "ERROR_UNKNOWN" + InstallErrorCode.ERROR_API_NOT_AVAILABLE -> "ERROR_API_NOT_AVAILABLE" + InstallErrorCode.ERROR_INVALID_REQUEST -> "ERROR_INVALID_REQUEST" + InstallErrorCode.ERROR_INSTALL_UNAVAILABLE -> "ERROR_INSTALL_UNAVAILABLE" + InstallErrorCode.ERROR_INSTALL_NOT_ALLOWED -> "ERROR_INSTALL_NOT_ALLOWED" + InstallErrorCode.ERROR_DOWNLOAD_NOT_PRESENT -> "ERROR_DOWNLOAD_NOT_PRESENT" + InstallErrorCode.ERROR_INTERNAL_ERROR -> "ERROR_INTERNAL_ERROR" + InstallErrorCode.ERROR_PLAY_STORE_NOT_FOUND -> "ERROR_PLAY_STORE_NOT_FOUND" + InstallErrorCode.ERROR_APP_NOT_OWNED -> "ERROR_APP_NOT_OWNED" + else -> "ERROR_UNMAPPED" + } + return "$name ($code)" + } +} diff --git a/app/src/main/java/com/iboalali/basicrootchecker/BasicRootCheckerApplication.kt b/app/src/main/java/com/iboalali/basicrootchecker/BasicRootCheckerApplication.kt index aecae591..ea655d43 100644 --- a/app/src/main/java/com/iboalali/basicrootchecker/BasicRootCheckerApplication.kt +++ b/app/src/main/java/com/iboalali/basicrootchecker/BasicRootCheckerApplication.kt @@ -3,10 +3,14 @@ package com.iboalali.basicrootchecker import android.app.Application import com.iboalali.basicrootchecker.analytics.Analytics import com.iboalali.basicrootchecker.data.UserPreferences +import com.iboalali.basicrootchecker.update.AppUpdateController +import com.iboalali.basicrootchecker.update.createAppUpdateController import com.telemetrydeck.sdk.TelemetryDeck class BasicRootCheckerApplication : Application() { + val appUpdateController: AppUpdateController by lazy { createAppUpdateController(this) } + override fun onCreate() { super.onCreate() diff --git a/app/src/main/java/com/iboalali/basicrootchecker/MainActivity.kt b/app/src/main/java/com/iboalali/basicrootchecker/MainActivity.kt index 4a481e56..12526fcd 100644 --- a/app/src/main/java/com/iboalali/basicrootchecker/MainActivity.kt +++ b/app/src/main/java/com/iboalali/basicrootchecker/MainActivity.kt @@ -27,6 +27,8 @@ class MainActivity : ComponentActivity() { super.onCreate(savedInstanceState) + (application as BasicRootCheckerApplication).appUpdateController.attach(this) + // Workaround: splash screen theme doesn't properly set light status bar val isNight = (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_YES) == Configuration.UI_MODE_NIGHT_YES WindowInsetsControllerCompat(window, window.decorView).isAppearanceLightStatusBars = !isNight diff --git a/app/src/main/java/com/iboalali/basicrootchecker/analytics/Analytics.kt b/app/src/main/java/com/iboalali/basicrootchecker/analytics/Analytics.kt index dc4918fd..f34a2471 100644 --- a/app/src/main/java/com/iboalali/basicrootchecker/analytics/Analytics.kt +++ b/app/src/main/java/com/iboalali/basicrootchecker/analytics/Analytics.kt @@ -41,4 +41,27 @@ object Analytics { mapOf("result" to result), ) } + + fun trackUpdateAvailable() { + if (!enabled) return + TelemetryDeck.signal("updateAvailable") + } + + fun trackUpdateStarted() { + if (!enabled) return + TelemetryDeck.signal("updateStarted") + } + + fun trackUpdateDownloaded() { + if (!enabled) return + TelemetryDeck.signal("updateDownloaded") + } + + fun trackUpdateFailed(error: String) { + if (!enabled) return + TelemetryDeck.signal( + "updateFailed", + mapOf("error" to error), + ) + } } diff --git a/app/src/main/java/com/iboalali/basicrootchecker/ui/main/MainScreen.kt b/app/src/main/java/com/iboalali/basicrootchecker/ui/main/MainScreen.kt index 59b9fc7d..8805bd17 100644 --- a/app/src/main/java/com/iboalali/basicrootchecker/ui/main/MainScreen.kt +++ b/app/src/main/java/com/iboalali/basicrootchecker/ui/main/MainScreen.kt @@ -74,6 +74,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.iboalali.basicrootchecker.R import com.iboalali.basicrootchecker.ui.theme.BasicRootCheckerTheme +import com.iboalali.basicrootchecker.update.AppUpdateEvent import com.iboalali.basicrootchecker.util.PreviewLocales import kotlinx.coroutines.launch @@ -89,6 +90,8 @@ fun MainScreen( MainScreenContent( uiState = uiState, onCheckRoot = viewModel::checkRoot, + onUpdateRequested = viewModel::onUpdateRequested, + onInstallRequested = viewModel::onInstallRequested, onNavigateToAbout = onNavigateToAbout, onNavigateToLicence = onNavigateToLicence, onNavigateToSettings = onNavigateToSettings, @@ -100,6 +103,8 @@ fun MainScreen( fun MainScreenContent( uiState: MainUiState, onCheckRoot: () -> Unit, + onUpdateRequested: () -> Unit, + onInstallRequested: () -> Unit, onNavigateToAbout: () -> Unit, onNavigateToLicence: () -> Unit, onNavigateToSettings: () -> Unit, @@ -301,6 +306,16 @@ fun MainScreenContent( Spacer(Modifier.height(24.dp)) + if (uiState.updateStatus !is AppUpdateEvent.None) { + // Update Card (hidden when updateStatus is None) + UpdateCard( + updateStatus = uiState.updateStatus, + onUpdateClick = onUpdateRequested, + onInstallClick = onInstallRequested, + ) + Spacer(Modifier.height(24.dp)) + } + // Device Info Card OutlinedCard( modifier = Modifier @@ -418,6 +433,8 @@ private fun MainScreenNotCheckedPreview() { androidVersion = "Android 16", ), onCheckRoot = {}, + onUpdateRequested = {}, + onInstallRequested = {}, onNavigateToAbout = {}, onNavigateToLicence = {}, onNavigateToSettings = {}, @@ -437,6 +454,8 @@ private fun MainScreenCheckingPreview() { androidVersion = "Android 16", ), onCheckRoot = {}, + onUpdateRequested = {}, + onInstallRequested = {}, onNavigateToAbout = {}, onNavigateToLicence = {}, onNavigateToSettings = {}, @@ -456,6 +475,8 @@ private fun MainScreenRootedPreview() { androidVersion = "Android 16", ), onCheckRoot = {}, + onUpdateRequested = {}, + onInstallRequested = {}, onNavigateToAbout = {}, onNavigateToLicence = {}, onNavigateToSettings = {}, @@ -475,6 +496,8 @@ private fun MainScreenLocalesPreview() { androidVersion = "Android 16", ), onCheckRoot = {}, + onUpdateRequested = {}, + onInstallRequested = {}, onNavigateToAbout = {}, onNavigateToLicence = {}, onNavigateToSettings = {}, @@ -497,6 +520,8 @@ private fun MainScreenNotRootedPreview() { androidVersion = "Android 16", ), onCheckRoot = {}, + onUpdateRequested = {}, + onInstallRequested = {}, onNavigateToAbout = {}, onNavigateToLicence = {}, onNavigateToSettings = {}, diff --git a/app/src/main/java/com/iboalali/basicrootchecker/ui/main/MainViewModel.kt b/app/src/main/java/com/iboalali/basicrootchecker/ui/main/MainViewModel.kt index f763dd46..bd180806 100644 --- a/app/src/main/java/com/iboalali/basicrootchecker/ui/main/MainViewModel.kt +++ b/app/src/main/java/com/iboalali/basicrootchecker/ui/main/MainViewModel.kt @@ -4,9 +4,11 @@ import android.app.Application import android.os.Build import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope +import com.iboalali.basicrootchecker.BasicRootCheckerApplication import com.iboalali.basicrootchecker.R import com.iboalali.basicrootchecker.analytics.Analytics import com.iboalali.basicrootchecker.data.RootChecker +import com.iboalali.basicrootchecker.update.AppUpdateEvent import com.iboalali.basicrootchecker.util.DeviceInfo import de.boehrsi.devicemarketingnames.DeviceMarketingNames import kotlinx.coroutines.flow.MutableStateFlow @@ -28,15 +30,24 @@ data class MainUiState( val deviceMarketingName: String = "", val deviceModelName: String = "", val androidVersion: String = "", + val updateStatus: AppUpdateEvent = AppUpdateEvent.None, ) class MainViewModel(application: Application) : AndroidViewModel(application) { + private val appUpdateController = + (application as BasicRootCheckerApplication).appUpdateController + private val _uiState = MutableStateFlow(MainUiState()) val uiState: StateFlow = _uiState.asStateFlow() init { loadDeviceInfo() + viewModelScope.launch { + appUpdateController.events.collect { event -> + _uiState.update { it.copy(updateStatus = event) } + } + } } private fun loadDeviceInfo() { @@ -66,4 +77,12 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { Analytics.trackRootCheckResult(status.name) } } + + fun onUpdateRequested() { + appUpdateController.startFlexibleFlow() + } + + fun onInstallRequested() { + appUpdateController.completeUpdate() + } } diff --git a/app/src/main/java/com/iboalali/basicrootchecker/ui/main/UpdateCard.kt b/app/src/main/java/com/iboalali/basicrootchecker/ui/main/UpdateCard.kt new file mode 100644 index 00000000..2567688f --- /dev/null +++ b/app/src/main/java/com/iboalali/basicrootchecker/ui/main/UpdateCard.kt @@ -0,0 +1,152 @@ +package com.iboalali.basicrootchecker.ui.main + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.iboalali.basicrootchecker.R +import com.iboalali.basicrootchecker.ui.theme.BasicRootCheckerTheme +import com.iboalali.basicrootchecker.update.AppUpdateEvent +import com.iboalali.basicrootchecker.util.PreviewLocales + +@Composable +fun UpdateCard( + updateStatus: AppUpdateEvent, + onUpdateClick: () -> Unit, + onInstallClick: () -> Unit, + modifier: Modifier = Modifier, +) { + OutlinedCard( + modifier = modifier + .widthIn(max = 600.dp) + .fillMaxWidth(), + colors = CardDefaults.cardColors(), + shape = RoundedCornerShape(32.dp), + elevation = CardDefaults.elevatedCardElevation(defaultElevation = 1.dp), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + when (updateStatus) { + AppUpdateEvent.None -> Unit + + AppUpdateEvent.Available -> { + Text( + text = stringResource(R.string.update_available_title), + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = stringResource(R.string.update_available_body), + style = MaterialTheme.typography.bodyMedium, + ) + Button(modifier = Modifier.fillMaxWidth(), onClick = onUpdateClick) { + Text(stringResource(R.string.update_action_update)) + } + } + + is AppUpdateEvent.Downloading -> { + Text( + text = stringResource(R.string.update_downloading), + style = MaterialTheme.typography.titleMedium, + ) + val total = updateStatus.totalBytes + val downloaded = updateStatus.bytesDownloaded + if (total > 0L) { + val progress = (downloaded.toFloat() / total.toFloat()).coerceIn(0f, 1f) + LinearProgressIndicator( + progress = { progress }, + modifier = Modifier.fillMaxWidth(), + ) + Text( + text = stringResource( + R.string.update_progress_megabytes, + formatMegabytes(downloaded), + formatMegabytes(total), + ), + style = MaterialTheme.typography.bodySmall, + ) + } else { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } + } + + AppUpdateEvent.Downloaded -> { + Text( + text = stringResource(R.string.update_downloaded_title), + style = MaterialTheme.typography.titleMedium, + ) + Button(modifier = Modifier.fillMaxWidth(), onClick = onInstallClick) { + Text(stringResource(R.string.update_action_install)) + } + } + + is AppUpdateEvent.Failed -> { + Text( + text = stringResource(R.string.update_failed), + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + } +} + +private fun formatMegabytes(bytes: Long): String { + val mb = bytes.toDouble() / (1024.0 * 1024.0) + return "%.1f".format(mb) +} + +@PreviewLocales +@Composable +private fun UpdateCardAvailablePreview() { + BasicRootCheckerTheme { + UpdateCard( + updateStatus = AppUpdateEvent.Available, + onUpdateClick = {}, + onInstallClick = {}, + ) + } +} + +@PreviewLocales +@Composable +private fun UpdateCardDownloadingPreview() { + BasicRootCheckerTheme { + UpdateCard( + updateStatus = AppUpdateEvent.Downloading( + bytesDownloaded = 3_500_000, + totalBytes = 12_000_000, + ), + onUpdateClick = {}, + onInstallClick = {}, + ) + } +} + +@PreviewLocales +@Composable +private fun UpdateCardDownloadedPreview() { + BasicRootCheckerTheme { + UpdateCard( + updateStatus = AppUpdateEvent.Downloaded, + onUpdateClick = {}, + onInstallClick = {}, + ) + } +} diff --git a/app/src/main/java/com/iboalali/basicrootchecker/update/AppUpdateController.kt b/app/src/main/java/com/iboalali/basicrootchecker/update/AppUpdateController.kt new file mode 100644 index 00000000..33f05d9b --- /dev/null +++ b/app/src/main/java/com/iboalali/basicrootchecker/update/AppUpdateController.kt @@ -0,0 +1,16 @@ +package com.iboalali.basicrootchecker.update + +import androidx.activity.ComponentActivity +import kotlinx.coroutines.flow.StateFlow + +interface AppUpdateController { + val events: StateFlow + + fun attach(activity: ComponentActivity) + + fun checkForUpdate() + + fun startFlexibleFlow() + + fun completeUpdate() +} diff --git a/app/src/main/java/com/iboalali/basicrootchecker/update/AppUpdateEvent.kt b/app/src/main/java/com/iboalali/basicrootchecker/update/AppUpdateEvent.kt new file mode 100644 index 00000000..1ffa7fdf --- /dev/null +++ b/app/src/main/java/com/iboalali/basicrootchecker/update/AppUpdateEvent.kt @@ -0,0 +1,9 @@ +package com.iboalali.basicrootchecker.update + +sealed class AppUpdateEvent { + data object None : AppUpdateEvent() + data object Available : AppUpdateEvent() + data class Downloading(val bytesDownloaded: Long, val totalBytes: Long) : AppUpdateEvent() + data object Downloaded : AppUpdateEvent() + data class Failed(val errorCode: Int) : AppUpdateEvent() +} diff --git a/app/src/main/java/com/iboalali/basicrootchecker/util/PlayStoreListingPreviews.kt b/app/src/main/java/com/iboalali/basicrootchecker/util/PlayStoreListingPreviews.kt index c18468d0..00d6a50b 100644 --- a/app/src/main/java/com/iboalali/basicrootchecker/util/PlayStoreListingPreviews.kt +++ b/app/src/main/java/com/iboalali/basicrootchecker/util/PlayStoreListingPreviews.kt @@ -20,6 +20,8 @@ private fun MainScreenPlayStoreListing() { androidVersion = "Android 16", ), onCheckRoot = {}, + onUpdateRequested = {}, + onInstallRequested = {}, onNavigateToAbout = {}, onNavigateToLicence = {}, onNavigateToSettings = {}, diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 1af4b647..632d10a9 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -41,4 +41,14 @@ هذا التطبيق يساعدك على عرض نص كبير على الشاشة، ويجعله بأكبر حجم ممكن دون قطع النص.\n\nيمكن استخدامه في الاجتماعات للتصويت على موضوع برقم أو نص. اختر إشعارًا دائمًا لإخفائه، وسيبقى مخفيًا طالما هذا التطبيق مثبت. + + تحديث متاح + إصدار جديد من التطبيق جاهز للتنزيل. + تحديث + جاري تنزيل التحديث… + %1$s / %2$s م.ب. + تم تنزيل التحديث + التثبيت الآن + فشل التحديث. ستتم إعادة المحاولة تلقائيًا. + \ No newline at end of file diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index dcd5df65..6799c617 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -37,4 +37,14 @@ Kontaktiere mich: Weitere Apps Diese App hilft dir, großen Text auf dem Bildschirm anzuzeigen, und macht ihn so groß wie möglich, ohne den Text abzuschneiden.\n\nKann in Meetings verwendet werden, um über ein Thema mit einer Zahl oder einem Text abzustimmen. Wähle eine dauerhafte Benachrichtigung zum Ausblenden aus und halte sie versteckt, solange diese App installiert ist. + + + Update verfügbar + Eine neue Version der App steht zum Download bereit. + Aktualisieren + Update wird heruntergeladen… + %1$s / %2$s MB + Update heruntergeladen + Jetzt installieren + Update fehlgeschlagen. Es wird automatisch erneut versucht. \ 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 d274c610..51a129d0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -187,6 +187,16 @@ Android Version Content Copied + + Update available + A new version of the app is ready to download. + Update + Downloading update… + %1$s / %2$s MB + Update downloaded + Install now + Update failed. It will retry automatically. + Other Apps This app will help you to show large text on the screen, and makes it as big as possible without cutting off the text.\n\nCan be used in meetings to vote on a subject with a number or a text. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 012c99a7..813f457d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,6 +13,7 @@ androidx-navigation3 = "1.1.0" boehrsi-devicemarketingnames = "1.0.6" # https://github.com/Boehrsi/DeviceMarketingNames/releases google-material = "1.13.0" +google-play-app-update = "2.1.0" # https://developer.android.com/reference/com/google/android/play/core/release-notes-in_app_updates kotlinx-collections-immutable = "0.4.0" kotlinx-serialization-core = "1.11.0" telemetrydeck = "6.3.0" # https://github.com/TelemetryDeck/KotlinSDK/releases @@ -36,6 +37,7 @@ androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", vers boehrsi-devicemarketingnames = { module = "de.boehrsi:devicemarketingnames", version.ref = "boehrsi-devicemarketingnames" } google-material = { module = "com.google.android.material:material", version.ref = "google-material" } +google-play-app-update-ktx = { module = "com.google.android.play:app-update-ktx", version.ref = "google-play-app-update" } kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinx-collections-immutable" } kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization-core" } telemetrydeck-sdk = { module = "com.telemetrydeck:kotlin-sdk", version.ref = "telemetrydeck" } From 6d426e823577f651cc999c102afc194a0aeb6162 Mon Sep 17 00:00:00 2001 From: Ibrahim Al-Alali Date: Sat, 25 Apr 2026 13:11:59 +0200 Subject: [PATCH 2/3] update android gradle plugin to 9.2.0 --- gradle/libs.versions.toml | 2 +- gradle/wrapper/gradle-wrapper.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 813f457d..c2b03147 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] kotlin = "2.3.20" -android-gradle-plugin = "9.1.1" +android-gradle-plugin = "9.2.0" androidx-activity = "1.13.0" androidx-annotation = "1.10.0" diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index added384..c49bd709 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-all.zip From 690ace34de0cdb82828858468b9fb353fa6c3a96 Mon Sep 17 00:00:00 2001 From: Ibrahim Al-Alali Date: Sat, 25 Apr 2026 15:20:31 +0200 Subject: [PATCH 3/3] added updated snackbar message on update installed --- app/build.gradle.kts | 2 +- .../basicrootchecker/data/UserPreferences.kt | 13 ++++++++++ .../basicrootchecker/ui/main/MainScreen.kt | 25 ++++++++++++++++++- .../basicrootchecker/ui/main/MainViewModel.kt | 22 ++++++++++++++++ .../util/PlayStoreListingPreviews.kt | 1 + app/src/main/res/values-ar/strings.xml | 1 + app/src/main/res/values-de/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 8 files changed, 64 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 452c7423..049dd825 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -12,7 +12,7 @@ android { applicationId = "com.iboalali.basicrootchecker" minSdk = 23 targetSdk = 36 - versionCode = 42 + versionCode = 43 versionName = "v2.0vc$versionCode" @Suppress("UnstableApiUsage") androidResources.localeFilters += listOf("en", "ar", "de") diff --git a/app/src/main/java/com/iboalali/basicrootchecker/data/UserPreferences.kt b/app/src/main/java/com/iboalali/basicrootchecker/data/UserPreferences.kt index 6eb4bab1..f23efd48 100644 --- a/app/src/main/java/com/iboalali/basicrootchecker/data/UserPreferences.kt +++ b/app/src/main/java/com/iboalali/basicrootchecker/data/UserPreferences.kt @@ -3,6 +3,7 @@ package com.iboalali.basicrootchecker.data import android.content.Context import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.preferencesDataStore import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first @@ -27,7 +28,19 @@ class UserPreferences(private val context: Context) { fun telemetryEnabledBlocking(): Boolean = runBlocking { telemetryEnabled.first() } + val lastSeenVersionCode: Flow = + context.userSettingsDataStore.data.map { preferences -> + preferences[LAST_SEEN_VERSION_CODE] ?: 0 + } + + suspend fun setLastSeenVersionCode(code: Int) { + context.userSettingsDataStore.edit { preferences -> + preferences[LAST_SEEN_VERSION_CODE] = code + } + } + companion object { private val TELEMETRY_ENABLED = booleanPreferencesKey("telemetry_enabled") + private val LAST_SEEN_VERSION_CODE = intPreferencesKey("last_seen_version_code") } } diff --git a/app/src/main/java/com/iboalali/basicrootchecker/ui/main/MainScreen.kt b/app/src/main/java/com/iboalali/basicrootchecker/ui/main/MainScreen.kt index 8805bd17..648a4ac8 100644 --- a/app/src/main/java/com/iboalali/basicrootchecker/ui/main/MainScreen.kt +++ b/app/src/main/java/com/iboalali/basicrootchecker/ui/main/MainScreen.kt @@ -42,6 +42,7 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedCard import androidx.compose.material3.Scaffold +import androidx.compose.material3.Snackbar import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text @@ -92,6 +93,7 @@ fun MainScreen( onCheckRoot = viewModel::checkRoot, onUpdateRequested = viewModel::onUpdateRequested, onInstallRequested = viewModel::onInstallRequested, + onAppUpdatedSnackbarShown = viewModel::onAppUpdatedSnackbarShown, onNavigateToAbout = onNavigateToAbout, onNavigateToLicence = onNavigateToLicence, onNavigateToSettings = onNavigateToSettings, @@ -105,6 +107,7 @@ fun MainScreenContent( onCheckRoot: () -> Unit, onUpdateRequested: () -> Unit, onInstallRequested: () -> Unit, + onAppUpdatedSnackbarShown: () -> Unit, onNavigateToAbout: () -> Unit, onNavigateToLicence: () -> Unit, onNavigateToSettings: () -> Unit, @@ -116,6 +119,14 @@ fun MainScreenContent( val checkingText = stringResource(R.string.string_checking_for_root) val copiedText = stringResource(R.string.toast_content_copied) + val appUpdatedText = stringResource(R.string.app_updated_snackbar) + + LaunchedEffect(uiState.appUpdatedShown) { + if (uiState.appUpdatedShown) { + snackbarHostState.showSnackbar(appUpdatedText) + onAppUpdatedSnackbarShown() + } + } Scaffold( modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), @@ -180,7 +191,14 @@ fun MainScreenContent( ) } }, - snackbarHost = { SnackbarHost(snackbarHostState) }, + snackbarHost = { + SnackbarHost(snackbarHostState) { data -> + Snackbar( + snackbarData = data, + shape = RoundedCornerShape(16.dp), + ) + } + }, ) { innerPadding -> val layoutDirection = LocalLayoutDirection.current val topPadding = innerPadding.calculateTopPadding() @@ -435,6 +453,7 @@ private fun MainScreenNotCheckedPreview() { onCheckRoot = {}, onUpdateRequested = {}, onInstallRequested = {}, + onAppUpdatedSnackbarShown = {}, onNavigateToAbout = {}, onNavigateToLicence = {}, onNavigateToSettings = {}, @@ -456,6 +475,7 @@ private fun MainScreenCheckingPreview() { onCheckRoot = {}, onUpdateRequested = {}, onInstallRequested = {}, + onAppUpdatedSnackbarShown = {}, onNavigateToAbout = {}, onNavigateToLicence = {}, onNavigateToSettings = {}, @@ -477,6 +497,7 @@ private fun MainScreenRootedPreview() { onCheckRoot = {}, onUpdateRequested = {}, onInstallRequested = {}, + onAppUpdatedSnackbarShown = {}, onNavigateToAbout = {}, onNavigateToLicence = {}, onNavigateToSettings = {}, @@ -498,6 +519,7 @@ private fun MainScreenLocalesPreview() { onCheckRoot = {}, onUpdateRequested = {}, onInstallRequested = {}, + onAppUpdatedSnackbarShown = {}, onNavigateToAbout = {}, onNavigateToLicence = {}, onNavigateToSettings = {}, @@ -522,6 +544,7 @@ private fun MainScreenNotRootedPreview() { onCheckRoot = {}, onUpdateRequested = {}, onInstallRequested = {}, + onAppUpdatedSnackbarShown = {}, onNavigateToAbout = {}, onNavigateToLicence = {}, onNavigateToSettings = {}, diff --git a/app/src/main/java/com/iboalali/basicrootchecker/ui/main/MainViewModel.kt b/app/src/main/java/com/iboalali/basicrootchecker/ui/main/MainViewModel.kt index bd180806..4b0f2672 100644 --- a/app/src/main/java/com/iboalali/basicrootchecker/ui/main/MainViewModel.kt +++ b/app/src/main/java/com/iboalali/basicrootchecker/ui/main/MainViewModel.kt @@ -5,15 +5,18 @@ import android.os.Build import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.iboalali.basicrootchecker.BasicRootCheckerApplication +import com.iboalali.basicrootchecker.BuildConfig import com.iboalali.basicrootchecker.R import com.iboalali.basicrootchecker.analytics.Analytics import com.iboalali.basicrootchecker.data.RootChecker +import com.iboalali.basicrootchecker.data.UserPreferences import com.iboalali.basicrootchecker.update.AppUpdateEvent import com.iboalali.basicrootchecker.util.DeviceInfo import de.boehrsi.devicemarketingnames.DeviceMarketingNames import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -31,6 +34,7 @@ data class MainUiState( val deviceModelName: String = "", val androidVersion: String = "", val updateStatus: AppUpdateEvent = AppUpdateEvent.None, + val appUpdatedShown: Boolean = false, ) class MainViewModel(application: Application) : AndroidViewModel(application) { @@ -38,6 +42,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { private val appUpdateController = (application as BasicRootCheckerApplication).appUpdateController + private val userPreferences = UserPreferences(application) + private val _uiState = MutableStateFlow(MainUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -48,6 +54,22 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { _uiState.update { it.copy(updateStatus = event) } } } + viewModelScope.launch { checkForAppUpdate() } + } + + private suspend fun checkForAppUpdate() { + val stored = userPreferences.lastSeenVersionCode.first() + val current = BuildConfig.VERSION_CODE + if (stored in 1..تم تنزيل التحديث التثبيت الآن فشل التحديث. ستتم إعادة المحاولة تلقائيًا. + تم تحديث التطبيق \ No newline at end of file diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 6799c617..38293aa5 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -47,4 +47,5 @@ Kontaktiere mich: Update heruntergeladen Jetzt installieren Update fehlgeschlagen. Es wird automatisch erneut versucht. + App wurde aktualisiert \ 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 51a129d0..969ddd45 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -196,6 +196,7 @@ Update downloaded Install now Update failed. It will retry automatically. + App was updated Other Apps