Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,13 @@ android {
}

buildTypes {
debug {
// In-app updates: Play's clientVersionStalenessDays returns null/0 over Internal App
// Sharing, so without a bypass the FLEXIBLE-update snackbar can't be manually verified
// pre-merge. Debug = true so IAS testing surfaces the prompt; release = false so the
// production threshold genuinely gates. See docs/active/in-app-updates/IMPLEMENTATION.md.
buildConfigField("boolean", "UPDATE_BYPASS_STALENESS", "true")
}
release {
isMinifyEnabled = true
isShrinkResources = true
Expand All @@ -63,6 +70,10 @@ android {
// Sign the release (APK/AAB) only when the keystore is present; otherwise leave it
// unsigned so non-publishing tasks still build.
signingConfig = if (keystorePropertiesFile.exists()) signingConfigs.getByName("release") else null
// Release ALWAYS honors the real staleness threshold — bypass exists only for debug
// IAS verification. Wiring it here (not just relying on the debug branch) makes the
// intent explicit and means a release AAB cannot ship with the bypass on.
buildConfigField("boolean", "UPDATE_BYPASS_STALENESS", "false")
}
}
compileOptions {
Expand Down Expand Up @@ -171,6 +182,10 @@ dependencies {
implementation(libs.androidx.media3.ui)
implementation(libs.androidx.media3.effect)

// Play In-App Updates — gentle "update ready" nudge on cold start (FLEXIBLE flow).
// See docs/active/in-app-updates/IMPLEMENTATION.md. -ktx pulls -core transitively.
implementation(libs.play.app.update.ktx)

// Testing
testImplementation(libs.junit)
testImplementation(libs.mockk)
Expand Down
70 changes: 70 additions & 0 deletions app/src/main/java/io/github/stozo04/openloop/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.IntentSenderRequest
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
Expand Down Expand Up @@ -47,6 +48,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
Expand All @@ -70,6 +72,7 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.media3.common.util.UnstableApi
import androidx.work.WorkManager
import com.google.android.play.core.appupdate.AppUpdateManagerFactory
import io.github.stozo04.openloop.camera.CameraManager
import io.github.stozo04.openloop.data.UserPreferencesRepositoryImpl
import io.github.stozo04.openloop.data.VideoImporterImpl
Expand All @@ -90,6 +93,7 @@ import io.github.stozo04.openloop.ui.OpenLoopUiState
import io.github.stozo04.openloop.ui.OpenLoopViewModel
import io.github.stozo04.openloop.ui.ProcessingScreen
import io.github.stozo04.openloop.ui.TrimScreen
import io.github.stozo04.openloop.update.AppUpdateController
import io.github.stozo04.openloop.ui.theme.Canvas
import io.github.stozo04.openloop.ui.theme.CoralRed
import io.github.stozo04.openloop.ui.theme.ElectricLime
Expand All @@ -100,6 +104,7 @@ import io.github.stozo04.openloop.ui.theme.OutlineVariant
import io.github.stozo04.openloop.ui.theme.SurfaceContainer
import io.github.stozo04.openloop.ui.theme.SurfaceContainerHigh
import io.github.stozo04.openloop.ui.theme.TextPrimary
import kotlinx.coroutines.launch
import java.io.File

class MainActivity : ComponentActivity() {
Expand Down Expand Up @@ -131,6 +136,13 @@ class MainActivity : ComponentActivity() {
}
private lateinit var cameraManager: CameraManager

/**
* Play In-App Updates controller (FLEXIBLE flow). Constructed in [onCreate] once the launcher
* below is registered. See `docs/active/in-app-updates/IMPLEMENTATION.md` for the design and
* the verification plan (T1 unit, T2 fake, T3 Internal App Sharing, T4 internal track).
*/
private lateinit var appUpdateController: AppUpdateController

/**
* Set when a boomerang share sheet is launched (slice 06); consumed on the next [onResume]. The
* "Saved — view in gallery" snackbar is deferred until then so it shows when the user is actually
Expand All @@ -156,6 +168,20 @@ class MainActivity : ComponentActivity() {
ActivityResultContracts.RequestPermission(),
) { /* granted or denied — export continues either way; notification is best-effort */ }

/**
* Play In-App Updates flow launcher (FLEXIBLE — non-blocking). Registered here as a class
* property so it's wired before `STARTED`, per the Activity Result API contract; the launched
* intent is owned by Play and shows its own confirmation dialog. A non-OK result just means
* the user declined or Play failed — nothing else in the app depends on it.
*/
private val appUpdateLauncher = registerForActivityResult(
ActivityResultContracts.StartIntentSenderForResult(),
) { result ->
if (result.resultCode != AppUpdateController.RESULT_OK) {
Log.w(TAG, "In-app update flow declined or failed: resultCode=${result.resultCode}")
}
}

private val requestPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission(),
) { granted ->
Expand Down Expand Up @@ -203,6 +229,13 @@ class MainActivity : ComponentActivity() {
savedInstanceState?.getBoolean(KEY_DEFERRED_SHARE_SHOW_SAVED, true) != false
cameraManager = CameraManager(this)

// Wire the Play In-App Updates controller (FLEXIBLE flow). The Activity owns the
// ActivityResultLauncher above; the controller owns the AppUpdateManager + listener.
// onUpdateDownloaded is set inside setContent below, after snackbarHostState exists.
appUpdateController = AppUpdateController(
appUpdateManager = AppUpdateManagerFactory.create(applicationContext),
).apply { attach(appUpdateLauncher) }

setContent {
OpenLoopTheme {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
Expand All @@ -222,6 +255,10 @@ class MainActivity : ComponentActivity() {
val reversePreviewReportAction = stringResource(R.string.snackbar_reverse_preview_report_action)
val importFailedMessage = stringResource(R.string.snackbar_import_failed)
val undoAction = stringResource(R.string.undo)
// In-app update prompt copy (read in composable scope so the resource lookup is
// invalidated on configuration change).
val updateReadyMessage = stringResource(R.string.update_ready_message)
val updateReadyAction = stringResource(R.string.update_ready_action)
// The "N loops deleted" plural is count-dependent, so we capture resources here (in a
// composable scope) and resolve the quantity string inside the collect lambda below.
// LocalResources (not LocalContext.current.resources) so the read is invalidated on a
Expand All @@ -230,12 +267,39 @@ class MainActivity : ComponentActivity() {

val lifecycleOwner = LocalLifecycleOwner.current

// Coroutine scope for the in-app update snackbar — onUpdateDownloaded fires from a
// Play listener callback (not a coroutine), so we need a scope to drive the
// suspend showSnackbar. rememberCoroutineScope is tied to the composition's
// lifetime; it cancels with the Activity.
val updateSnackbarScope = rememberCoroutineScope()

LaunchedEffect(Unit) {
viewModel.requestPostNotifications.collect {
maybeRequestPostNotificationsPermission()
}
}

// Wire the in-app update prompt and fire the cold-start check exactly once.
// onUpdateDownloaded fires from a Play install-state callback (not a coroutine),
// so the closure dispatches via updateSnackbarScope into the host's suspend queue.
// Indefinite + action: restart-required, never auto-dismiss. The styled
// SnackbarHost below renders this with the Electric-Lime accent.
LaunchedEffect(Unit) {
appUpdateController.onUpdateDownloaded = {
updateSnackbarScope.launch {
val result = snackbarHostState.showSnackbar(
message = updateReadyMessage,
actionLabel = updateReadyAction,
duration = SnackbarDuration.Indefinite,
)
if (result == SnackbarResult.ActionPerformed) {
appUpdateController.completeUpdate()
}
}
}
appUpdateController.checkOnStart()
}

// Collect one-shot boomerang events → share sheet + snackbars (the app's only
// SnackbarHost). `when` stays exhaustive with no `else` (Lesson 014) so a new event
// must be handled here to compile.
Expand Down Expand Up @@ -465,6 +529,9 @@ class MainActivity : ComponentActivity() {
awaitingShareReturn = false
viewModel.onShareSheetClosed()
}
// Re-surface the "Update ready" prompt if a Play download completed while the app was
// backgrounded (Google's recommended stalled-update handling).
appUpdateController.checkOnResume()
}

override fun onSaveInstanceState(outState: Bundle) {
Expand All @@ -478,6 +545,9 @@ class MainActivity : ComponentActivity() {
override fun onDestroy() {
super.onDestroy()
cameraManager.shutdown()
// Release the Play InstallStateUpdatedListener. The ActivityResultLauncher unregisters
// automatically with the Activity.
if (::appUpdateController.isInitialized) appUpdateController.detach()
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
package io.github.stozo04.openloop.update

import android.app.Activity
import android.util.Log
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.IntentSenderRequest
import com.google.android.play.core.appupdate.AppUpdateManager
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
import io.github.stozo04.openloop.BuildConfig

/**
* Default staleness gate for the FLEXIBLE in-app update prompt. Lets a same-week patch reach
* users on day 0–2 without nagging, then nudges anyone still on a 3+ day-old build. Tunable —
* see [shouldPromptForFlexibleUpdate].
*/
const val UPDATE_STALENESS_DAYS_THRESHOLD: Int = 3

/**
* Pure decision: should we prompt the user with a FLEXIBLE in-app update right now?
*
* Extracted as a top-level pure function so it's covered by a plain JVM unit test (T1 in the
* verification plan) without dragging the Play library into a Robolectric harness. Every Play
* field that drives the decision is passed in as a primitive so the test has no need to construct
* a real [com.google.android.play.core.appupdate.AppUpdateInfo].
*
* Logic: prompt iff Play reports an update is available, FLEXIBLE is allowed, and either the
* staleness bypass is on (debug builds, for IAS verification) or the build has been stale for at
* least [thresholdDays] days. A null [stalenessDays] is treated as "not stale enough" — Play
* returns null when it hasn't yet computed the staleness, which is correct for our gate.
*/
fun shouldPromptForFlexibleUpdate(
availability: Int,
stalenessDays: Int?,
flexibleAllowed: Boolean,
thresholdDays: Int = UPDATE_STALENESS_DAYS_THRESHOLD,
bypassStaleness: Boolean = false,
): Boolean = availability == UpdateAvailability.UPDATE_AVAILABLE
&& flexibleAllowed
&& (bypassStaleness || (stalenessDays ?: -1) >= thresholdDays)

/**
* Activity-bound wrapper around Google Play's [AppUpdateManager]. Drives the FLEXIBLE in-app
* update flow: checks Play's update metadata on cold start, kicks off a background download when
* the gate is open, and surfaces the "Update ready — restart to install" prompt via [onUpdateDownloaded]
* once Play has the new APK staged locally.
*
* Designed for testability:
* - [appUpdateManager] is injectable so tests can pass a `FakeAppUpdateManager` (T2).
* - The decision logic lives in [shouldPromptForFlexibleUpdate] (T1).
* - The [ActivityResultLauncher] is **owned by the Activity** (only it can register one before
* `STARTED`) and handed to the controller via [attach]. The controller never reaches for an
* Activity instance itself.
*
* Lifecycle: [attach] in `onCreate` after the launcher is registered; [detach] in `onDestroy` to
* release the [InstallStateUpdatedListener]. [checkOnStart] runs once per cold start; [checkOnResume]
* runs on every `onResume` to re-surface a download that completed while the app was backgrounded
* (Google's recommended stalled-update handling).
*
* Off-Play behavior: when the app is sideloaded (or running on a device without Play Services),
* [AppUpdateManager.getAppUpdateInfo] returns `UPDATE_NOT_AVAILABLE` and the controller silently
* no-ops. No exception is thrown and no UI is shown — the correct behavior for debug installs.
*/
class AppUpdateController(
private val appUpdateManager: AppUpdateManager,
private val bypassStaleness: Boolean = BuildConfig.UPDATE_BYPASS_STALENESS,
private val stalenessThresholdDays: Int = UPDATE_STALENESS_DAYS_THRESHOLD,
) {
/**
* Invoked when Play has finished downloading a newer APK and it's ready to install. The
* Activity sets this to fire the "Update ready — restart to install" snackbar. Stays nullable
* so the controller can be constructed before the SnackbarHostState exists (it's created
* inside `setContent`).
*/
var onUpdateDownloaded: (() -> Unit)? = null

private var updateLauncher: ActivityResultLauncher<IntentSenderRequest>? = null
private var installStateListener: InstallStateUpdatedListener? = null

/**
* Idempotent. Wires the [ActivityResultLauncher] (owned by the host Activity) and registers
* the [InstallStateUpdatedListener] that routes `DOWNLOADED` transitions to [onUpdateDownloaded].
* Safe to call multiple times — subsequent calls are no-ops.
*/
fun attach(launcher: ActivityResultLauncher<IntentSenderRequest>) {
if (updateLauncher != null) return
updateLauncher = launcher
installStateListener = InstallStateUpdatedListener { state ->
if (state.installStatus() == InstallStatus.DOWNLOADED) {
Log.i(TAG, "Update downloaded — prompting to restart")
onUpdateDownloaded?.invoke()
}
}.also { appUpdateManager.registerListener(it) }
}

/**
* One-shot check on cold start. If Play reports a newer build and the gate is open, kicks off
* a FLEXIBLE update flow (Play's confirmation dialog → background download). If the gate is
* closed for any reason, silently no-ops with a debug log.
*/
fun checkOnStart() {
val launcher = updateLauncher ?: run {
Log.w(TAG, "checkOnStart called before attach(); skipping")
return
}
appUpdateManager.appUpdateInfo
.addOnSuccessListener { info ->
val shouldPrompt = shouldPromptForFlexibleUpdate(
availability = info.updateAvailability(),
stalenessDays = info.clientVersionStalenessDays(),
flexibleAllowed = info.isUpdateTypeAllowed(AppUpdateType.FLEXIBLE),
thresholdDays = stalenessThresholdDays,
bypassStaleness = bypassStaleness,
)
if (shouldPrompt) {
Log.i(
TAG,
"UPDATE_AVAILABLE, starting FLEXIBLE flow " +
"(staleness=${info.clientVersionStalenessDays()}, bypass=$bypassStaleness)",
)
appUpdateManager.startUpdateFlowForResult(
info,
launcher,
AppUpdateOptions.newBuilder(AppUpdateType.FLEXIBLE).build(),
)
} else {
Log.d(
TAG,
"No flexible update prompted: availability=${info.updateAvailability()} " +
"staleness=${info.clientVersionStalenessDays()} " +
"flexibleAllowed=${info.isUpdateTypeAllowed(AppUpdateType.FLEXIBLE)}",
)
}
}
.addOnFailureListener { e ->
// Routine off-Play / no-network failures — log at WARN, no Crashlytics noise.
Log.w(TAG, "appUpdateInfo check failed", e)
}
}

/**
* Re-surface the "Update ready" prompt when the user returns to the app after a download
* completed in the background (Google's recommended stalled-update handling). Cheap — just a
* single [AppUpdateManager.getAppUpdateInfo] poll.
*/
fun checkOnResume() {
appUpdateManager.appUpdateInfo.addOnSuccessListener { info ->
if (info.installStatus() == InstallStatus.DOWNLOADED) {
Log.i(TAG, "checkOnResume: update already DOWNLOADED — prompting")
onUpdateDownloaded?.invoke()
}
}
}

/** Trigger Play to install the staged APK and restart the app. Called from the snackbar action. */
fun completeUpdate() {
Log.i(TAG, "completeUpdate: handing off to Play install")
appUpdateManager.completeUpdate()
}

/**
* Unregister the [InstallStateUpdatedListener]. Call from `onDestroy`. The
* [ActivityResultLauncher] is owned by the Activity and unregisters with it automatically.
*/
fun detach() {
installStateListener?.let { appUpdateManager.unregisterListener(it) }
installStateListener = null
}

companion object {
const val TAG: String = "AppUpdate"

/**
* Result-code constant matching `Activity.RESULT_OK`. Exposed so the launcher callback in
* the Activity can be a one-liner that doesn't pull in the full `android.app.Activity`
* surface just for this comparison.
*/
const val RESULT_OK: Int = Activity.RESULT_OK
}
}
4 changes: 4 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,8 @@
<string name="import_too_long_title">That clip\'s a bit long</string>
<string name="import_too_long_body">OpenLoop makes loops from videos up to 30 seconds. Pick a shorter clip and we\'ll loop it.</string>
<string name="import_too_long_button">Got it</string>

<!-- In-app updates (gentle nudge — docs/active/in-app-updates/IMPLEMENTATION.md) -->
<string name="update_ready_message">Update ready — restart to install.</string>
<string name="update_ready_action">Restart</string>
</resources>
Loading