2020import io .github .vvb2060 .keyattestation .AppApplication ;
2121import io .github .vvb2060 .keyattestation .R ;
2222
23- public record RevocationList (String status , String reason ) {
23+ public record RevocationList (String status , String reason , DataSource source ) {
24+ public enum DataSource {
25+ NETWORK_UPDATE ,
26+ NETWORK_UP_TO_DATE ,
27+ CACHE ,
28+ BUNDLED
29+ }
30+
2431 private static final String TAG = "RevocationList" ;
2532 private static final String CACHE_FILE = "revocation_cache.json" ;
2633 private static final String PREFS_NAME = "revocation_prefs" ;
2734 private static final String KEY_PUBLISH_TIME = "last_publish_time" ;
35+
2836 private static JSONObject data = null ;
2937 private static Date publishTime = null ;
38+ private static DataSource currentSource = DataSource .BUNDLED ;
39+
40+ private record StatusResult (JSONObject json , DataSource source ) {}
41+ private record NetworkResult (JSONObject json , int responseCode ) {}
3042
3143 private static String toString (InputStream input ) throws IOException {
3244 if (Build .VERSION .SDK_INT >= Build .VERSION_CODES .TIRAMISU ) {
@@ -43,16 +55,15 @@ private static String toString(InputStream input) throws IOException {
4355
4456 private static JSONObject parseStatus (InputStream inputStream ) throws IOException {
4557 try {
46- var statusListJson = new JSONObject (toString (inputStream ));
47- return statusListJson .getJSONObject ("entries" );
58+ return new JSONObject (toString (inputStream ));
4859 } catch (JSONException e ) {
4960 throw new IOException (e );
5061 }
5162 }
5263
53- private static void saveToCache (JSONObject json ) {
64+ private static void saveToCache (JSONObject fullJson ) {
5465 try (var output = AppApplication .app .openFileOutput (CACHE_FILE , Context .MODE_PRIVATE )) {
55- output .write (json .toString ().getBytes (StandardCharsets .UTF_8 ));
66+ output .write (fullJson .toString ().getBytes (StandardCharsets .UTF_8 ));
5667 if (publishTime != null ) {
5768 var prefs = AppApplication .app .getSharedPreferences (PREFS_NAME , Context .MODE_PRIVATE );
5869 prefs .edit ().putLong (KEY_PUBLISH_TIME , publishTime .getTime ()).apply ();
@@ -62,7 +73,7 @@ private static void saveToCache(JSONObject json) {
6273 }
6374 }
6475
65- private static JSONObject fetchFromNetwork (String statusUrl ) {
76+ private static NetworkResult fetchFromNetwork (String statusUrl , long cachedTime ) {
6677 HttpURLConnection connection = null ;
6778 try {
6879 URL url = new URL (statusUrl );
@@ -72,108 +83,137 @@ private static JSONObject fetchFromNetwork(String statusUrl) {
7283 connection .setReadTimeout (10000 );
7384 connection .setRequestProperty ("User-Agent" , "KeyAttestation" );
7485
86+ if (cachedTime != 0 ) {
87+ connection .setIfModifiedSince (cachedTime );
88+ }
89+
7590 int responseCode = connection .getResponseCode ();
91+
92+ if (responseCode == HttpURLConnection .HTTP_NOT_MODIFIED ) {
93+ return new NetworkResult (null , responseCode );
94+ }
95+
7696 if (responseCode == HttpURLConnection .HTTP_OK ) {
7797 long lastModified = connection .getLastModified ();
7898 if (lastModified != 0 ) {
7999 publishTime = new Date (lastModified );
80- Log .i (TAG , "Revocation list Last-Modified: " + publishTime );
81100 }
82101
83102 try (var input = connection .getInputStream ()) {
84- return parseStatus (input );
103+ return new NetworkResult ( parseStatus (input ), responseCode );
85104 }
86- } else {
87- Log .w (TAG , "Failed to fetch revocation list from network, HTTP " + responseCode );
88- return null ;
89105 }
106+ return null ;
90107 } catch (Exception e ) {
91- Log .w (TAG , "Failed to fetch revocation list from network " , e );
108+ Log .w (TAG , "Network fetch failed " , e );
92109 return null ;
93110 } finally {
94- if (connection != null ) {
95- connection .disconnect ();
96- }
111+ if (connection != null ) connection .disconnect ();
97112 }
98113 }
99114
100- private static JSONObject getStatus () {
115+ private static StatusResult getStatus () {
101116 var statusUrl = "https://android.googleapis.com/attestation/status" ;
102- var resName = "android:string/vendor_required_attestation_revocation_list_url" ;
103117 var res = AppApplication .app .getResources ();
104- // noinspection DiscouragedApi
118+ var resName = "android:string/vendor_required_attestation_revocation_list_url" ;
105119 var id = res .getIdentifier (resName , null , null );
106120 if (id != 0 ) {
107121 var url = res .getString (id );
108122 if (!statusUrl .equals (url ) && url .toLowerCase (Locale .ROOT ).startsWith ("https" )) {
109123 statusUrl = url ;
110124 }
111125 }
126+
127+ var prefs = AppApplication .app .getSharedPreferences (PREFS_NAME , Context .MODE_PRIVATE );
128+ long cachedTime = prefs .getLong (KEY_PUBLISH_TIME , 0 );
112129
113- // 1. Try Network
114- JSONObject networkData = fetchFromNetwork (statusUrl );
115- if (networkData != null ) {
116- Log .i (TAG , "Successfully fetched revocation list from network" );
117- saveToCache (networkData );
118- return networkData ;
130+ // 1. Network Check
131+ NetworkResult networkResult = fetchFromNetwork (statusUrl , cachedTime );
132+ if (networkResult != null ) {
133+ if (networkResult .responseCode () == HttpURLConnection .HTTP_NOT_MODIFIED ) {
134+ try (var fis = AppApplication .app .openFileInput (CACHE_FILE )) {
135+ var cacheJson = parseStatus (fis );
136+ publishTime = new Date (cachedTime );
137+ return new StatusResult (cacheJson .getJSONObject ("entries" ), DataSource .NETWORK_UP_TO_DATE );
138+ } catch (Exception e ) {
139+ Log .w (TAG , "Failed to read cache despite 304 response. Falling back." , e );
140+ }
141+ } else if (networkResult .json () != null ) {
142+ saveToCache (networkResult .json ());
143+ try {
144+ return new StatusResult (networkResult .json ().getJSONObject ("entries" ), DataSource .NETWORK_UPDATE );
145+ } catch (JSONException ignored ) {}
146+ }
119147 }
120148
121- // 2. Try Cache (osm0sis fallback)
149+ // 2. Cache
122150 try (var fis = AppApplication .app .openFileInput (CACHE_FILE )) {
123- Log .i (TAG , "Using cached revocation list" );
124- var prefs = AppApplication .app .getSharedPreferences (PREFS_NAME , Context .MODE_PRIVATE );
125- long lastTime = prefs .getLong (KEY_PUBLISH_TIME , 0 );
126- if (lastTime != 0 ) publishTime = new Date (lastTime );
127- return parseStatus (fis );
128- } catch (IOException e ) {
129- Log .i (TAG , "No cached revocation list found" );
151+ var cacheJson = parseStatus (fis );
152+ if (cachedTime != 0 ) publishTime = new Date (cachedTime );
153+ return new StatusResult (cacheJson .getJSONObject ("entries" ), DataSource .CACHE );
154+ } catch (Exception e ) {
155+ Log .i (TAG , "Cache unavailable" );
130156 }
131157
132- // 3. Fallback to bundled resource
133- Log .i (TAG , "Using bundled revocation list" );
158+ // 3. Bundled
134159 try (var input = res .openRawResource (R .raw .status )) {
135- return parseStatus (input );
136- } catch (IOException e ) {
137- throw new RuntimeException ("Failed to parse certificate revocation status" , e );
160+ var bundledJson = parseStatus (input );
161+ publishTime = null ;
162+ return new StatusResult (bundledJson .getJSONObject ("entries" ), DataSource .BUNDLED );
163+ } catch (Exception e ) {
164+ throw new RuntimeException ("Critical: Failed to load revocation data" , e );
138165 }
139166 }
140167
141168 public static Date getPublishTime () {
142169 return publishTime ;
143170 }
144171
172+ public static DataSource getCurrentSource () {
173+ return currentSource ;
174+ }
175+
145176 public static void refresh () {
146177 synchronized (RevocationList .class ) {
147- data = getStatus ();
178+ StatusResult result = getStatus ();
179+ data = result .json ();
180+
181+ // If we successfully fetched a brand new file this session,
182+ // don't let a subsequent UI refresh overwrite our status with a 304!
183+ if (currentSource == DataSource .NETWORK_UPDATE && result .source () == DataSource .NETWORK_UP_TO_DATE ) {
184+ Log .i (TAG , "Preserving NETWORK_UPDATE status across multiple refreshes" );
185+ } else {
186+ currentSource = result .source ();
187+ }
148188 }
149189 }
150190
151191 public static RevocationList get (BigInteger serialNumber ) {
152192 if (data == null ) {
153193 synchronized (RevocationList .class ) {
154194 if (data == null ) {
155- data = getStatus ();
195+ StatusResult result = getStatus ();
196+ data = result .json ();
197+
198+ if (currentSource == DataSource .NETWORK_UPDATE && result .source () == DataSource .NETWORK_UP_TO_DATE ) {
199+ Log .i (TAG , "Preserving NETWORK_UPDATE status in get()" );
200+ } else {
201+ currentSource = result .source ();
202+ }
156203 }
157204 }
158205 }
159- String serialNumberString = serialNumber .toString (16 ).toLowerCase ();
160- JSONObject revocationStatus ;
206+ String serial = serialNumber .toString (16 ).toLowerCase ();
161207 try {
162- revocationStatus = data .getJSONObject (serialNumberString );
208+ JSONObject entry = data .getJSONObject (serial );
209+ return new RevocationList (entry .getString ("status" ), entry .getString ("reason" ), currentSource );
163210 } catch (JSONException e ) {
164211 return null ;
165212 }
166- try {
167- var status = revocationStatus .getString ("status" );
168- var reason = revocationStatus .getString ("reason" );
169- return new RevocationList (status , reason );
170- } catch (JSONException e ) {
171- return new RevocationList ("" , "" );
172- }
173213 }
174214
175215 @ Override
176216 public String toString () {
177- return "status is " + status + ", reason is " + reason ;
217+ return "status: " + status + ", source: " + source ;
178218 }
179219}
0 commit comments