Skip to content

Commit 5cb313e

Browse files
authored
Changes included in v1.0.1.44 of Covid Tracker App (HSEIreland#3)
1 parent e4d6b4c commit 5cb313e

20 files changed

+2016
-221
lines changed

android/build.gradle

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111
// - https://github.com/facebook/react-native/blob/0.58-stable/local-cli/templates/HelloWorld/android/app/build.gradle
1212

1313
def DEFAULT_COMPILE_SDK_VERSION = 28
14-
def DEFAULT_BUILD_TOOLS_VERSION = '28.0.3'
1514
def DEFAULT_MIN_SDK_VERSION = 23
1615
def DEFAULT_TARGET_SDK_VERSION = 28
1716

@@ -52,7 +51,6 @@ buildscript {
5251

5352
android {
5453
compileSdkVersion safeExtGet('compileSdkVersion', DEFAULT_COMPILE_SDK_VERSION)
55-
buildToolsVersion safeExtGet('buildToolsVersion', DEFAULT_BUILD_TOOLS_VERSION)
5654
defaultConfig {
5755
minSdkVersion safeExtGet('minSdkVersion', DEFAULT_MIN_SDK_VERSION)
5856
targetSdkVersion safeExtGet('targetSdkVersion', DEFAULT_TARGET_SDK_VERSION)

android/src/main/java/ie/gov/tracing/ExposureNotificationModule.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ public void tryEnable(Promise promise) {
108108
}
109109

110110
@ReactMethod
111-
public void checkExposure(Boolean readExposureDetails) {
111+
public void checkExposure(Boolean readExposureDetails, Boolean skipTimeCheck) {
112112
if(nearbyNotSupported()) return;
113113
Tracing.checkExposure(readExposureDetails);
114114
}

android/src/main/java/ie/gov/tracing/nearby/ExposureNotificationClientWrapper.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ public Task<List<TemporaryExposureKey>> getTemporaryExposureKeyHistory() {
5959
}
6060

6161
Task<Void> provideDiagnosisKeys(List<File> files, String token) {
62-
String settings = Fetcher.fetch("/settings", false, appContext);
62+
String settings = Fetcher.fetch("/settings/exposures", false, appContext);
6363
Gson gson = new Gson();
6464
Map map = gson.fromJson(settings, Map.class);
6565

android/src/main/java/ie/gov/tracing/network/Fetcher.kt

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import android.content.Context
44
import androidx.annotation.Keep
55
import com.google.common.io.BaseEncoding
66
import com.google.gson.Gson
7+
import ie.gov.tracing.Tracing
78
import ie.gov.tracing.common.Events
9+
import ie.gov.tracing.storage.ExpoSecureStoreInterop
810
import ie.gov.tracing.storage.ExposureEntity
911
import ie.gov.tracing.storage.SharedPrefs
1012
import org.apache.commons.io.FileUtils
@@ -23,6 +25,10 @@ data class Callback(val mobile: String, val closeContactDate: Long, val payload:
2325
@Keep
2426
data class Metric(val os: String, val event: String, val version: String, val payload: Map<String, Any>?)
2527

28+
@Keep
29+
data class CallbackRecovery(val mobile:String, val code: String, val iso: String,val number: String)
30+
31+
2632
class Fetcher {
2733

2834
companion object {
@@ -186,7 +192,8 @@ class Fetcher {
186192
fun triggerCallback(exposureEntity: ExposureEntity, context: Context, payload: Map<String, Any>) {
187193
try {
188194
val notificationSent = SharedPrefs.getLong("notificationSent", context)
189-
val callbackNum = SharedPrefs.getString("callbackNumber", context)
195+
var callbackNum = SharedPrefs.getString("callbackNumber", context)
196+
190197

191198
if (notificationSent > 0) {
192199
Events.raiseEvent(Events.INFO, "triggerCallback - notification " +
@@ -195,9 +202,29 @@ class Fetcher {
195202
}
196203

197204
if (callbackNum.isEmpty()) {
198-
Events.raiseEvent(Events.INFO, "triggerCallback - no callback number " +
199-
"set, not sending callback")
200-
return
205+
206+
try {
207+
val store = ExpoSecureStoreInterop(context)
208+
val jsonStr = store.getItemImpl("cti.callBack")
209+
val callBackData = Gson().fromJson(jsonStr, CallbackRecovery::class.java)
210+
211+
if(callBackData.code == null || callBackData.number == null){
212+
Events.raiseEvent(Events.INFO, "triggerCallback - no callback recovery")
213+
return;
214+
}
215+
callbackNum = callBackData.code + callBackData.number
216+
217+
218+
} catch (exExpo: Exception) {
219+
Events.raiseError("ExpoSecureStoreInterop", exExpo)
220+
}
221+
222+
if (callbackNum.isEmpty()) {
223+
224+
Events.raiseEvent(Events.INFO, "triggerCallback - no callback number " +
225+
"set, not sending callback")
226+
return
227+
}
201228
}
202229

203230
val dayInMs = 1000 * 60 * 60 * 24
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
// Modified from https://github.com/expo/expo/blob/master/packages/expo-secure-store/android/src/main/java/expo/modules/securestore/SecureStoreModule.java
2+
package ie.gov.tracing.storage;
3+
4+
import android.content.Context;
5+
import android.content.SharedPreferences;
6+
import android.util.Base64;
7+
import android.util.Log;
8+
9+
import org.json.JSONException;
10+
import org.json.JSONObject;
11+
12+
import java.io.IOException;
13+
import java.nio.charset.StandardCharsets;
14+
import java.security.GeneralSecurityException;
15+
import java.security.KeyStore;
16+
import java.security.KeyStoreException;
17+
import java.security.NoSuchAlgorithmException;
18+
import java.security.cert.CertificateException;
19+
20+
import javax.crypto.Cipher;
21+
import javax.crypto.spec.GCMParameterSpec;
22+
23+
public class ExpoSecureStoreInterop {
24+
25+
private static final String SHARED_PREFERENCES_NAME = "SecureStore";
26+
private static final String KEYSTORE_PROVIDER = "AndroidKeyStore";
27+
28+
private static final String SCHEME_PROPERTY = "scheme";
29+
30+
private KeyStore mKeyStore;
31+
private AESEncrypter mAESEncrypter;
32+
33+
private Context mContext;
34+
public ExpoSecureStoreInterop(Context context) {
35+
36+
mContext = context;
37+
mAESEncrypter = new AESEncrypter();
38+
}
39+
40+
public String getItemImpl(String key) {
41+
// We use a SecureStore-specific shared preferences file, which lets us do things like enumerate
42+
// its entries or clear all of them
43+
SharedPreferences prefs = getSharedPreferences();
44+
if (prefs.contains(key)) {
45+
return readJSONEncodedItem(key,prefs);
46+
} else {
47+
return "";
48+
}
49+
}
50+
51+
private String readJSONEncodedItem(String key, SharedPreferences prefs) {
52+
String encryptedItemString = prefs.getString(key, null);
53+
JSONObject encryptedItem;
54+
try {
55+
encryptedItem = new JSONObject(encryptedItemString);
56+
} catch (JSONException e) {
57+
return "";
58+
}
59+
60+
String scheme = encryptedItem.optString(SCHEME_PROPERTY);
61+
if (scheme == null) {
62+
return "";
63+
}
64+
65+
String value;
66+
try {
67+
switch (scheme) {
68+
case AESEncrypter.NAME:
69+
70+
KeyStore keyStore = getKeyStore();
71+
String keystoreAlias = mAESEncrypter.getKeyStoreAlias();
72+
KeyStore.SecretKeyEntry secretKeyEntry = (KeyStore.SecretKeyEntry) keyStore.getEntry(keystoreAlias, null);
73+
74+
value = mAESEncrypter.decryptItem(encryptedItem, secretKeyEntry);
75+
break;
76+
default:
77+
return "";
78+
}
79+
} catch (Exception e) {
80+
return "";
81+
}
82+
83+
return value;
84+
}
85+
86+
/**
87+
* We use a shared preferences file that's scoped to both the experience and SecureStore. This
88+
* lets us easily list or remove all the entries for an experience.
89+
*/
90+
private SharedPreferences getSharedPreferences() {
91+
return mContext.getSharedPreferences(SHARED_PREFERENCES_NAME, Context.MODE_PRIVATE);
92+
}
93+
94+
private KeyStore getKeyStore() throws IOException, KeyStoreException, NoSuchAlgorithmException, CertificateException {
95+
if (mKeyStore == null) {
96+
KeyStore keyStore = KeyStore.getInstance(KEYSTORE_PROVIDER);
97+
keyStore.load(null);
98+
mKeyStore = keyStore;
99+
}
100+
return mKeyStore;
101+
}
102+
103+
104+
/**
105+
* An encrypter that stores a symmetric key (AES) in the Android keystore. It generates a new IV
106+
* each time an item is written to prevent many-time pad attacks. The IV is stored with the
107+
* encrypted item.
108+
* <p>
109+
* AES with GCM is supported on Android 10+ but storing an AES key in the keystore is supported
110+
* on only Android 23+. If you generate your own key instead of using the Android keystore (like
111+
* the hybrid encrypter does) you can use the encyption and decryption methods of this class.
112+
*/
113+
protected static class AESEncrypter {
114+
public static final String NAME = "aes";
115+
116+
private static final String DEFAULT_ALIAS = "key_v1";
117+
private static final String AES_CIPHER = "AES/GCM/NoPadding";
118+
private static final int AES_KEY_SIZE_BITS = 256;
119+
120+
private static final String CIPHERTEXT_PROPERTY = "ct";
121+
private static final String IV_PROPERTY = "iv";
122+
private static final String GCM_AUTHENTICATION_TAG_LENGTH_PROPERTY = "tlen";
123+
124+
public String getKeyStoreAlias() {
125+
String baseAlias = DEFAULT_ALIAS;
126+
return AES_CIPHER + ":" + baseAlias;
127+
}
128+
129+
public String decryptItem(JSONObject encryptedItem, KeyStore.SecretKeyEntry secretKeyEntry) throws
130+
GeneralSecurityException, JSONException {
131+
132+
String ciphertext = encryptedItem.getString(CIPHERTEXT_PROPERTY);
133+
String ivString = encryptedItem.getString(IV_PROPERTY);
134+
int authenticationTagLength = encryptedItem.getInt(GCM_AUTHENTICATION_TAG_LENGTH_PROPERTY);
135+
byte[] ciphertextBytes = Base64.decode(ciphertext, Base64.DEFAULT);
136+
byte[] ivBytes = Base64.decode(ivString, Base64.DEFAULT);
137+
138+
GCMParameterSpec gcmSpec = new GCMParameterSpec(authenticationTagLength, ivBytes);
139+
Cipher cipher = Cipher.getInstance(AES_CIPHER);
140+
cipher.init(Cipher.DECRYPT_MODE, secretKeyEntry.getSecretKey(), gcmSpec);
141+
byte[] plaintextBytes = cipher.doFinal(ciphertextBytes);
142+
143+
return new String(plaintextBytes, StandardCharsets.UTF_8);
144+
}
145+
}
146+
}

index.d.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
declare module "react-native-exposure-notification-service" {
1+
declare module "@nearform/react-native-exposure-notification-service" {
22
import { EventSubscriptionVendor } from "react-native"
33

44
export enum AuthorisedStatus {
@@ -81,7 +81,7 @@ declare module "react-native-exposure-notification-service" {
8181

8282
getDiagnosisKeys(): Promise<DiagnosisKey[]>
8383

84-
checkExposure(readDetails?: boolean): void
84+
checkExposure(readDetails?: boolean, skipTimeCheck?: boolean): void
8585

8686
getCloseContacts(): Promise<CloseContact[]>
8787

ios/ExposureCheck.swift

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ class ExposureCheck: AsyncOperation {
6161
case .callback:
6262
return self.configData.serverURL + "/callback"
6363
case .settings:
64-
return self.configData.serverURL + "/settings"
64+
return self.configData.serverURL + "/settings/exposures"
6565
case .refresh:
6666
return self.configData.serverURL + "/refresh"
6767
}
@@ -98,7 +98,9 @@ class ExposureCheck: AsyncOperation {
9898
self.finishNoProcessing("No config set so can't proceeed with checking exposures")
9999
return
100100
}
101+
101102
self.sessionManager = Session(interceptor: RequestInterceptor(self.configData, self.serverURL(.refresh)))
103+
102104
os_log("Running with params %@, %@, %@", log: OSLog.checkExposure, type: .debug, self.configData.serverURL, self.configData.authToken, self.configData.refreshToken)
103105

104106
guard (self.configData.lastRunDate!.addingTimeInterval(TimeInterval(self.configData.checkExposureInterval * 60)) < Date() || self.skipTimeCheck) else {
@@ -125,11 +127,13 @@ class ExposureCheck: AsyncOperation {
125127
}
126128
}
127129

130+
128131
private func finishNoProcessing(_ message: String) {
129132
os_log("%@", log: OSLog.checkExposure, type: .info, message)
130133

131134
Storage.shared.updateRunData(self.storageContext, message)
132-
self.finish()
135+
136+
self.trackDailyMetrics()
133137
}
134138

135139
private func processExposures(_ files: [URL], _ lastIndex: Int) {
@@ -224,8 +228,8 @@ class ExposureCheck: AsyncOperation {
224228

225229
let durations:[Int] = exposures.customAttenuationDurations ?? exposures.attenuationDurations
226230

227-
guard durations.count == 3, thresholds.thresholdWeightings.count == 3 else {
228-
Storage.shared.updateRunData(self.storageContext, "Failure processing exposure keys, Durations or threshold not an array of 3 elements")
231+
guard thresholds.thresholdWeightings.count >= durations.count else {
232+
Storage.shared.updateRunData(self.storageContext, "Failure processing exposure keys, thresholds not correctly defined")
229233
return self.trackDailyMetrics()
230234
}
231235

@@ -234,7 +238,7 @@ class ExposureCheck: AsyncOperation {
234238
contactTime += Int(Double(element) * thresholds.thresholdWeightings[index])
235239
}
236240

237-
os_log("Calculated contact time, %d, %d, %d, %d, %d", log: OSLog.checkExposure, type: .debug, durations[0], durations[1], durations[2], contactTime, thresholds.timeThreshold)
241+
os_log("Calculated contact time, %@, %d, %d", log: OSLog.checkExposure, type: .debug, durations.map { String($0) }, contactTime, thresholds.timeThreshold)
238242

239243
if contactTime >= thresholds.timeThreshold && exposures.maximumRiskScoreFullRange > 0 {
240244
os_log("Detected exposure event", log: OSLog.checkExposure, type: .info)
@@ -256,13 +260,15 @@ class ExposureCheck: AsyncOperation {
256260
}
257261

258262
private func trackDailyMetrics() {
259-
let calendar = Calendar.current
260-
261-
guard let dailyTrace = self.configData.dailyTrace else {
263+
guard self.configData != nil else {
264+
// don't track daily trace if config not setup
262265
return self.finish()
263266
}
264-
265-
if (!self.isCancelled && !calendar.isDate(Date(), inSameDayAs: dailyTrace)) {
267+
268+
let calendar = Calendar.current
269+
let checkDate: Date = self.configData.dailyTrace ?? calendar.date(byAdding: .day, value: -2, to: Date())!
270+
271+
if (!self.isCancelled && !calendar.isDate(Date(), inSameDayAs: checkDate)) {
266272
Storage.shared.updateDailyTrace(self.storageContext, date: Date())
267273
self.saveMetric(event: "DAILY_ACTIVE_TRACE") { _ in
268274
self.finish()

ios/ExposureNotificationModule.m

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@ @interface RCT_EXTERN_MODULE(ExposureNotificationModule, RCTEventEmitter)
4242
RCT_EXTERN_METHOD(getLogData:(RCTPromiseResolveBlock)resolve
4343
rejecter:(RCTPromiseRejectBlock)reject)
4444

45-
RCT_EXTERN_METHOD(checkExposure:(BOOL *)readExposureDetails)
45+
RCT_EXTERN_METHOD(checkExposure:(BOOL *)readExposureDetails
46+
:(BOOL *)skipTimeCheck)
4647

4748
RCT_EXTERN_METHOD(deleteAllData:(RCTPromiseResolveBlock)resolve
4849
rejecter:(RCTPromiseRejectBlock)reject)

ios/ExposureNotificationModule.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ public class ExposureNotificationModule: RCTEventEmitter {
5353
notificationDesc: configDict["notificationDesc"] as? String ?? "The COVID Tracker App has detected that you may have been exposed to someone who has tested positive for COVID-19.",
5454
authToken: token,
5555
version: (configDict["version"] as? String ?? Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String)!,
56-
fileLimit: configDict["fileLimit"] as? Int ?? 10,
56+
fileLimit: configDict["fileLimit"] as? Int ?? 3,
5757
callbackNumber: configDict["callbackNumber"] as? String ?? "",
5858
analyticsOptin: configDict["analyticsOptin"] as? Bool ?? false
5959
)
@@ -174,9 +174,9 @@ public class ExposureNotificationModule: RCTEventEmitter {
174174
}
175175
}
176176

177-
@objc public func checkExposure(_ readExposureDetails: Bool) {
177+
@objc public func checkExposure(_ readExposureDetails: Bool, _ skipTimeCheck: Bool) {
178178
if #available(iOS 13.5, *) {
179-
ExposureProcessor.shared.checkExposureForeground(readExposureDetails)
179+
ExposureProcessor.shared.checkExposureForeground(readExposureDetails, skipTimeCheck)
180180
}
181181
}
182182

ios/ExposureProcessor.swift

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,7 @@ public class ExposureProcessor {
245245
os_log("Registering background task", log: OSLog.exposure, type: .debug)
246246
}
247247

248-
public func checkExposureBackground(_ task: BGTask) {
248+
private func checkExposureBackground(_ task: BGTask) {
249249
os_log("Running exposure check in background", log: OSLog.exposure, type: .debug)
250250
let queue = OperationQueue()
251251
queue.maxConcurrentOperationCount = 1
@@ -265,19 +265,19 @@ public class ExposureProcessor {
265265
}
266266
}
267267

268-
public func checkExposureForeground(_ exposureDetails: Bool) {
268+
public func checkExposureForeground(_ exposureDetails: Bool, _ skipTimeCheck: Bool) {
269269
os_log("Running exposure check in foreground", log: OSLog.exposure, type: .debug)
270270
let queue = OperationQueue()
271271
queue.maxConcurrentOperationCount = 1
272-
queue.addOperation(ExposureCheck(true, exposureDetails))
272+
queue.addOperation(ExposureCheck(skipTimeCheck, exposureDetails))
273273

274274
let lastOperation = queue.operations.last
275275
lastOperation?.completionBlock = {
276276
os_log("Foreground exposure check is complete, %d", log: OSLog.exposure, type: .debug, lastOperation?.isCancelled ?? false)
277277
}
278278
}
279279

280-
public func scheduleCheckExposure() {
280+
private func scheduleCheckExposure() {
281281
let context = Storage.PersistentContainer.shared.newBackgroundContext()
282282
guard ENManager.authorizationStatus == .authorized else {
283283
os_log("Not authorised so can't schedule exposure checks", log: OSLog.exposure, type: .info)

0 commit comments

Comments
 (0)