Commit 93bc59b9 authored by Ulrich Scheller's avatar Ulrich Scheller
Browse files

Release 1.7.0

parent 0525447b
# Release 1.7.0
-Include "my luca" tab
-Bug fixes and improvements
With this update we introduce another tab: The "my luca" tab. In this tab you are able to import documents that may be helpful for the locations you check into.
# Release 1.6.7
- Added support for CWA compatible QR codes
......
......@@ -2,6 +2,9 @@ pipeline {
agent {
label('docker')
}
options {
timeout(time: 30, unit: 'MINUTES')
}
environment {
CONTAINER = 'harbor.seamlessme.local/seamlessme/android:build'
......@@ -26,19 +29,22 @@ pipeline {
steps {
sh '''
cd Luca
docker run --rm -u $(id -u):$(id -g) -v `pwd`:/Luca ${CONTAINER} bash -c "cd /Luca/app; ./../gradlew :app:test"
docker run --rm -u $(id -u):$(id -g) -v `pwd`:/Luca ${CONTAINER} bash -c "cd /Luca/app; ./../gradlew :app:testDebug"
'''
}
}
stage('Build Debug') {
stage('Build Debug & QA') {
steps {
withCredentials([
usernamePassword(credentialsId: "luca-staging-api-basic-auth-user-pw", usernameVariable: 'STAGING_API_USERNAME', passwordVariable: 'STAGING_API_PASSWORD'),
]) {
sh '''
cd Luca
BUILD_NAME=$(python -c "print('$BUILD_TAG'.split('%2F')[-1].replace(' ', ''))")
STAGING_PARAMS="-PSTAGING_API_USERNAME=$QUOTE$STAGING_API_USERNAME$QUOTE -PSTAGING_API_PASSWORD=$QUOTE$STAGING_API_PASSWORD$QUOTE"
CMD="cd /Luca/app; ./../gradlew :app:assembleDebug $STAGING_PARAMS && mv build/outputs/apk/debug/app-debug.apk build/outputs/apk/debug/app-debug_${BUILD_NUMBER}.apk"
CMD="cd /Luca/app; ./../gradlew :app:assembleDebug :app:assembleQa $STAGING_PARAMS"
CMD="$CMD && cd build/outputs/apk/debug && mv app-debug.apk app-debug_${BUILD_NAME}.apk"
CMD="$CMD && cd ../qa/ && mv app-qa.apk app-qa_${BUILD_NAME}.apk"
docker run --rm -u $(id -u):$(id -g) -v `pwd`:/Luca ${CONTAINER} bash -c "$CMD"
'''
}
......@@ -54,10 +60,11 @@ pipeline {
]) {
sh '''
cd Luca
BUILD_NAME=$(python -c "print('$BUILD_TAG'.split('%2F')[-1].replace(' ', ''))")
LOCAL_KEYSTORE_FILE=$(basename $KEYSTORE_FILE)
KEYSTORE_PARAMS="-PC4L_SIGNING_STORE_FILE=$LOCAL_KEYSTORE_FILE -PC4L_SIGNING_KEY_PASSWORD=$C4L_SIGNING_KEY_PASSWORD -PC4L_SIGNING_KEY_ALIAS=$C4L_SIGNING_KEY_ALIAS -PC4L_SIGNING_STORE_PASSWORD=$C4L_SIGNING_STORE_PASSWORD"
CMD="cd /Luca/app; ./../gradlew :app:assembleRelease $KEYSTORE_PARAMS"
CMD="$CMD && mv build/outputs/apk/release/app-release.apk build/outputs/apk/release/app-release_${BUILD_NUMBER}.apk"
CMD="$CMD && cd build/outputs/apk/release && mv app-release.apk app-release_${BUILD_NAME}.apk"
docker run --rm -u $(id -u):$(id -g) -v `pwd`:/Luca -v $KEYSTORE_FILE:/Luca/app/$LOCAL_KEYSTORE_FILE ${CONTAINER} bash -c "$CMD"
'''
}
......@@ -96,6 +103,7 @@ pipeline {
post {
always {
junit 'Luca/app/build/test-results/**/*.xml'
cleanWs()
}
}
......
......@@ -8,8 +8,8 @@ android {
applicationId "de.culture4life.luca"
minSdkVersion 21
targetSdkVersion 30
versionCode 58
versionName "1.6.7"
versionCode 59
versionName "1.7.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
signingConfigs {
......@@ -33,6 +33,7 @@ android {
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
signingConfig signingConfigs.culture4life
buildConfigField "String", "API_BASE_URL", '"https://app.luca-app.de"'
buildConfigField "String", "STAGING_API_USERNAME", '""'
buildConfigField "String", "STAGING_API_PASSWORD", '""'
}
......@@ -42,23 +43,34 @@ android {
versionNameSuffix " Debug"
applicationIdSuffix ".debug"
signingConfig signingConfigs.debug
buildConfigField "String", "API_BASE_URL", '"https://app-dev.luca-app.de"'
buildConfigField "String", "STAGING_API_USERNAME", project.getProperties().getOrDefault("STAGING_API_USERNAME", '"<staging username>"')
buildConfigField "String", "STAGING_API_PASSWORD", project.getProperties().getOrDefault("STAGING_API_PASSWORD", '"<staging password>"')
}
qa {
initWith debug
versionNameSuffix " QA"
applicationIdSuffix ".qa"
buildConfigField "String", "API_BASE_URL", '"https://app-qs.luca-app.de"'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
testOptions {
unitTests.includeAndroidResources true
animationsDisabled true
unitTests {
includeAndroidResources = true
returnDefaultValues = false
}
}
}
play {
serviceAccountCredentials.set(file("service-account.json"))
defaultToAppBundles.set(true)
track.set("internal-testing")
track.set("internal")
}
dependencies {
......@@ -77,6 +89,8 @@ dependencies {
implementation 'androidx.work:work-runtime:2.5.0'
implementation 'androidx.work:work-rxjava3:2.5.0'
implementation 'com.auth0:java-jwt:3.15.0'
implementation 'com.github.akarnokd:rxjava3-extensions:3.0.1'
implementation 'com.github.akarnokd:rxjava3-bridge:3.0.0'
implementation 'com.github.kenglxn.QRGen:android:2.6.0'
......
package de.culture4life.luca.network;
import com.google.gson.JsonObject;
import de.culture4life.luca.BuildConfig;
import de.culture4life.luca.LucaApplication;
import de.culture4life.luca.network.endpoints.LucaEndpointsV3;
import de.culture4life.luca.network.pojo.UserDeletionRequestData;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
import androidx.test.platform.app.InstrumentationRegistry;
import retrofit2.HttpException;
public class NetworkManagerTest {
private LucaApplication application;
private NetworkManager networkManager;
private LucaEndpointsV3 lucaEndpoints;
@Before
public void setup() {
application = (LucaApplication) InstrumentationRegistry.getInstrumentation().getTargetContext().getApplicationContext();
application.getCryptoManager().initialize(application).blockingAwait();
networkManager = application.getNetworkManager();
networkManager.doInitialize(application).blockingAwait();
lucaEndpoints = networkManager.getLucaEndpointsV3().blockingGet();
}
@Test
public void getDailyKeyPairPublicKey_call_isSuccessful() {
JsonObject response = lucaEndpoints.getDailyKeyPairPublicKey().blockingGet();
Assert.assertTrue(response.has("publicKey"));
}
@Test(expected = HttpException.class)
public void deleteUser_invalidRequestBody_fails() {
if (BuildConfig.DEBUG) {
UserDeletionRequestData data = new UserDeletionRequestData("...");
lucaEndpoints.deleteUser("9a5c8715-2810-4e17-a3c9-0c8190507dd5", data)
.blockingAwait();
}
}
}
\ No newline at end of file
package de.culture4life.luca.registration;
import de.culture4life.luca.BuildConfig;
import de.culture4life.luca.LucaApplication;
import org.junit.Assert;
import org.junit.Assume;
import org.junit.Before;
import org.junit.Test;
import androidx.test.platform.app.InstrumentationRegistry;
import static org.junit.Assert.assertFalse;
public class RegistrationManagerTest {
private LucaApplication application;
private RegistrationManager registrationManager;
@Before
public void setup() {
Assume.assumeTrue(BuildConfig.DEBUG);
application = (LucaApplication) InstrumentationRegistry.getInstrumentation().getTargetContext().getApplicationContext();
registrationManager = application.getRegistrationManager();
registrationManager.doInitialize(application).blockingAwait();
}
@Test
public void registerUser_hasCompletedRegistration_isTrue() {
registrationManager.registerUser().blockingAwait();
Boolean isRegistered = registrationManager.hasCompletedRegistration().blockingGet();
Assert.assertTrue(isRegistered);
}
@Test
public void hasCompletedRegistration_afterDeleteAll_isFalse() {
registrationManager.registerUser().blockingAwait();
registrationManager.deleteRegistrationData().blockingAwait();
Boolean isRegistered = registrationManager.hasCompletedRegistration().blockingGet();
assertFalse(isRegistered);
}
}
\ No newline at end of file
......@@ -21,12 +21,12 @@
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.App.DayNight">
android:theme="@style/Theme.Luca.DayNight">
<activity
android:name=".ui.splash.SplashActivity"
android:screenOrientation="portrait"
android:theme="@style/Theme.App.DayNight.Splash">
android:theme="@style/Theme.Luca.DayNight.Splash">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
......
......@@ -25,8 +25,10 @@ import de.culture4life.luca.notification.LucaNotificationManager;
import de.culture4life.luca.preference.PreferencesManager;
import de.culture4life.luca.registration.RegistrationManager;
import de.culture4life.luca.service.LucaService;
import de.culture4life.luca.testing.TestingManager;
import de.culture4life.luca.ui.ViewError;
import de.culture4life.luca.ui.dialog.BaseDialogFragment;
import de.culture4life.luca.util.TimeUtil;
import java.util.ArrayList;
import java.util.HashSet;
......@@ -55,6 +57,8 @@ import static android.content.Intent.FLAG_ACTIVITY_NEW_TASK;
public class LucaApplication extends MultiDexApplication {
private static final long MAXIMUM_TIMESTAMP_OFFSET = TimeUnit.MINUTES.toMillis(1);
private final PreferencesManager preferencesManager;
private final CryptoManager cryptoManager;
private final NetworkManager networkManager;
......@@ -65,6 +69,7 @@ public class LucaApplication extends MultiDexApplication {
private final MeetingManager meetingManager;
private final HistoryManager historyManager;
private final DataAccessManager dataAccessManager;
private final TestingManager testingManager;
private final GeofenceManager geofenceManager;
private final CompositeDisposable applicationDisposable;
......@@ -91,6 +96,7 @@ public class LucaApplication extends MultiDexApplication {
meetingManager = new MeetingManager(preferencesManager, networkManager, locationManager, historyManager, cryptoManager);
checkInManager = new CheckInManager(preferencesManager, networkManager, geofenceManager, locationManager, historyManager, cryptoManager, notificationManager);
dataAccessManager = new DataAccessManager(preferencesManager, networkManager, notificationManager, checkInManager, historyManager, cryptoManager);
testingManager = new TestingManager(preferencesManager, historyManager, registrationManager);
applicationDisposable = new CompositeDisposable();
......@@ -152,14 +158,44 @@ public class LucaApplication extends MultiDexApplication {
checkInManager.initialize(this).subscribeOn(Schedulers.io()),
historyManager.initialize(this).subscribeOn(Schedulers.io()),
dataAccessManager.initialize(this).subscribeOn(Schedulers.io()),
testingManager.initialize(this).subscribeOn(Schedulers.io()),
geofenceManager.initialize(this).subscribeOn(Schedulers.io())
).andThen(Completable.mergeArray(
invokeServerTimeCheck(),
invokeRotatingBackendPublicKeyUpdate(),
invokeAccessedDataUpdate(),
startKeepingDataUpdated()
));
}
private Completable invokeServerTimeCheck() {
return Completable.fromAction(() -> applicationDisposable.add(networkManager.getLucaEndpointsV3()
.flatMap(LucaEndpointsV3::getServerTime)
.map(jsonObject -> jsonObject.get("unix").getAsInt())
.flatMap(TimeUtil::convertFromUnixTimestamp)
.map(serverTimestamp -> Math.abs(System.currentTimeMillis() - serverTimestamp))
.subscribeOn(Schedulers.io())
.subscribe(
timestampOffset -> {
Timber.d("Timestamp offset: %d", timestampOffset);
if (timestampOffset > MAXIMUM_TIMESTAMP_OFFSET) {
showErrorAsDialog(new ViewError.Builder(this)
.withTitle(R.string.error_timestamp_offset_title)
.withDescription(R.string.error_timestamp_offset_description)
.withResolveLabel(R.string.action_resolve)
.withResolveAction(Completable.fromAction(() -> {
Intent intent = new Intent(android.provider.Settings.ACTION_DATE_SETTINGS);
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
startActivity(intent);
}))
.removeWhenShown()
.build());
}
},
throwable -> Timber.w("Unable to get server time offset: %s", throwable.toString())
)));
}
private Completable invokeRotatingBackendPublicKeyUpdate() {
return Completable.fromAction(() -> applicationDisposable.add(cryptoManager.updateDailyKeyPairPublicKey()
.doOnError(throwable -> {
......@@ -262,6 +298,8 @@ public class LucaApplication extends MultiDexApplication {
notificationManager.dispose();
preferencesManager.dispose();
geofenceManager.dispose();
dataAccessManager.dispose();
testingManager.dispose();
stopService();
Timber.i("Stopping application");
System.exit(0);
......@@ -408,6 +446,10 @@ public class LucaApplication extends MultiDexApplication {
return dataAccessManager;
}
public TestingManager getTestingManager() {
return testingManager;
}
public GeofenceManager getGeofenceManager() {
return geofenceManager;
}
......
......@@ -6,6 +6,10 @@ import com.google.gson.annotations.SerializedName;
import java.util.ArrayList;
import java.util.List;
/**
* Model of previous check-ins, stored locally and removed after two weeks {@link
* CheckInManager#deleteOldArchivedCheckInData()}.
*/
public class ArchivedCheckInData {
@Expose
......
......@@ -7,6 +7,13 @@ import java.util.UUID;
import androidx.annotation.Nullable;
/**
* Check-In data containing trace id enabling health departments to contact guests in case of an
* infection.
*
* @see <a href="https://luca-app.de/securityoverview/properties/assets.html#term-Check-In">Security
* Overview: Assets</a>
*/
public class CheckInData {
@SerializedName("traceId")
......
......@@ -61,12 +61,23 @@ import timber.log.Timber;
import static android.Manifest.permission.ACCESS_COARSE_LOCATION;
import static android.Manifest.permission.ACCESS_FINE_LOCATION;
import static de.culture4life.luca.crypto.HashProvider.TRIMMED_HASH_LENGTH;
import static de.culture4life.luca.history.HistoryManager.KEEP_DATA_DURATION;
import static de.culture4life.luca.location.GeofenceManager.MAXIMUM_GEOFENCE_RADIUS;
import static de.culture4life.luca.location.GeofenceManager.MINIMUM_GEOFENCE_RADIUS;
import static de.culture4life.luca.location.GeofenceManager.UPDATE_INTERVAL_DEFAULT;
import static de.culture4life.luca.notification.LucaNotificationManager.NOTIFICATION_ID_EVENT;
import static de.culture4life.luca.util.SerializationUtil.serializeToBase64;
/**
* Facilitates check-in to a venues either by having a shown barcode scanned or scanning a printed
* QR-code.
*
* @see <a href="https://www.luca-app.de/securityoverview/processes/guest_app_checkin.html">Security
* Overview: Check-In via Mobile Phone App</a>
* @see <a href="https://www.luca-app.de/securityoverview/processes/guest_self_checkin.html">Security
* Overview: Check-In via a Printed QR Code</a>
*/
public class CheckInManager extends Manager {
public static final String KEY_CHECKED_IN_TRACE_ID = "checked_in_trace_id";
......@@ -142,6 +153,13 @@ public class CheckInManager extends Manager {
Check-in
*/
/**
* Perform self check-in, generating the check-in data locally and uploading it to the luca
* server.
*
* @see <a href="https://www.luca-app.de/securityoverview/processes/guest_self_checkin.html">Security
* Overview: Check-In via a Printed QR Code</a>
*/
public Completable checkIn(@NonNull UUID scannerId, @NonNull QrCodeData qrCodeData) {
return assertNotCheckedIn()
.andThen(generateCheckInData(qrCodeData, scannerId)
......@@ -152,7 +170,7 @@ public class CheckInManager extends Manager {
}
/**
* Should be called after a check-in occurred (either triggered by the user or in the backend)
* Should be called after a check-in occurred (either triggered by the user or in the backend).
*/
private Completable processCheckIn(@NonNull CheckInData checkInData) {
return Completable.fromAction(() -> this.checkInData = checkInData)
......@@ -189,6 +207,13 @@ public class CheckInManager extends Manager {
.doOnSuccess(checkInRequestData -> checkInRequestData.setScannerId(scannerId.toString()));
}
/**
* Generate data required for checking in. The encrypted contact data is encrypted a second time
* using the venue's key.
*
* @see <a href="https://www.luca-app.de/securityoverview/processes/guest_self_checkin.html">Security
* Overview: Check-In via a Printed QR Code</a>
*/
private Single<CheckInRequestData> generateCheckInData(@NonNull QrCodeData qrCodeData, @NonNull PublicKey locationPublicKey) {
return Single.fromCallable(() -> {
CheckInRequestData checkInRequestData = new CheckInRequestData();
......@@ -209,7 +234,7 @@ public class CheckInManager extends Manager {
.flatMap(SerializationUtil::serializeToBase64).blockingGet();
checkInRequestData.setScannerEphemeralPublicKey(serializedScannerPublicKey);
byte[] iv = cryptoManager.generateSecureRandomData(16).blockingGet();
byte[] iv = cryptoManager.generateSecureRandomData(TRIMMED_HASH_LENGTH).blockingGet();
String encodedIv = serializeToBase64(iv).blockingGet();
checkInRequestData.setIv(encodedIv);
......@@ -311,7 +336,7 @@ public class CheckInManager extends Manager {
String serializedTraceId = serializeToBase64(traceId).blockingGet();
additionalCheckInProperties.setTraceId(serializedTraceId);
byte[] iv = cryptoManager.generateSecureRandomData(16).blockingGet();
byte[] iv = cryptoManager.generateSecureRandomData(TRIMMED_HASH_LENGTH).blockingGet();
String encodedIv = serializeToBase64(iv).blockingGet();
additionalCheckInProperties.setIv(encodedIv);
......@@ -352,6 +377,12 @@ public class CheckInManager extends Manager {
Check-out
*/
/**
* Perform check-out, uploading trace ID and checkout time to the luca server.
*
* @see <a href="https://www.luca-app.de/securityoverview/processes/guest_checkout.html#checkout-process">Security
* Overview: Checkout Process</a>
*/
@SuppressLint("MissingPermission")
public Completable checkOut() {
return assertCheckedIn()
......@@ -377,7 +408,8 @@ public class CheckInManager extends Manager {
}
/**
* Should be called after a check-out occurred (either triggered by the user or in the backend)
* Should be called after a check-out occurred (either triggered by the user or in the
* backend).
*/
private Completable processCheckOut() {
return getCheckInDataIfAvailable()
......@@ -386,6 +418,12 @@ public class CheckInManager extends Manager {
.andThen(disableAutomaticCheckOut());
}
/**
* Create check-out data of current trace ID and the current timestamp.
*
* @see <a href="https://www.luca-app.de/securityoverview/processes/guest_checkout.html#checkout-process">Security
* Overview: Checkout Process</a>
*/
private Single<CheckOutRequestData> generateCheckOutData() {
return Single.just(new CheckOutRequestData())
.flatMap(checkOutRequestData -> getCheckedInTraceId()
......@@ -601,6 +639,15 @@ public class CheckInManager extends Manager {
.doOnNext(checkInData -> Timber.v("Check-in data updated from preferences: %s", checkInData));
}
/**
* Checking in using a scanner doesn't require the device to be online, nevertheless the backend
* is polled regularly in an attempt to provide visual feedback of a successful check-in.
*
* @param interval to poll backend at (millis)
* @return Completable to finalize check-in {@link #processCheckIn(CheckInData)}
* @see <a href="https://luca-app.de/securityoverview/processes/guest_app_checkin.html#qr-code-scanning-feedback">Security
* Overview: QR Code Scanning Feedback</a>
*/
public Completable requestCheckInDataUpdates(long interval) {
return pollCheckInData(interval)
.distinctUntilChanged((previous, current) -> {
......@@ -773,7 +820,7 @@ public class CheckInManager extends Manager {
public Completable deleteOldArchivedCheckInData() {
return getArchivedCheckInData()
.filter(checkInData -> checkInData.getTimestamp() > System.currentTimeMillis() - TimeUnit.DAYS.toMillis(14))
.filter(checkInData -> checkInData.getTimestamp() > System.currentTimeMillis() - KEEP_DATA_DURATION)
.toList()
.map(ArchivedCheckInData::new)
.flatMapCompletable(archivedCheckInData -> preferencesManager.persist(KEY_ARCHIVED_CHECK_IN_DATA, archivedCheckInData))
......
......@@ -29,6 +29,11 @@ import io.reactivex.rxjava3.core.Single;
import static com.nexenio.rxkeystore.RxKeyStore.PROVIDER_BOUNCY_CASTLE;
/**
* Provides EC cryptography using the secp256r1 curve.
*
* Uses Bouncy Castle due to Android limitations.
*/
public class AsymmetricCipherProvider extends EcCipherProvider {
private static final String CURVE_NAME = "secp256r1";
......
......@@ -3,8 +3,13 @@ package de.culture4life.luca.crypto;
import com.google.gson.annotations.Expose;
import com.google.gson.annotations.SerializedName;
import de.culture4life.luca.checkin.CheckInData;
import java.security.interfaces.ECPublicKey;
/**
* Public part of the daily key pair, used to encrypt {@link CheckInData}.
*/
public class DailyKeyPairPublicKeyWrapper {
@SerializedName("id")
......
......@@ -5,8 +5,13 @@ import com.nexenio.rxkeystore.provider.hash.Sha256HashProvider;
import androidx.annotation.NonNull;
/**
* Provides hashes using SHA256.
*/
public class HashProvider extends Sha256HashProvider {
public static final int TRIMMED_HASH_LENGTH = 16;
public HashProvider(@NonNull RxKeyStore rxKeyStore) {
super(rxKeyStore);
}
......
......@@ -5,6 +5,9 @@ import com.nexenio.rxkeystore.provider.mac.HmacProvider;
import androidx.annotation.NonNull;
/**
* Provides message authentication codes using HMAC-SHA256.
*/
public class MacProvider extends HmacProvider {
public MacProvider(@NonNull RxKeyStore rxKeyStore) {
......
......@@ -5,6 +5,9 @@ import com.nexenio.rxkeystore.provider.signature.BaseSignatureProvider;
import androidx.annotation.NonNull;
/**
* Signature provider used for signing contact data and verifying daily key.
*/
public class SignatureProvider extends BaseSignatureProvider {
public SignatureProvider(@NonNull RxKeyStore rxKeyStore) {
......
......@@ -6,12 +6,22 @@ import android.os.Build;
import com.nexenio.rxkeystore.RxKeyStore;
import com.nexenio.rxkeystore.provider.cipher.symmetric.aes.AesCipherProvider;
import de.culture4life.luca.network.pojo.ContactData;
import de.culture4life.luca.network.pojo.TransferData;
import de.culture4life.luca.ui.qrcode.QrCodeData;
import javax.crypto.SecretKey;
import androidx.annotation.NonNull;
import androidx.annotation.RequiresApi;
import io.reactivex.rxjava3.core.Single;
/**
* Symmetric cipher provider, used to encrypt {@link ContactData}, {@link TransferData} and {@link
* QrCodeData} using AES-CTR with 128 bit keys.
*
* Uses Bouncy Castle due to Android limitations
*/
@RequiresApi(api = Build.VERSION_CODES.M)
public class SymmetricCipherProvider extends AesCipherProvider {
......