Coverage for app/backend/src/couchers/native_updates.py: 100%
76 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-16 17:03 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-16 17:03 +0000
1"""
2Native app update decisions for CheckNativeStatus.
3"""
5import enum
6import logging
7import uuid
8from dataclasses import dataclass
9from datetime import UTC, datetime, timedelta
11from couchers.context import CouchersContext
12from couchers.proto import bugs_pb2
14logger = logging.getLogger(__name__)
17DEFAULT_OTA_WARN_DAYS = 21.0
18DEFAULT_OTA_BLOCK_DAYS = 28.0
19DEFAULT_STORE_WARN_DAYS = 70.0
20DEFAULT_STORE_BLOCK_DAYS = 91.0
23class Severity(enum.IntEnum):
24 # Ordered by severity so max() picks the worst across the two clocks.
25 none = 0
26 warn = 1
27 block = 2
30class UpdateAction(enum.Enum):
31 unspecified = enum.auto()
32 none = enum.auto()
33 ota = enum.auto()
34 store = enum.auto()
35 # Reserved for a future nuke path (delete and reinstall the app). Not produced by the current
36 # decision logic — no signal feeds it.
37 reinstall = enum.auto()
40class UpdateCause(enum.Enum):
41 unspecified = enum.auto()
42 # Bundle or binary is past its support window — user is on an old version.
43 age = enum.auto()
44 # Currently-running bundle is banned — we shipped a buggy version.
45 banned = enum.auto()
48@dataclass(frozen=True, kw_only=True)
49class NativeClientInfo:
50 eas_client_id: uuid.UUID
51 platform: str = ""
52 runtime_version: str = ""
53 update_id: str | None = None
54 is_ota_launch: bool = False
55 binary_created_at: datetime | None = None
56 bundle_created_at: datetime | None = None
59@dataclass(frozen=True)
60class NativeUpdateDecision:
61 action: UpdateAction
62 severity: Severity
63 act_by: datetime | None
64 cause: UpdateCause
67_NO_UPDATE = NativeUpdateDecision(
68 action=UpdateAction.none, severity=Severity.none, act_by=None, cause=UpdateCause.unspecified
69)
72def client_info_from_request(request: bugs_pb2.CheckNativeStatusReq) -> NativeClientInfo:
73 update_id = request.update_id or None
74 # "none" is the placeholder the client sends when expo-updates has no current updateId.
75 if update_id == "none":
76 update_id = None
78 # launch_source is authoritative; is_embedded_launch is wire-level diagnostics only.
79 is_ota_launch = request.launch_source == "ota"
81 binary_created_at = (
82 request.embedded_created_at.ToDatetime(tzinfo=UTC) if request.HasField("embedded_created_at") else None
83 )
84 bundle_created_at = request.created_at.ToDatetime(tzinfo=UTC) if request.HasField("created_at") else None
86 return NativeClientInfo(
87 platform=request.platform,
88 runtime_version=request.runtime_version,
89 update_id=update_id,
90 is_ota_launch=is_ota_launch,
91 binary_created_at=binary_created_at,
92 bundle_created_at=bundle_created_at,
93 eas_client_id=uuid.UUID(request.eas_client_id),
94 )
97def _clock_state(age: timedelta, warn: timedelta, block: timedelta) -> Severity:
98 if block <= timedelta(0):
99 return Severity.none
100 if age >= block:
101 return Severity.block
102 if warn > timedelta(0) and age >= warn:
103 return Severity.warn
104 return Severity.none
107def decide_native_update(
108 context: CouchersContext,
109 info: NativeClientInfo,
110 now: datetime,
111 *,
112 banned: bool = False,
113) -> NativeUpdateDecision:
114 # A device running a banned OTA bundle is blocked immediately, ahead of the age clocks. The
115 # banned ban only stops new check-ins being served the bundle; this is what forces the devices
116 # already on it to move. act_by is left unset: per the proto contract the client treats an
117 # unset deadline as block-now, which avoids a tiny clock-skew window where a now-timestamp
118 # would land slightly in the client's future and read as warn.
119 if banned and info.is_ota_launch:
120 return NativeUpdateDecision(
121 action=UpdateAction.ota, severity=Severity.block, act_by=None, cause=UpdateCause.banned
122 )
124 store_warn = timedelta(days=context.get_float_value("native_store_warn_days", DEFAULT_STORE_WARN_DAYS))
125 store_block = timedelta(days=context.get_float_value("native_store_block_days", DEFAULT_STORE_BLOCK_DAYS))
126 ota_warn = timedelta(days=context.get_float_value("native_ota_warn_days", DEFAULT_OTA_WARN_DAYS))
127 ota_block = timedelta(days=context.get_float_value("native_ota_block_days", DEFAULT_OTA_BLOCK_DAYS))
129 store_state = Severity.none
130 store_deadline: datetime | None = None
131 if info.binary_created_at is not None:
132 store_deadline = info.binary_created_at + store_block
133 store_state = _clock_state(now - info.binary_created_at, store_warn, store_block)
135 ota_state = Severity.none
136 ota_deadline: datetime | None = None
137 if info.is_ota_launch and info.bundle_created_at is not None:
138 ota_deadline = info.bundle_created_at + ota_block
139 ota_state = _clock_state(now - info.bundle_created_at, ota_warn, ota_block)
141 severity = Severity(max(store_state, ota_state))
142 if severity == Severity.none:
143 return _NO_UPDATE
145 # Store precedence at equal severity: a failing binary cannot be rescued by an OTA.
146 if store_state == severity:
147 return NativeUpdateDecision(
148 action=UpdateAction.store, severity=severity, act_by=store_deadline, cause=UpdateCause.age
149 )
150 return NativeUpdateDecision(action=UpdateAction.ota, severity=severity, act_by=ota_deadline, cause=UpdateCause.age)