diff --git a/apps/mobile/modules/edgeever-app-store/android/build.gradle b/apps/mobile/modules/edgeever-app-store/android/build.gradle index 82032d61..07ab3a78 100644 --- a/apps/mobile/modules/edgeever-app-store/android/build.gradle +++ b/apps/mobile/modules/edgeever-app-store/android/build.gradle @@ -6,6 +6,10 @@ plugins { group = 'org.edgeever.appstore' version = '1.0.0' +dependencies { + implementation 'com.google.android.play:app-update:2.1.0' +} + android { namespace "org.edgeever.appstore" defaultConfig { diff --git a/apps/mobile/modules/edgeever-app-store/android/src/main/java/org/edgeever/appstore/EdgeEverAppStoreModule.kt b/apps/mobile/modules/edgeever-app-store/android/src/main/java/org/edgeever/appstore/EdgeEverAppStoreModule.kt index 45562d0c..54c95c91 100644 --- a/apps/mobile/modules/edgeever-app-store/android/src/main/java/org/edgeever/appstore/EdgeEverAppStoreModule.kt +++ b/apps/mobile/modules/edgeever-app-store/android/src/main/java/org/edgeever/appstore/EdgeEverAppStoreModule.kt @@ -1,9 +1,20 @@ package org.edgeever.appstore +import android.app.Activity import android.content.ActivityNotFoundException import android.content.Intent import android.content.pm.PackageManager import android.net.Uri +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 +import expo.modules.kotlin.Promise +import expo.modules.kotlin.functions.Queues import expo.modules.kotlin.modules.Module import expo.modules.kotlin.modules.ModuleDefinition @@ -14,8 +25,92 @@ private const val RESULT_OPENED = "opened" private const val RESULT_UNAVAILABLE = "unavailable" class EdgeEverAppStoreModule : Module() { + private var updateManager: AppUpdateManager? = null + private var updateVersionCode = 0 + private val updateListener = InstallStateUpdatedListener { state -> + sendEvent("onGooglePlayUpdate", updateState(installStatus(state.installStatus()))) + } + + private fun manager(): AppUpdateManager { + return updateManager ?: AppUpdateManagerFactory.create( + requireNotNull(appContext.reactContext) + ).also { + it.registerListener(updateListener) + updateManager = it + } + } + + private fun installStatus(status: Int): String = when (status) { + InstallStatus.PENDING, InstallStatus.DOWNLOADING, InstallStatus.INSTALLING -> "downloading" + InstallStatus.DOWNLOADED -> "ready" + else -> "unavailable" + } + + private fun updateState(status: String) = mapOf( + "status" to status, + "versionCode" to updateVersionCode + ) + + private fun infoStatus(info: AppUpdateInfo): String { + updateVersionCode = info.availableVersionCode() + val status = installStatus(info.installStatus()) + if (status != "unavailable") return status + return if (info.updateAvailability() == UpdateAvailability.UPDATE_AVAILABLE && + info.isUpdateTypeAllowed(AppUpdateType.FLEXIBLE)) "available" else "unavailable" + } + override fun definition() = ModuleDefinition { Name("EdgeEverAppStore") + Events("onGooglePlayUpdate") + + OnDestroy { + updateManager?.unregisterListener(updateListener) + updateManager = null + } + + AsyncFunction("getGooglePlayUpdate") { promise: Promise -> + manager().appUpdateInfo + .addOnSuccessListener { promise.resolve(updateState(infoStatus(it))) } + .addOnFailureListener { promise.resolve(updateState("unavailable")) } + }.runOnQueue(Queues.MAIN) + + AsyncFunction("startGooglePlayUpdate") { promise: Promise -> + val activity = appContext.currentActivity + if (activity == null) { + promise.resolve("unavailable") + return@AsyncFunction + } + val manager = manager() + // Always fetch a fresh AppUpdateInfo: each instance can launch only one flow. + manager.appUpdateInfo.addOnSuccessListener { info -> + val status = infoStatus(info) + if (status != "available") { + sendEvent("onGooglePlayUpdate", updateState(status)) + promise.resolve(if (status == "unavailable") "unavailable" else "accepted") + return@addOnSuccessListener + } + try { + manager.startUpdateFlow(info, activity, + AppUpdateOptions.newBuilder(AppUpdateType.FLEXIBLE).build()) + .addOnSuccessListener { result -> + promise.resolve(when (result) { + Activity.RESULT_OK -> "accepted" + Activity.RESULT_CANCELED -> "cancelled" + else -> "unavailable" + }) + } + .addOnFailureListener { promise.resolve("unavailable") } + } catch (_: Exception) { + promise.resolve("unavailable") + } + }.addOnFailureListener { promise.resolve("unavailable") } + }.runOnQueue(Queues.MAIN) + + AsyncFunction("completeGooglePlayUpdate") { promise: Promise -> + manager().completeUpdate() + .addOnSuccessListener { promise.resolve(true) } + .addOnFailureListener { promise.resolve(false) } + }.runOnQueue(Queues.MAIN) AsyncFunction("openGooglePlayDetails") { applicationId: String -> val activity = appContext.currentActivity ?: return@AsyncFunction RESULT_UNAVAILABLE diff --git a/apps/mobile/modules/edgeever-app-store/index.ts b/apps/mobile/modules/edgeever-app-store/index.ts index c7588503..175a29ff 100644 --- a/apps/mobile/modules/edgeever-app-store/index.ts +++ b/apps/mobile/modules/edgeever-app-store/index.ts @@ -4,6 +4,55 @@ export type GooglePlayOpenResult = "disabled" | "not-installed" | "opened" | "un type EdgeEverAppStoreNativeModule = { openGooglePlayDetails: (applicationId: string) => Promise; + getGooglePlayUpdate?: () => Promise; + startGooglePlayUpdate?: () => Promise; + completeGooglePlayUpdate?: () => Promise; + addListener: (event: "onGooglePlayUpdate", listener: (state: GooglePlayUpdateState) => void) => { remove: () => void }; +}; + +export type GooglePlayUpdateState = { + status: "unavailable" | "available" | "downloading" | "ready"; + versionCode: number; +}; +export type GooglePlayUpdateResult = "accepted" | "cancelled" | "unavailable"; + +// Older installed binaries can receive this JS through OTA before the native SDK. +// Missing native methods must therefore behave like an unavailable Play update. +export const googlePlayUpdates = { + async check(): Promise { + try { + return await requireNativeModule("EdgeEverAppStore") + .getGooglePlayUpdate?.() ?? { status: "unavailable", versionCode: 0 }; + } catch { + return { status: "unavailable", versionCode: 0 }; + } + }, + async start(): Promise { + try { + return await requireNativeModule("EdgeEverAppStore") + .startGooglePlayUpdate?.() ?? "unavailable"; + } catch { + return "unavailable"; + } + }, + async complete(): Promise { + try { + return await requireNativeModule("EdgeEverAppStore") + .completeGooglePlayUpdate?.() ?? false; + } catch { + return false; + } + }, + subscribe(listener: (state: GooglePlayUpdateState) => void): { remove: () => void } { + try { + const module = requireNativeModule("EdgeEverAppStore"); + return module.getGooglePlayUpdate + ? module.addListener("onGooglePlayUpdate", listener) + : { remove() {} }; + } catch { + return { remove() {} }; + } + }, }; export const openGooglePlayDetails = async (applicationId: string): Promise => { diff --git a/apps/mobile/src/components/MobileUpdateCard.tsx b/apps/mobile/src/components/MobileUpdateCard.tsx index c611d763..e44a4165 100644 --- a/apps/mobile/src/components/MobileUpdateCard.tsx +++ b/apps/mobile/src/components/MobileUpdateCard.tsx @@ -23,6 +23,8 @@ export const MobileUpdateCard = () => { ? (english ? "Restart to apply" : "重启以应用") : updateKind === "ota" ? (english ? "Download update" : "下载更新") + : updateKind === "play" + ? (english ? "Update with Google Play" : "通过 Google Play 更新") : (english ? "Choose update source" : "选择更新渠道"); return ( diff --git a/apps/mobile/src/lib/mobile-play-update.test.ts b/apps/mobile/src/lib/mobile-play-update.test.ts new file mode 100644 index 00000000..59dea360 --- /dev/null +++ b/apps/mobile/src/lib/mobile-play-update.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, test } from "bun:test"; +import { MobilePlayUpdate } from "./mobile-play-update"; +import type { GooglePlayUpdateResult, GooglePlayUpdateState } from "../../modules/edgeever-app-store"; + +const available: GooglePlayUpdateState = { status: "available", versionCode: 234 }; +const unavailable: GooglePlayUpdateState = { status: "unavailable", versionCode: 0 }; + +function setup(result: GooglePlayUpdateResult = "accepted") { + let snapshot = available; + let starts = 0; + const states: GooglePlayUpdateState[] = []; + const controller = new MobilePlayUpdate({ + check: async () => snapshot, + start: async () => { starts += 1; return result; }, + }, (state) => states.push(state)); + return { controller, states, starts: () => starts, snapshot: (state: GooglePlayUpdateState) => { snapshot = state; } }; +} + +describe("Google Play flexible updates", () => { + test("automatically requests consent and downloads without restarting", async () => { + const { controller, states, starts } = setup(); + expect(await controller.check(true)).toBe(true); + expect(starts()).toBe(1); + expect(states.map((state) => state.status)).toEqual(["available", "downloading"]); + controller.receive({ ...available, status: "ready" }); + expect(controller.state.status).toBe("ready"); + }); + + test("declining prevents repeat automatic prompts but allows manual retry", async () => { + const { controller, starts, snapshot } = setup("cancelled"); + await controller.check(true); + await controller.check(true); + expect(starts()).toBe(1); + expect(controller.state.status).toBe("available"); + await controller.start(); + expect(starts()).toBe(2); + snapshot({ ...available, versionCode: 235 }); + await controller.check(true); + expect(starts()).toBe(3); + }); + + test("unavailable Play updates allow existing release and OTA checks to continue", async () => { + const { controller, starts, snapshot } = setup(); + snapshot(unavailable); + expect(await controller.check(true)).toBe(false); + expect(controller.hasKnownUpdate).toBe(false); + expect(starts()).toBe(0); + }); + + test("a launch failure falls back and is not automatically prompted again", async () => { + const { controller, starts } = setup("unavailable"); + expect(await controller.check(true)).toBe(false); + expect(controller.hasKnownUpdate).toBe(true); + await controller.check(true); + expect(starts()).toBe(1); + expect(await controller.start()).toBe("unavailable"); + }); + + test.each(["downloading", "ready"] as const)("recovers %s on foreground without launching another consent flow", async (status) => { + const { controller, starts, snapshot } = setup(); + snapshot({ ...available, status }); + expect(await controller.check(false)).toBe(true); + expect(controller.state.status).toBe(status); + expect(starts()).toBe(0); + }); + + test("manual checks discover Play updates without automatically opening consent", async () => { + const { controller, starts } = setup(); + expect(await controller.check(false)).toBe(true); + expect(starts()).toBe(0); + }); + + test("concurrent taps share one consent request and never regress a downloaded event", async () => { + let resolve!: (result: GooglePlayUpdateResult) => void; + let starts = 0; + const controller = new MobilePlayUpdate({ + check: async () => available, + start: () => { starts += 1; return new Promise((done) => { resolve = done; }); }, + }, () => {}); + await controller.check(false); + const first = controller.start(); + const second = controller.start(); + controller.receive({ ...available, status: "ready" }); + resolve("accepted"); + await Promise.all([first, second]); + expect(starts).toBe(1); + expect(controller.state.status).toBe("ready"); + }); + + test("a stale check cannot overwrite a newer download event", async () => { + let resolve!: (state: GooglePlayUpdateState) => void; + const controller = new MobilePlayUpdate({ + check: () => new Promise((done) => { resolve = done; }), + start: async () => "accepted", + }, () => {}); + const pending = controller.check(false); + controller.receive({ ...available, status: "ready" }); + resolve(unavailable); + expect(await pending).toBe(true); + expect(controller.state.status).toBe("ready"); + }); + + test("download failure leaves the Play state so manual update sources can take over", async () => { + const { controller } = setup(); + await controller.check(true); + controller.receive({ ...available, status: "unavailable" }); + expect(controller.state.status).toBe("unavailable"); + }); +}); diff --git a/apps/mobile/src/lib/mobile-play-update.ts b/apps/mobile/src/lib/mobile-play-update.ts new file mode 100644 index 00000000..a18c93ee --- /dev/null +++ b/apps/mobile/src/lib/mobile-play-update.ts @@ -0,0 +1,50 @@ +import type { GooglePlayUpdateResult, GooglePlayUpdateState } from "../../modules/edgeever-app-store"; + +type PlayUpdateApi = { + check: () => Promise; + start: () => Promise; +}; + +/** Owns Play consent and state recovery independently of GitHub release availability. */ +export class MobilePlayUpdate { + state: GooglePlayUpdateState = { status: "unavailable", versionCode: 0 }; + hasKnownUpdate = false; + private promptedVersions = new Set(); + private starting: Promise | null = null; + private revision = 0; + + constructor(private api: PlayUpdateApi, private onState: (state: GooglePlayUpdateState) => void) {} + + receive = (state: GooglePlayUpdateState) => { + this.revision += 1; + this.state = state; + if (state.status !== "unavailable") this.hasKnownUpdate = true; + this.onState(state); + }; + + async check(prompt: boolean): Promise { + const revision = this.revision; + const state = await this.api.check(); + // An install event received during the request is more recent than its snapshot. + if (revision === this.revision) this.receive(state); + if (prompt && this.state.status === "available" && !this.promptedVersions.has(this.state.versionCode)) { + await this.start(); + } + return this.state.status !== "unavailable"; + } + + start(): Promise { + if (this.starting) return this.starting; + this.promptedVersions.add(this.state.versionCode); + const revision = this.revision; + this.starting = this.api.start().then((result) => { + if (result === "unavailable") { + this.receive({ ...this.state, status: "unavailable" }); + } else if (result === "accepted" && revision === this.revision) { + this.receive({ ...this.state, status: "downloading" }); + } + return result; + }).finally(() => { this.starting = null; }); + return this.starting; + } +} diff --git a/apps/mobile/src/lib/mobile-update.tsx b/apps/mobile/src/lib/mobile-update.tsx index 9e9d239c..8537072d 100644 --- a/apps/mobile/src/lib/mobile-update.tsx +++ b/apps/mobile/src/lib/mobile-update.tsx @@ -3,7 +3,8 @@ import Constants from "expo-constants"; import { AppState, Linking, Platform, type AppStateStatus } from "react-native"; import * as Updates from "expo-updates"; import { Alert } from "../components/LocalizedText"; -import { openGooglePlayDetails } from "../../modules/edgeever-app-store"; +import { googlePlayUpdates, openGooglePlayDetails } from "../../modules/edgeever-app-store"; +import { MobilePlayUpdate } from "./mobile-play-update"; import { useMobileLocale } from "./mobile-locale"; import { ANDROID_INSTALL_UPDATE_SOURCES, @@ -16,7 +17,7 @@ import { openMobileInstallUpdateSource } from "./mobile-update-destination"; const FOREGROUND_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; type MobileUpdateStatus = "idle" | "checking" | "available" | "downloading" | "ready"; -export type MobileUpdateKind = "install" | "ota"; +export type MobileUpdateKind = "install" | "ota" | "play"; type MobileUpdateContextValue = { checkForUpdate: () => Promise; @@ -46,7 +47,21 @@ export const MobileUpdateProvider = ({ children }: { children: ReactNode }) => { const [updateKind, setUpdateKind] = useState(null); const activeCheckRef = useRef | null>(null); const lastAutomaticCheckRef = useRef(0); - const isSupported = !__DEV__ && Updates.isEnabled; + const playActiveRef = useRef(false); + const playUpdate = useMemo(() => new MobilePlayUpdate(googlePlayUpdates, (state) => { + if (state.status === "unavailable") { + if (playActiveRef.current) { + playActiveRef.current = false; + setUpdateKind("install"); + setStatus("available"); + } + return; + } + playActiveRef.current = true; + setUpdateKind("play"); + setStatus(state.status); + }), []); + const isSupported = !__DEV__ && (Platform.OS === "android" || Updates.isEnabled); const english = resolvedLocale !== "zh-CN"; const installedVersion = Updates.runtimeVersion ?? Constants.expoConfig?.version ?? null; @@ -105,7 +120,7 @@ export const MobileUpdateProvider = ({ children }: { children: ReactNode }) => { }); }, [english, showLinkError]); - const openInstallUpdateOptions = useCallback((release: MobileRelease) => { + const openInstallUpdateOptions = useCallback((release: MobileRelease | null) => { const googlePlay = ANDROID_INSTALL_UPDATE_SOURCES.find((source) => source.id === "google-play"); const github = ANDROID_INSTALL_UPDATE_SOURCES.find((source) => source.id === "github"); if (!googlePlay || !github) { @@ -113,9 +128,11 @@ export const MobileUpdateProvider = ({ children }: { children: ReactNode }) => { } Alert.alert( english ? "Update available" : "发现新版本", - english - ? `EdgeEver ${release.version} is available. Choose where to update.` - : `EdgeEver ${release.version} 已发布,请选择更新渠道。`, + !release + ? (english ? "Choose where to check for an app update." : "请选择检查应用更新的渠道。") + : (english + ? `EdgeEver ${release.version} is available. Choose where to update.` + : `EdgeEver ${release.version} 已发布,请选择更新渠道。`), [ { text: english ? "Later" : "稍后", @@ -155,6 +172,9 @@ export const MobileUpdateProvider = ({ children }: { children: ReactNode }) => { const check = (async () => { try { setStatus("checking"); + if (Platform.OS === "android" && await playUpdate.check(!userInitiated)) { + return; + } if (Platform.OS === "android") { try { @@ -171,9 +191,17 @@ export const MobileUpdateProvider = ({ children }: { children: ReactNode }) => { } catch { // Fall back to Expo's in-app update check when the release API is unavailable. } + // Play can publish before GitHub. A failed native flow must still leave + // manual destinations available even when the release API has no update. + if (playUpdate.hasKnownUpdate) { + setInstallRelease(null); + setUpdateKind("install"); + setStatus("available"); + return; + } } - const result = await Updates.checkForUpdateAsync(); + const result = Updates.isEnabled ? await Updates.checkForUpdateAsync() : { isAvailable: false }; if (!result.isAvailable) { setInstallRelease(null); @@ -196,7 +224,39 @@ export const MobileUpdateProvider = ({ children }: { children: ReactNode }) => { activeCheckRef.current = null; }); return check; - }, [english, installedVersion, isSupported]); + }, [english, installedVersion, isSupported, playUpdate]); + + const completePlayUpdate = useCallback(async () => { + if (await googlePlayUpdates.complete()) return; + Alert.alert( + english ? "Could not install update" : "无法安装更新", + english ? "Try again, or use an existing update channel." : "请重试,或使用现有更新渠道。", + [ + { text: english ? "Later" : "稍后", style: "cancel" }, + { text: english ? "Choose update source" : "选择更新渠道", onPress: () => openInstallUpdateOptions(installRelease) }, + ] + ); + }, [english, installRelease, openInstallUpdateOptions]); + + useEffect(() => { + if (Platform.OS !== "android" || !isSupported) return; + const subscription = googlePlayUpdates.subscribe(playUpdate.receive); + return () => subscription.remove(); + }, [isSupported, playUpdate]); + + const readyPromptVersionRef = useRef(null); + useEffect(() => { + if (updateKind !== "play" || status !== "ready" || readyPromptVersionRef.current === playUpdate.state.versionCode) return; + readyPromptVersionRef.current = playUpdate.state.versionCode; + Alert.alert( + english ? "Update ready" : "更新已就绪", + english ? "Restart now to install the Google Play update, or continue using the app." : "重启即可安装 Google Play 更新,也可以稍后继续使用。", + [ + { text: english ? "Later" : "稍后", style: "cancel" }, + { text: english ? "Restart" : "立即重启", onPress: () => { void completePlayUpdate(); } }, + ] + ); + }, [completePlayUpdate, english, playUpdate, status, updateKind]); useEffect(() => { const attemptAutomaticCheck = () => { @@ -209,7 +269,14 @@ export const MobileUpdateProvider = ({ children }: { children: ReactNode }) => { const timer = setTimeout(attemptAutomaticCheck, 1_500); const subscription = AppState.addEventListener("change", (nextState: AppStateStatus) => { if (nextState === "active") { - attemptAutomaticCheck(); + if (Platform.OS === "android" && isSupported) { + // Recover downloads even during the normal six-hour check cooldown. + void playUpdate.check(false).then((handled) => { + if (!handled || playUpdate.state.status === "available") attemptAutomaticCheck(); + }); + } else { + attemptAutomaticCheck(); + } } }); @@ -217,11 +284,20 @@ export const MobileUpdateProvider = ({ children }: { children: ReactNode }) => { clearTimeout(timer); subscription.remove(); }; - }, [runCheck]); + }, [isSupported, playUpdate, runCheck]); const openUpdate = useCallback(async () => { + if (updateKind === "play") { + if (status === "ready") { + await completePlayUpdate(); + } else if (status !== "downloading") { + const result = await playUpdate.start(); + if (result === "unavailable") openInstallUpdateOptions(installRelease); + } + return; + } if (updateKind === "install") { - if (Platform.OS !== "android" || !installRelease) { + if (Platform.OS !== "android") { return; } openInstallUpdateOptions(installRelease); @@ -277,7 +353,7 @@ export const MobileUpdateProvider = ({ children }: { children: ReactNode }) => { : "无法下载应用内更新,请稍后再试。" ); } - }, [english, installRelease, isSupported, openInstallUpdateOptions, status, updateKind]); + }, [completePlayUpdate, english, installRelease, isSupported, openInstallUpdateOptions, playUpdate, status, updateKind]); const hasUpdate = status === "available" || status === "ready" || status === "downloading"; const value = useMemo( diff --git a/apps/mobile/src/screens/WorkspaceSettingsView.tsx b/apps/mobile/src/screens/WorkspaceSettingsView.tsx index b09808a7..87886b52 100644 --- a/apps/mobile/src/screens/WorkspaceSettingsView.tsx +++ b/apps/mobile/src/screens/WorkspaceSettingsView.tsx @@ -426,6 +426,8 @@ const SystemInfoCard = ({ ? (english ? "Restart to apply" : "重启以应用") : updateKind === "ota" ? (english ? "Download update" : "下载更新") + : updateKind === "play" + ? (english ? "Update with Google Play" : "通过 Google Play 更新") : copy.openUpdate; const copySystemInfo = async () => { diff --git a/docs/mobile-build.md b/docs/mobile-build.md index 88d6c166..b4f23127 100644 --- a/docs/mobile-build.md +++ b/docs/mobile-build.md @@ -15,6 +15,24 @@ bunx eas-cli update --channel production --environment production --message "Des The update runtime uses the app-version policy. Any native dependency, Expo SDK, native module, or native configuration change requires incrementing `expo.version` and shipping a new store build before publishing code that depends on that native change. EAS Update does not replace Google Play or App Store updates for native binaries. +### Google Play Flexible Updates + +Android release builds first query Google's Play In-App Updates SDK. When Play offers a flexible update, the automatic check opens Google's native consent dialog. Downloads run in the background; the app asks before completing installation and restarting. Declining consent suppresses further automatic prompts for that version during the current app session, while the settings update action allows a manual retry. Manual checks discover the update without opening the consent dialog until the user selects the update action. + +Play download state is checked on every return to the foreground, including during the ordinary six-hour check cooldown. Downloaded updates remain available through **Restart to apply** if the user dismisses the restart prompt. Failed or unavailable Play flows retain the existing Google Play listing and GitHub update destinations; compatible EAS updates remain a separate fallback. Old binaries without the native SDK safely skip this integration when receiving compatible JavaScript. + +This native SDK addition requires a new Android binary and a new app runtime version at release time. Existing binaries cannot gain the native dialog through OTA alone. A GitHub release does not mean a Play update is available: Play decides eligibility using its own distribution state, account ownership, application ID, signing certificate, and higher version code. See [Google's testing requirements](https://developer.android.com/guide/playcore/in-app-updates/test). + +Before publishing this update-path change, validate on a USB-connected physical Android device using a Play test track or internal app sharing: + +1. Install an older build that already includes this SDK integration, then make a higher-version-code build available to the same account through Play. For internal app sharing, follow Google's link-opening procedure without installing the newer build from its listing. +2. Confirm the native consent dialog, cancellation without repeated automatic prompts, and manual retry. +3. Accept the flexible update and continue editing during download. Background and foreground the app, and restart the process to verify download recovery. +4. Dismiss the ready prompt, then use **Restart to apply**. Confirm the new native version launches and existing notes and pending edits remain intact. +5. Verify the existing manual destinations on a device/account without an eligible Play update, plus download/installation failure recovery and the older-native-binary OTA fallback. + +Compilation and automated state-machine tests do not replace this real old-to-new installation gate. Until these steps pass, Play installation, process recovery, signing compatibility, and data preservation across this update remain unverified. + ## Main-Branch Android APK The `Build EdgeEver Mobile` workflow runs on GitHub Actions and produces a