Skip to content

Commit b466db6

Browse files
committed
Optimized over-the-network CRL & auto-refresh
- Added CRL Manager for proper network-aware management of the Revocation List. - Fixed RevocationList to instantly fallback to local cache on network error or timeout. Τhis fixes this issue: #16
1 parent b141c10 commit b466db6

5 files changed

Lines changed: 194 additions & 94 deletions

File tree

app/src/main/java/io/github/vvb2060/keyattestation/AppApplication.kt

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import android.content.pm.PackageManager
77
import android.widget.Toast
88
import androidx.arch.core.executor.ArchTaskExecutor
99
import io.github.vvb2060.keyattestation.keystore.KeyStoreManager
10+
import io.github.vvb2060.keyattestation.util.CrlManager
1011
import io.github.vvb2060.keyattestation.util.LocaleManager
1112
import org.bouncycastle.jce.provider.BouncyCastleProvider
1213
import rikka.html.text.HtmlCompat
@@ -39,14 +40,7 @@ class AppApplication : Application() {
3940
HtmlCompat.setContext(this)
4041
installProvider(this)
4142

42-
// Initialize revocation list in background to fetch latest from network
43-
executor.execute {
44-
try {
45-
io.github.vvb2060.keyattestation.attestation.RevocationList.refresh()
46-
} catch (e: Exception) {
47-
android.util.Log.w(TAG, "Failed to initialize revocation list", e)
48-
}
49-
}
43+
CrlManager.install(this)
5044

5145
if (Sui.init(BuildConfig.APPLICATION_ID)) {
5246
KeyStoreManager.requestPermission();

app/src/main/java/io/github/vvb2060/keyattestation/attestation/RevocationList.java

Lines changed: 118 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
import android.content.Context;
44
import android.os.Build;
5+
import android.os.Handler;
6+
import android.os.Looper;
57
import android.util.Log;
68

79
import org.json.JSONException;
@@ -16,12 +18,16 @@
1618
import java.nio.charset.StandardCharsets;
1719
import java.util.Date;
1820
import java.util.Locale;
21+
import java.util.concurrent.ExecutorService;
22+
import java.util.concurrent.Executors;
23+
import java.util.concurrent.Future;
24+
import java.util.concurrent.TimeUnit;
1925

2026
import io.github.vvb2060.keyattestation.AppApplication;
2127
import io.github.vvb2060.keyattestation.R;
2228

2329
public record RevocationList(String status, String reason, DataSource source) {
24-
public enum DataSource {
30+
public enum DataSource {
2531
NETWORK_INITIAL,
2632
NETWORK_UPDATE,
2733
NETWORK_UP_TO_DATE,
@@ -38,6 +44,14 @@ public enum DataSource {
3844
private static Date publishTime = null;
3945
private static DataSource currentSource = DataSource.BUNDLED;
4046

47+
private static final ExecutorService asyncExecutor = Executors.newSingleThreadExecutor();
48+
private static final ExecutorService networkExecutor = Executors.newSingleThreadExecutor();
49+
private static final Handler mainHandler = new Handler(Looper.getMainLooper());
50+
51+
public interface OnUpdateListener {
52+
void onUpdateSuccess(DataSource newSource);
53+
}
54+
4155
private record StatusResult(JSONObject json, DataSource source) {}
4256
private record NetworkResult(JSONObject json, int responseCode) {}
4357

@@ -69,6 +83,7 @@ private static void saveToCache(JSONObject fullJson) {
6983
var prefs = AppApplication.app.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
7084
prefs.edit().putLong(KEY_PUBLISH_TIME, publishTime.getTime()).apply();
7185
}
86+
Log.i(TAG, "Local cache file written successfully.");
7287
} catch (IOException e) {
7388
Log.w(TAG, "Failed to cache revocation list", e);
7489
}
@@ -80,25 +95,21 @@ private static NetworkResult fetchFromNetwork(String statusUrl, long cachedTime)
8095
URL url = new URL(statusUrl);
8196
connection = (HttpURLConnection) url.openConnection();
8297
connection.setRequestMethod("GET");
83-
connection.setConnectTimeout(10000);
84-
connection.setReadTimeout(10000);
98+
connection.setConnectTimeout(3000);
99+
connection.setReadTimeout(3000);
85100
connection.setRequestProperty("User-Agent", "KeyAttestation");
86101

87102
double rand = Math.round(Math.random() * 1000.0) / 1000.0;
88-
89-
// Force the CDN to bypass edge caches
90103
connection.setRequestProperty("Cache-Control", "no-cache, no-store, no-transform, max-age=0");
91104
connection.setRequestProperty("Accept", "application/json, */*;q=" + rand);
92105
connection.setRequestProperty("Accept-Encoding", "identity, *;q=" + rand);
93106
connection.setRequestProperty("Accept-Ranges", "bytes");
94107

95-
// Standard conditional GET for bandwidth saving (if the node IS synced)
96108
if (cachedTime != 0) {
97109
connection.setIfModifiedSince(cachedTime);
98110
}
99111

100112
int responseCode = connection.getResponseCode();
101-
102113
if (responseCode == HttpURLConnection.HTTP_NOT_MODIFIED) {
103114
return new NetworkResult(null, responseCode);
104115
}
@@ -108,92 +119,135 @@ private static NetworkResult fetchFromNetwork(String statusUrl, long cachedTime)
108119
if (lastModified != 0) {
109120
publishTime = new Date(lastModified);
110121
}
111-
112122
try (var input = connection.getInputStream()) {
113123
return new NetworkResult(parseStatus(input), responseCode);
114124
}
115125
}
116126
return null;
117127
} catch (Exception e) {
118-
Log.w(TAG, "Network fetch failed", e);
119128
return null;
120129
} finally {
121130
if (connection != null) connection.disconnect();
122131
}
123132
}
124133

125-
private static StatusResult getStatus() {
126-
var statusUrl = "https://android.googleapis.com/attestation/status";
127-
var res = AppApplication.app.getResources();
128-
var resName = "android:string/vendor_required_attestation_revocation_list_url";
129-
var id = res.getIdentifier(resName, null, null);
130-
if (id != 0) {
131-
var url = res.getString(id);
132-
if (!statusUrl.equals(url) && url.toLowerCase(Locale.ROOT).startsWith("https")) {
133-
statusUrl = url;
134-
}
134+
private static NetworkResult fetchNetworkWithTimeout(String url, long cachedTime) {
135+
Future<NetworkResult> future = networkExecutor.submit(() -> fetchFromNetwork(url, cachedTime));
136+
try {
137+
return future.get(3, TimeUnit.SECONDS);
138+
} catch (Exception e) {
139+
Log.w(TAG, "Network fetch dropped gracefully (Hard 3-second DNS/Connection Timeout)");
140+
future.cancel(true);
141+
return null;
135142
}
143+
}
136144

145+
private static StatusResult loadLocalData() {
137146
var prefs = AppApplication.app.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
138147
long cachedTime = prefs.getLong(KEY_PUBLISH_TIME, 0);
139148

140-
// 1. Network Check
141-
NetworkResult networkResult = fetchFromNetwork(statusUrl, cachedTime);
142-
143-
if (networkResult != null && networkResult.responseCode() == HttpURLConnection.HTTP_NOT_MODIFIED) {
144-
try (var fis = AppApplication.app.openFileInput(CACHE_FILE)) {
145-
var cacheJson = parseStatus(fis);
146-
publishTime = new Date(cachedTime);
147-
return new StatusResult(cacheJson.getJSONObject("entries"), DataSource.NETWORK_UP_TO_DATE);
148-
} catch (Exception e) {
149-
Log.w(TAG, "Legacy cache format detected. Clearing and forcing fresh fetch.", e);
150-
151-
// 1. Wipe the old, incompatible cache file
152-
AppApplication.app.deleteFile(CACHE_FILE);
153-
154-
// 2. Wipe the saved timestamp so we don't send If-Modified-Since again
155-
prefs.edit().remove(KEY_PUBLISH_TIME).apply();
156-
157-
// 3. Immediately force a fresh 200 OK download
158-
NetworkResult retryResult = fetchFromNetwork(statusUrl, 0);
159-
160-
if (retryResult != null && retryResult.json() != null) {
161-
saveToCache(retryResult.json());
162-
163-
try {
164-
return new StatusResult(retryResult.json().getJSONObject("entries"), DataSource.NETWORK_UPDATE);
165-
} catch (JSONException je) {
166-
Log.e(TAG, "Failed to parse entries from fresh fallback fetch", je);
167-
}
168-
}
169-
}
170-
} else if (networkResult != null && networkResult.json() != null) {
171-
saveToCache(networkResult.json());
172-
try {
173-
DataSource successState = (cachedTime == 0) ? DataSource.NETWORK_INITIAL : DataSource.NETWORK_UPDATE;
174-
return new StatusResult(networkResult.json().getJSONObject("entries"), successState);
175-
} catch (JSONException ignored) {}
176-
}
177-
178-
// 2. Cache
179149
try (var fis = AppApplication.app.openFileInput(CACHE_FILE)) {
180150
var cacheJson = parseStatus(fis);
181151
if (cachedTime != 0) publishTime = new Date(cachedTime);
152+
Log.i(TAG, "Successfully matched database schemas inside local CACHE storage.");
182153
return new StatusResult(cacheJson.getJSONObject("entries"), DataSource.CACHE);
183154
} catch (Exception e) {
184-
Log.i(TAG, "Cache unavailable");
155+
Log.i(TAG, "Local file cache missing, loading baseline fallback asset.");
185156
}
186157

187-
// 3. Bundled
158+
var res = AppApplication.app.getResources();
188159
try (var input = res.openRawResource(R.raw.status)) {
189160
var bundledJson = parseStatus(input);
190161
publishTime = null;
191162
return new StatusResult(bundledJson.getJSONObject("entries"), DataSource.BUNDLED);
192163
} catch (Exception e) {
193-
throw new RuntimeException("Critical: Failed to load revocation data", e);
164+
throw new RuntimeException("Critical: Baseline resource asset file missing from app payload", e);
194165
}
195166
}
196167

168+
public static void refreshAsync(OnUpdateListener listener) {
169+
asyncExecutor.execute(() -> {
170+
var statusUrl = "https://android.googleapis.com/attestation/status";
171+
var res = AppApplication.app.getResources();
172+
var resName = "android:string/vendor_required_attestation_revocation_list_url";
173+
var id = res.getIdentifier(resName, null, null);
174+
if (id != 0) {
175+
var url = res.getString(id);
176+
if (!statusUrl.equals(url) && url.toLowerCase(Locale.ROOT).startsWith("https")) {
177+
statusUrl = url;
178+
}
179+
}
180+
181+
var prefs = AppApplication.app.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
182+
long cachedTime = prefs.getLong(KEY_PUBLISH_TIME, 0);
183+
184+
Log.i(TAG, "Starting background network sync operation...");
185+
NetworkResult networkResult = fetchNetworkWithTimeout(statusUrl, cachedTime);
186+
DataSource finalSource = null;
187+
JSONObject finalEntries = null;
188+
189+
if (networkResult != null && networkResult.responseCode() == HttpURLConnection.HTTP_NOT_MODIFIED) {
190+
Log.i(TAG, "Network connection reporting 304 Not Modified. Local data streams up to date.");
191+
try (var fis = AppApplication.app.openFileInput(CACHE_FILE)) {
192+
var cacheJson = parseStatus(fis);
193+
publishTime = new Date(cachedTime);
194+
finalEntries = cacheJson.getJSONObject("entries");
195+
finalSource = DataSource.NETWORK_UP_TO_DATE;
196+
} catch (Exception e) {
197+
Log.w(TAG, "Legacy cache format error. Resetting storage contexts.", e);
198+
AppApplication.app.deleteFile(CACHE_FILE);
199+
prefs.edit().remove(KEY_PUBLISH_TIME).apply();
200+
201+
NetworkResult retryResult = fetchNetworkWithTimeout(statusUrl, 0);
202+
if (retryResult != null && retryResult.json() != null) {
203+
saveToCache(retryResult.json());
204+
try {
205+
finalEntries = retryResult.json().getJSONObject("entries");
206+
finalSource = DataSource.NETWORK_UPDATE;
207+
} catch (JSONException ignored) {}
208+
}
209+
}
210+
} else if (networkResult != null && networkResult.json() != null) {
211+
Log.i(TAG, "Network fetch SUCCESS! Downloaded updated production data components.");
212+
saveToCache(networkResult.json());
213+
try {
214+
finalEntries = networkResult.json().getJSONObject("entries");
215+
finalSource = (cachedTime == 0) ? DataSource.NETWORK_INITIAL : DataSource.NETWORK_UPDATE;
216+
} catch (JSONException ignored) {}
217+
} else {
218+
// CHANGE THIS: Force parsing the local cache when connection errors drop out
219+
Log.i(TAG, "Sync complete. Network timed out or dropped; falling back to offline cache.");
220+
try {
221+
StatusResult localResult = loadLocalData();
222+
finalEntries = localResult.json();
223+
finalSource = localResult.source(); // Becomes DataSource.CACHE
224+
} catch (Exception e) {
225+
Log.w(TAG, "Failed to load local data fallback during offline transition", e);
226+
}
227+
}
228+
229+
if (finalEntries != null && finalSource != null) {
230+
final DataSource sourceToApply = finalSource;
231+
final JSONObject entriesToApply = finalEntries;
232+
233+
synchronized (RevocationList.class) {
234+
data = entriesToApply;
235+
currentSource = sourceToApply;
236+
}
237+
238+
if (listener != null) {
239+
mainHandler.post(() -> listener.onUpdateSuccess(sourceToApply));
240+
}
241+
} else {
242+
Log.i(TAG, "Sync complete. Network timed out or dropped; local data streams unchanged.");
243+
}
244+
});
245+
}
246+
247+
public static void refresh() {
248+
refreshAsync(null);
249+
}
250+
197251
public static Date getPublishTime() {
198252
return publishTime;
199253
}
@@ -202,33 +256,13 @@ public static DataSource getCurrentSource() {
202256
return currentSource;
203257
}
204258

205-
public static void refresh() {
206-
synchronized (RevocationList.class) {
207-
StatusResult result = getStatus();
208-
data = result.json();
209-
210-
// If we successfully fetched a brand new file this session,
211-
// don't let a subsequent UI refresh overwrite our status with a 304!
212-
if ((currentSource == DataSource.NETWORK_UPDATE || currentSource == DataSource.NETWORK_INITIAL) && result.source() == DataSource.NETWORK_UP_TO_DATE) {
213-
Log.i(TAG, "Preserving NETWORK_UPDATE status across multiple refreshes");
214-
} else {
215-
currentSource = result.source();
216-
}
217-
}
218-
}
219-
220259
public static RevocationList get(BigInteger serialNumber) {
221260
if (data == null) {
222261
synchronized (RevocationList.class) {
223262
if (data == null) {
224-
StatusResult result = getStatus();
263+
StatusResult result = loadLocalData();
225264
data = result.json();
226-
227-
if ((currentSource == DataSource.NETWORK_UPDATE || currentSource == DataSource.NETWORK_INITIAL) && result.source() == DataSource.NETWORK_UP_TO_DATE) {
228-
Log.i(TAG, "Preserving NETWORK_UPDATE status in get()");
229-
} else {
230-
currentSource = result.source();
231-
}
265+
currentSource = result.source();
232266
}
233267
}
234268
}
@@ -245,4 +279,4 @@ public static RevocationList get(BigInteger serialNumber) {
245279
public String toString() {
246280
return "status: " + status + ", source: " + source;
247281
}
248-
}
282+
}

app/src/main/java/io/github/vvb2060/keyattestation/home/HomeFragment.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import io.github.vvb2060.keyattestation.lang.AttestationException
3232
import io.github.vvb2060.keyattestation.repository.AttestationData
3333
import io.github.vvb2060.keyattestation.util.Status
3434
import io.github.vvb2060.keyattestation.util.ColorManager
35+
import io.github.vvb2060.keyattestation.util.CrlManager
3536
import io.github.vvb2060.keyattestation.util.LocaleManager
3637
import rikka.html.text.HtmlCompat
3738
import rikka.html.text.toHtml
@@ -80,6 +81,10 @@ class HomeFragment : AppFragment(), HomeAdapter.Listener, MenuProvider {
8081
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
8182
super.onViewCreated(view, savedInstanceState)
8283
requireActivity().addMenuProvider(this, viewLifecycleOwner)
84+
85+
CrlManager.refreshTrigger.observe(viewLifecycleOwner) {
86+
viewModel.load(showLoading = false)
87+
}
8388

8489
val context = view.context
8590

app/src/main/java/io/github/vvb2060/keyattestation/home/HomeViewModel.kt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -181,8 +181,10 @@ class HomeViewModel(
181181
attestationData.postValue(result)
182182
}
183183

184-
fun load(reset: Boolean = false) = AppApplication.executor.execute {
185-
attestationData.postValue(Resource.loading(null))
184+
fun load(reset: Boolean = false, showLoading: Boolean = true) = AppApplication.executor.execute {
185+
if (showLoading) {
186+
attestationData.postValue(Resource.loading(null))
187+
}
186188

187189
var uniqueIdIncluded = false
188190
var useSak = false

0 commit comments

Comments
 (0)