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

1""" 

2Native app update decisions for CheckNativeStatus. 

3""" 

4 

5import enum 

6import logging 

7import uuid 

8from dataclasses import dataclass 

9from datetime import UTC, datetime, timedelta 

10 

11from couchers.context import CouchersContext 

12from couchers.proto import bugs_pb2 

13 

14logger = logging.getLogger(__name__) 

15 

16 

17DEFAULT_OTA_WARN_DAYS = 21.0 

18DEFAULT_OTA_BLOCK_DAYS = 28.0 

19DEFAULT_STORE_WARN_DAYS = 70.0 

20DEFAULT_STORE_BLOCK_DAYS = 91.0 

21 

22 

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 

28 

29 

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() 

38 

39 

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() 

46 

47 

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 

57 

58 

59@dataclass(frozen=True) 

60class NativeUpdateDecision: 

61 action: UpdateAction 

62 severity: Severity 

63 act_by: datetime | None 

64 cause: UpdateCause 

65 

66 

67_NO_UPDATE = NativeUpdateDecision( 

68 action=UpdateAction.none, severity=Severity.none, act_by=None, cause=UpdateCause.unspecified 

69) 

70 

71 

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 

77 

78 # launch_source is authoritative; is_embedded_launch is wire-level diagnostics only. 

79 is_ota_launch = request.launch_source == "ota" 

80 

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 

85 

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 ) 

95 

96 

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 

105 

106 

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 ) 

123 

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)) 

128 

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) 

134 

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) 

140 

141 severity = Severity(max(store_state, ota_state)) 

142 if severity == Severity.none: 

143 return _NO_UPDATE 

144 

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)