diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 67bbe74..a057817 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -130,6 +130,8 @@ dependencies { implementation(libs.okhttp.logging) implementation(libs.play.services.auth) implementation(libs.play.services.auth.api.phone) + implementation(libs.play.app.update) + implementation(libs.play.app.update.ktx) implementation(libs.retrofit) implementation(libs.retrofit.gson) diff --git a/app/src/main/java/pl/firmatpp/kierowca/MainActivity.kt b/app/src/main/java/pl/firmatpp/kierowca/MainActivity.kt index 0b532c5..012480f 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/MainActivity.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/MainActivity.kt @@ -3,6 +3,7 @@ package pl.firmatpp.kierowca import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -11,17 +12,33 @@ import androidx.lifecycle.viewmodel.compose.viewModel import pl.firmatpp.kierowca.ui.DriverApp import pl.firmatpp.kierowca.ui.DriverViewModel import pl.firmatpp.kierowca.ui.theme.TppKierowcaTheme +import pl.firmatpp.kierowca.update.PlayAppUpdateState +import pl.firmatpp.kierowca.update.PlayInAppUpdateController class MainActivity : ComponentActivity() { private var notificationRouteId by mutableStateOf(null) private var notificationRouteTarget by mutableStateOf(null) private var notificationLeaveRequestId by mutableStateOf(null) + private var playAppUpdateState by mutableStateOf(PlayAppUpdateState()) + private lateinit var playInAppUpdates: PlayInAppUpdateController + private val appUpdateLauncher = registerForActivityResult( + ActivityResultContracts.StartIntentSenderForResult(), + ) { result -> + if (::playInAppUpdates.isInitialized) { + playInAppUpdates.onUpdateFlowResult(result.resultCode) + } + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) notificationRouteId = intent.getStringExtra(EXTRA_ROUTE_ID) notificationRouteTarget = intent.getStringExtra(EXTRA_ROUTE_TARGET) notificationLeaveRequestId = intent.getStringExtra(EXTRA_LEAVE_REQUEST_ID) + playInAppUpdates = PlayInAppUpdateController( + activity = this, + launcher = appUpdateLauncher, + onStateChanged = { playAppUpdateState = it }, + ) setContent { val viewModel: DriverViewModel = viewModel() val state by viewModel.state.collectAsStateWithLifecycle() @@ -32,9 +49,27 @@ class MainActivity : ComponentActivity() { initialRouteId = notificationRouteId, initialRouteTarget = notificationRouteTarget, initialLeaveRequestId = notificationLeaveRequestId, + playAppUpdateState = playAppUpdateState, + onStartAppUpdate = playInAppUpdates::startUpdate, + onCompleteAppUpdate = playInAppUpdates::completeDownloadedUpdate, ) } } + playInAppUpdates.checkForUpdate() + } + + override fun onResume() { + super.onResume() + if (::playInAppUpdates.isInitialized) { + playInAppUpdates.checkForUpdate() + } + } + + override fun onDestroy() { + if (::playInAppUpdates.isInitialized) { + playInAppUpdates.dispose() + } + super.onDestroy() } override fun onNewIntent(intent: android.content.Intent) { diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/model/DriverModels.kt b/app/src/main/java/pl/firmatpp/kierowca/data/model/DriverModels.kt index 906b90d..a66c1b5 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/model/DriverModels.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/model/DriverModels.kt @@ -50,6 +50,16 @@ data class BootstrapResponse( val notificationPreferences: NotificationPreferencesDto? = null, val realtime: RealtimeConfigDto? = null, val syncState: SyncStateResponse? = null, + val appUpdate: AppUpdateDto? = null, +) + +data class AppUpdateDto( + val latestVersionCode: Int? = null, + val latestVersionName: String? = null, + val minimumSupportedVersionCode: Int = 0, + val playStoreUrl: String? = null, + val track: String? = null, + val checkedAt: String? = null, ) data class DriverAppSettingsDto( diff --git a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt index 3b46603..6556413 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt @@ -74,6 +74,7 @@ import androidx.compose.material.icons.outlined.Navigation import androidx.compose.material.icons.outlined.Person import androidx.compose.material.icons.outlined.Phone import androidx.compose.material.icons.outlined.Refresh +import androidx.compose.material.icons.outlined.SystemUpdate import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.pullRefresh @@ -149,6 +150,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged import java.util.Locale import pl.firmatpp.kierowca.R +import pl.firmatpp.kierowca.BuildConfig import pl.firmatpp.kierowca.data.PhotoUploadMetadata import pl.firmatpp.kierowca.data.model.DispatchSheetReminderDto import pl.firmatpp.kierowca.data.model.DriverLeaveCalendarEntryDto @@ -169,6 +171,7 @@ import pl.firmatpp.kierowca.domain.RouteDisplayMapper import pl.firmatpp.kierowca.domain.StartupSessionPolicy import pl.firmatpp.kierowca.ui.theme.AppThemeMode import pl.firmatpp.kierowca.ui.theme.TppTheme +import pl.firmatpp.kierowca.update.PlayAppUpdateState @Composable fun DriverApp( @@ -176,8 +179,27 @@ fun DriverApp( initialRouteId: String? = null, initialRouteTarget: String? = null, initialLeaveRequestId: String? = null, + playAppUpdateState: PlayAppUpdateState = PlayAppUpdateState(), + onStartAppUpdate: (required: Boolean, playStoreUrl: String?) -> Unit = { _, _ -> }, + onCompleteAppUpdate: () -> Unit = {}, ) { val state by viewModel.state.collectAsState() + val updateUi = appUpdateBannerUi( + currentVersionCode = BuildConfig.VERSION_CODE, + latestVersionCode = state.appUpdate?.latestVersionCode, + latestVersionName = state.appUpdate?.latestVersionName, + minimumSupportedVersionCode = state.appUpdate?.minimumSupportedVersionCode ?: 0, + playUpdateAvailable = playAppUpdateState.updateAvailable, + playAvailableVersionCode = playAppUpdateState.availableVersionCode, + updateDownloaded = playAppUpdateState.downloaded, + ) + val runUpdate = { + if (updateUi?.downloaded == true) { + onCompleteAppUpdate() + } else { + onStartAppUpdate(updateUi?.required == true, state.appUpdate?.playStoreUrl) + } + } val lifecycleOwner = LocalLifecycleOwner.current val context = LocalContext.current var appInForeground by remember { mutableStateOf(true) } @@ -399,6 +421,80 @@ fun DriverApp( CircularProgressIndicator(color = TppTheme.colors.forest) } } + + if (updateUi != null && !updateUi.required) { + AppUpdateBanner( + update = updateUi, + onUpdate = runUpdate, + modifier = Modifier + .align(Alignment.TopCenter) + .statusBarsPadding() + .padding(horizontal = 12.dp, vertical = 8.dp), + ) + } + } + + if (updateUi?.required == true) { + AlertDialog( + onDismissRequest = {}, + title = { + Text( + "Wymagana aktualizacja", + color = TppTheme.colors.ink, + fontWeight = FontWeight.Bold, + ) + }, + text = { Text(updateUi.message, color = TppTheme.colors.muted) }, + confirmButton = { + Button( + onClick = runUpdate, + colors = ButtonDefaults.buttonColors(containerColor = TppTheme.colors.forest), + ) { + Text(updateUi.actionLabel, color = Color.White, fontWeight = FontWeight.Bold) + } + }, + ) + } +} + +@Composable +private fun AppUpdateBanner( + update: AppUpdateBannerUi, + onUpdate: () -> Unit, + modifier: Modifier = Modifier, +) { + Card( + colors = CardDefaults.cardColors(containerColor = TppTheme.colors.warningContainer), + border = BorderStroke(1.dp, TppTheme.colors.warningOutline), + shape = MaterialTheme.shapes.medium, + modifier = modifier.fillMaxWidth(), + ) { + Row( + Modifier.fillMaxWidth().padding(14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Box( + Modifier.size(40.dp).background(Color.White, RoundedCornerShape(6.dp)), + contentAlignment = Alignment.Center, + ) { + Icon(Icons.Outlined.SystemUpdate, contentDescription = null, tint = TppTheme.colors.forest) + } + Text( + update.message, + color = Color(0xFF5F4200), + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f), + ) + Button( + onClick = onUpdate, + colors = ButtonDefaults.buttonColors(containerColor = TppTheme.colors.forest), + shape = MaterialTheme.shapes.small, + contentPadding = PaddingValues(horizontal = 14.dp, vertical = 10.dp), + ) { + Text(update.actionLabel, color = Color.White, fontWeight = FontWeight.Bold) + } + } } } diff --git a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverUiRules.kt b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverUiRules.kt index 619bdba..5b78cdd 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverUiRules.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverUiRules.kt @@ -32,6 +32,48 @@ data class NavigationIntentCandidate( val packageName: String? = null, ) +data class AppUpdateBannerUi( + val message: String, + val actionLabel: String, + val required: Boolean, + val downloaded: Boolean, +) + +fun appUpdateBannerUi( + currentVersionCode: Int, + latestVersionCode: Int?, + latestVersionName: String?, + minimumSupportedVersionCode: Int, + playUpdateAvailable: Boolean, + playAvailableVersionCode: Int?, + updateDownloaded: Boolean, +): AppUpdateBannerUi? { + val highestAvailableVersion = listOfNotNull(latestVersionCode, playAvailableVersionCode).maxOrNull() + val required = minimumSupportedVersionCode > 0 && currentVersionCode < minimumSupportedVersionCode + val available = updateDownloaded || + playUpdateAvailable || + highestAvailableVersion?.let { it > currentVersionCode } == true || + required + if (!available) return null + + val versionLabel = latestVersionName + ?.takeIf { it.isNotBlank() } + ?.let { " $it" } + .orEmpty() + val message = when { + updateDownloaded -> "Aktualizacja$versionLabel jest pobrana i gotowa do instalacji." + required -> "Ta wersja aplikacji nie jest już obsługiwana. Zaktualizuj ją, aby kontynuować." + else -> "Dostępna jest nowa wersja aplikacji$versionLabel." + } + + return AppUpdateBannerUi( + message = message, + actionLabel = if (updateDownloaded) "Zainstaluj" else "Aktualizuj", + required = required, + downloaded = updateDownloaded, + ) +} + fun hasValidNavigationCoordinates(latitude: Double?, longitude: Double?): Boolean = latitude != null && longitude != null && diff --git a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt index 22f8548..5b2f305 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt @@ -24,6 +24,7 @@ import pl.firmatpp.kierowca.data.sync.DriverSyncRepository import pl.firmatpp.kierowca.data.sync.NetworkMonitor import pl.firmatpp.kierowca.data.sync.StartupOfflineFallbackLoader import pl.firmatpp.kierowca.data.model.DispatchSheetReminderDto +import pl.firmatpp.kierowca.data.model.AppUpdateDto import pl.firmatpp.kierowca.data.model.DriverDto import pl.firmatpp.kierowca.data.model.DriverLeaveCalendarEntryDto import pl.firmatpp.kierowca.data.model.DriverLeaveRequestDto @@ -92,6 +93,7 @@ data class DriverUiState( val photoUploads: List = emptyList(), val queuedPhotoUploads: List = emptyList(), val dispatchSheetReminder: DispatchSheetReminderDto? = null, + val appUpdate: AppUpdateDto? = null, val dispatchSheetUploads: List = emptyList(), val leaveRequestsConfig: LeaveRequestsConfig? = null, val leaveRequests: List = emptyList(), @@ -422,6 +424,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) driver = response.session.driver, routes = response.routes.today, dispatchSheetReminder = response.dispatchSheetReminder, + appUpdate = response.appUpdate ?: it.appUpdate, selectedDate = settings?.selectedDate ?: date ?: it.selectedDate, minRouteDate = settings?.minDate ?: it.minRouteDate, maxRouteDate = settings?.maxDate ?: it.maxRouteDate, diff --git a/app/src/main/java/pl/firmatpp/kierowca/update/PlayInAppUpdateController.kt b/app/src/main/java/pl/firmatpp/kierowca/update/PlayInAppUpdateController.kt new file mode 100644 index 0000000..0546163 --- /dev/null +++ b/app/src/main/java/pl/firmatpp/kierowca/update/PlayInAppUpdateController.kt @@ -0,0 +1,146 @@ +package pl.firmatpp.kierowca.update + +import android.app.Activity +import android.content.ActivityNotFoundException +import android.content.Intent +import android.net.Uri +import androidx.activity.ComponentActivity +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.IntentSenderRequest +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.InstallStatus +import com.google.android.play.core.install.model.UpdateAvailability + +data class PlayAppUpdateState( + val updateAvailable: Boolean = false, + val availableVersionCode: Int? = null, + val downloaded: Boolean = false, + val checking: Boolean = false, +) + +class PlayInAppUpdateController( + private val activity: ComponentActivity, + private val launcher: ActivityResultLauncher, + private val onStateChanged: (PlayAppUpdateState) -> Unit, + private val appUpdateManager: AppUpdateManager = AppUpdateManagerFactory.create(activity), +) { + private var state = PlayAppUpdateState() + private val installStateListener = InstallStateUpdatedListener { installState -> + when (installState.installStatus()) { + InstallStatus.DOWNLOADED -> updateState(state.copy(downloaded = true, updateAvailable = true)) + InstallStatus.INSTALLED -> updateState(PlayAppUpdateState()) + else -> Unit + } + } + + init { + appUpdateManager.registerListener(installStateListener) + } + + fun checkForUpdate() { + updateState(state.copy(checking = true)) + appUpdateManager.appUpdateInfo + .addOnSuccessListener(::handleUpdateInfo) + .addOnFailureListener { updateState(state.copy(checking = false)) } + } + + fun startUpdate(required: Boolean, playStoreUrl: String?) { + appUpdateManager.appUpdateInfo + .addOnSuccessListener { info -> + if (info.updateAvailability() != UpdateAvailability.UPDATE_AVAILABLE) { + openPlayStore(playStoreUrl) + return@addOnSuccessListener + } + + val preferredType = if (required) AppUpdateType.IMMEDIATE else AppUpdateType.FLEXIBLE + val updateType = when { + info.isUpdateTypeAllowed(preferredType) -> preferredType + info.isUpdateTypeAllowed(AppUpdateType.FLEXIBLE) -> AppUpdateType.FLEXIBLE + info.isUpdateTypeAllowed(AppUpdateType.IMMEDIATE) -> AppUpdateType.IMMEDIATE + else -> null + } + + if (updateType == null || !launchUpdate(info, updateType)) { + openPlayStore(playStoreUrl) + } + } + .addOnFailureListener { openPlayStore(playStoreUrl) } + } + + fun completeDownloadedUpdate() { + appUpdateManager.completeUpdate() + } + + fun onUpdateFlowResult(resultCode: Int) { + if (resultCode != Activity.RESULT_OK) { + checkForUpdate() + } + } + + fun dispose() { + appUpdateManager.unregisterListener(installStateListener) + } + + private fun handleUpdateInfo(info: AppUpdateInfo) { + val downloaded = info.installStatus() == InstallStatus.DOWNLOADED + val available = info.updateAvailability() == UpdateAvailability.UPDATE_AVAILABLE || + info.updateAvailability() == UpdateAvailability.DEVELOPER_TRIGGERED_UPDATE_IN_PROGRESS || + downloaded + + updateState( + PlayAppUpdateState( + updateAvailable = available, + availableVersionCode = info.availableVersionCode().takeIf { it > 0 }, + downloaded = downloaded, + checking = false, + ), + ) + + if ( + info.updateAvailability() == UpdateAvailability.DEVELOPER_TRIGGERED_UPDATE_IN_PROGRESS && + info.isUpdateTypeAllowed(AppUpdateType.IMMEDIATE) + ) { + launchUpdate(info, AppUpdateType.IMMEDIATE) + } + } + + private fun launchUpdate(info: AppUpdateInfo, updateType: Int): Boolean = + appUpdateManager.startUpdateFlowForResult( + info, + launcher, + AppUpdateOptions.newBuilder(updateType).build(), + ) + + private fun openPlayStore(playStoreUrl: String?) { + val packageName = activity.packageName + val candidates = listOf( + Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=$packageName")).setPackage("com.android.vending"), + Intent( + Intent.ACTION_VIEW, + Uri.parse(playStoreUrl?.takeIf { it.isNotBlank() } + ?: "https://play.google.com/store/apps/details?id=$packageName"), + ), + ) + + for (intent in candidates) { + try { + activity.startActivity(intent) + return + } catch (_: ActivityNotFoundException) { + // Try the browser fallback. + } catch (_: SecurityException) { + // Try the browser fallback. + } + } + } + + private fun updateState(next: PlayAppUpdateState) { + state = next + onStateChanged(next) + } +} diff --git a/app/src/test/java/pl/firmatpp/kierowca/ui/DriverUiRulesTest.kt b/app/src/test/java/pl/firmatpp/kierowca/ui/DriverUiRulesTest.kt index 74cdeec..6924c1d 100644 --- a/app/src/test/java/pl/firmatpp/kierowca/ui/DriverUiRulesTest.kt +++ b/app/src/test/java/pl/firmatpp/kierowca/ui/DriverUiRulesTest.kt @@ -115,6 +115,75 @@ class DriverUiRulesTest { assertTrue(navigationIntentCandidates(null, null, "Cel").isEmpty()) } + @Test + fun showsAnOptionalUpdateWhenGooglePlayOrBackendHasANewerVersion() { + val fromPlay = appUpdateBannerUi( + currentVersionCode = 113, + latestVersionCode = null, + latestVersionName = null, + minimumSupportedVersionCode = 0, + playUpdateAvailable = true, + playAvailableVersionCode = 114, + updateDownloaded = false, + ) + val fromBackend = appUpdateBannerUi( + currentVersionCode = 113, + latestVersionCode = 114, + latestVersionName = "1.0.61", + minimumSupportedVersionCode = 0, + playUpdateAvailable = false, + playAvailableVersionCode = null, + updateDownloaded = false, + ) + + assertEquals(false, fromPlay?.required) + assertEquals("Aktualizuj", fromPlay?.actionLabel) + assertEquals("Dostępna jest nowa wersja aplikacji 1.0.61.", fromBackend?.message) + } + + @Test + fun requiresAnUpdateOnlyBelowTheConfiguredMinimumVersion() { + val required = appUpdateBannerUi( + currentVersionCode = 111, + latestVersionCode = 114, + latestVersionName = "1.0.61", + minimumSupportedVersionCode = 112, + playUpdateAvailable = true, + playAvailableVersionCode = 114, + updateDownloaded = false, + ) + val current = appUpdateBannerUi( + currentVersionCode = 114, + latestVersionCode = 114, + latestVersionName = "1.0.61", + minimumSupportedVersionCode = 112, + playUpdateAvailable = false, + playAvailableVersionCode = null, + updateDownloaded = false, + ) + + assertEquals(true, required?.required) + assertTrue(required?.message?.contains("nie jest już obsługiwana") == true) + assertEquals(null, current) + } + + @Test + fun downloadedUpdateUsesInstallAction() { + val update = appUpdateBannerUi( + currentVersionCode = 113, + latestVersionCode = 114, + latestVersionName = "1.0.61", + minimumSupportedVersionCode = 0, + playUpdateAvailable = true, + playAvailableVersionCode = 114, + updateDownloaded = true, + ) + + assertEquals("Zainstaluj", update?.actionLabel) + assertEquals(true, update?.downloaded) + assertTrue(update?.message?.contains("gotowa do instalacji") == true) + } + @Test fun calculatesPhotoGridRowsForTwoColumnInlineGallery() { assertEquals(0, inlinePhotoGridRows(0)) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1dedf13..3d951fb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,6 +17,7 @@ room = "2.7.0" ksp = "2.1.10-1.0.31" playServicesAuth = "21.6.0" playServicesAuthApiPhone = "18.3.0" +playAppUpdate = "2.1.0" junit = "4.13.2" firebaseBom = "33.7.0" googleServices = "4.4.2" @@ -52,6 +53,8 @@ okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhtt okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" } play-services-auth = { group = "com.google.android.gms", name = "play-services-auth", version.ref = "playServicesAuth" } play-services-auth-api-phone = { group = "com.google.android.gms", name = "play-services-auth-api-phone", version.ref = "playServicesAuthApiPhone" } +play-app-update = { group = "com.google.android.play", name = "app-update", version.ref = "playAppUpdate" } +play-app-update-ktx = { group = "com.google.android.play", name = "app-update-ktx", version.ref = "playAppUpdate" } retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } retrofit-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" } firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" }