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

1""" 

2Native app update decisions for CheckNativeStatus. 

3""" 

4 

5import enum 

6import logging 

7from dataclasses import dataclass 

8from datetime import UTC, datetime, timedelta 

9 

10from couchers.context import CouchersContext 

11from couchers.proto import bugs_pb2 

12 

13logger = logging.getLogger(__name__) 

14 

15 

16DEFAULT_OTA_WARN_DAYS = 21 

17DEFAULT_OTA_BLOCK_DAYS = 28 

18DEFAULT_STORE_WARN_DAYS = 70 

19DEFAULT_STORE_BLOCK_DAYS = 91 

20 

21 

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 

27 

28 

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

37 

38 

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

45 

46 

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 

55 

56 

57@dataclass(frozen=True) 

58class NativeUpdateDecision: 

59 action: UpdateAction 

60 severity: Severity 

61 act_by: datetime | None 

62 cause: UpdateCause 

63 

64 

65_NO_UPDATE = NativeUpdateDecision( 

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

67) 

68 

69 

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 

75 

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

77 is_ota_launch = request.launch_source == "ota" 

78 

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 

83 

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 ) 

92 

93 

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 

102 

103 

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 ) 

120 

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

125 

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) 

131 

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) 

137 

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

139 if severity == Severity.none: 

140 return _NO_UPDATE 

141 

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)