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