feat(android): add Google Play flexible in-app updates
Use the native Play consent flow, track background downloads, and request confirmation before installation. Preserve manual Play and GitHub destinations when the native flow is unavailable, including older binaries without the SDK. Validation: 20 update tests, root and mobile typechecks, Web build, Android Kotlin compilation, and signed release APK build passed. Real Play old-to-new installation verification remains incomplete and is deferred at the maintainer's request.
This commit is contained in:
1 parent
cb13c07592
commit
34ef8b2efc
9 files changed
+418
-13
No files matched your search
@@ -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 {
|
||||
|
||||
+95
@@ -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
|
||||
|
||||
@@ -4,6 +4,55 @@ export type GooglePlayOpenResult = "disabled" | "not-installed" | "opened" | "un
|
||||
|
||||
type EdgeEverAppStoreNativeModule = {
|
||||
openGooglePlayDetails: (applicationId: string) => Promise<GooglePlayOpenResult | boolean>;
|
||||
getGooglePlayUpdate?: () => Promise<GooglePlayUpdateState>;
|
||||
startGooglePlayUpdate?: () => Promise<GooglePlayUpdateResult>;
|
||||
completeGooglePlayUpdate?: () => Promise<boolean>;
|
||||
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<GooglePlayUpdateState> {
|
||||
try {
|
||||
return await requireNativeModule<EdgeEverAppStoreNativeModule>("EdgeEverAppStore")
|
||||
.getGooglePlayUpdate?.() ?? { status: "unavailable", versionCode: 0 };
|
||||
} catch {
|
||||
return { status: "unavailable", versionCode: 0 };
|
||||
}
|
||||
},
|
||||
async start(): Promise<GooglePlayUpdateResult> {
|
||||
try {
|
||||
return await requireNativeModule<EdgeEverAppStoreNativeModule>("EdgeEverAppStore")
|
||||
.startGooglePlayUpdate?.() ?? "unavailable";
|
||||
} catch {
|
||||
return "unavailable";
|
||||
}
|
||||
},
|
||||
async complete(): Promise<boolean> {
|
||||
try {
|
||||
return await requireNativeModule<EdgeEverAppStoreNativeModule>("EdgeEverAppStore")
|
||||
.completeGooglePlayUpdate?.() ?? false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
subscribe(listener: (state: GooglePlayUpdateState) => void): { remove: () => void } {
|
||||
try {
|
||||
const module = requireNativeModule<EdgeEverAppStoreNativeModule>("EdgeEverAppStore");
|
||||
return module.getGooglePlayUpdate
|
||||
? module.addListener("onGooglePlayUpdate", listener)
|
||||
: { remove() {} };
|
||||
} catch {
|
||||
return { remove() {} };
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export const openGooglePlayDetails = async (applicationId: string): Promise<GooglePlayOpenResult> => {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { GooglePlayUpdateResult, GooglePlayUpdateState } from "../../modules/edgeever-app-store";
|
||||
|
||||
type PlayUpdateApi = {
|
||||
check: () => Promise<GooglePlayUpdateState>;
|
||||
start: () => Promise<GooglePlayUpdateResult>;
|
||||
};
|
||||
|
||||
/** 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<number>();
|
||||
private starting: Promise<GooglePlayUpdateResult> | 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<boolean> {
|
||||
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<GooglePlayUpdateResult> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<void>;
|
||||
@@ -46,7 +47,21 @@ export const MobileUpdateProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [updateKind, setUpdateKind] = useState<MobileUpdateKind | null>(null);
|
||||
const activeCheckRef = useRef<Promise<void> | 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<number | null>(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<MobileUpdateContextValue>(
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user