diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 440246ad..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") @@ -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/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 59b9fc7d..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 @@ -74,6 +75,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 +91,9 @@ fun MainScreen( MainScreenContent( uiState = uiState, onCheckRoot = viewModel::checkRoot, + onUpdateRequested = viewModel::onUpdateRequested, + onInstallRequested = viewModel::onInstallRequested, + onAppUpdatedSnackbarShown = viewModel::onAppUpdatedSnackbarShown, onNavigateToAbout = onNavigateToAbout, onNavigateToLicence = onNavigateToLicence, onNavigateToSettings = onNavigateToSettings, @@ -100,6 +105,9 @@ fun MainScreen( fun MainScreenContent( uiState: MainUiState, onCheckRoot: () -> Unit, + onUpdateRequested: () -> Unit, + onInstallRequested: () -> Unit, + onAppUpdatedSnackbarShown: () -> Unit, onNavigateToAbout: () -> Unit, onNavigateToLicence: () -> Unit, onNavigateToSettings: () -> Unit, @@ -111,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), @@ -175,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() @@ -301,6 +324,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 +451,9 @@ private fun MainScreenNotCheckedPreview() { androidVersion = "Android 16", ), onCheckRoot = {}, + onUpdateRequested = {}, + onInstallRequested = {}, + onAppUpdatedSnackbarShown = {}, onNavigateToAbout = {}, onNavigateToLicence = {}, onNavigateToSettings = {}, @@ -437,6 +473,9 @@ private fun MainScreenCheckingPreview() { androidVersion = "Android 16", ), onCheckRoot = {}, + onUpdateRequested = {}, + onInstallRequested = {}, + onAppUpdatedSnackbarShown = {}, onNavigateToAbout = {}, onNavigateToLicence = {}, onNavigateToSettings = {}, @@ -456,6 +495,9 @@ private fun MainScreenRootedPreview() { androidVersion = "Android 16", ), onCheckRoot = {}, + onUpdateRequested = {}, + onInstallRequested = {}, + onAppUpdatedSnackbarShown = {}, onNavigateToAbout = {}, onNavigateToLicence = {}, onNavigateToSettings = {}, @@ -475,6 +517,9 @@ private fun MainScreenLocalesPreview() { androidVersion = "Android 16", ), onCheckRoot = {}, + onUpdateRequested = {}, + onInstallRequested = {}, + onAppUpdatedSnackbarShown = {}, onNavigateToAbout = {}, onNavigateToLicence = {}, onNavigateToSettings = {}, @@ -497,6 +542,9 @@ private fun MainScreenNotRootedPreview() { androidVersion = "Android 16", ), 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 f763dd46..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 @@ -4,14 +4,19 @@ 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.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 @@ -28,15 +33,43 @@ data class MainUiState( val deviceMarketingName: String = "", val deviceModelName: String = "", val androidVersion: String = "", + val updateStatus: AppUpdateEvent = AppUpdateEvent.None, + val appUpdatedShown: Boolean = false, ) 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() init { loadDeviceInfo() + viewModelScope.launch { + appUpdateController.events.collect { event -> + _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.. 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..3c6c1d89 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,9 @@ private fun MainScreenPlayStoreListing() { androidVersion = "Android 16", ), onCheckRoot = {}, + onUpdateRequested = {}, + onInstallRequested = {}, + onAppUpdatedSnackbarShown = {}, 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..92a0b2b6 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -41,4 +41,15 @@ هذا التطبيق يساعدك على عرض نص كبير على الشاشة، ويجعله بأكبر حجم ممكن دون قطع النص.\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..38293aa5 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -37,4 +37,15 @@ 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. + 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 d274c610..969ddd45 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -187,6 +187,17 @@ 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. + App was updated + 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..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" @@ -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" } 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