Commit 31fa1592 authored by Daniel Beckmann's avatar Daniel Beckmann
Browse files

Release 1.11.0

parent 22451438
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectRootManager" version="2" languageLevel="JDK_1_8" default="true" project-jdk-name="1.8" project-jdk-type="JavaSDK">
<component name="ProjectRootManager" version="2" languageLevel="JDK_11" default="true" project-jdk-name="1.8" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
......
......@@ -10,8 +10,8 @@ android {
applicationId "de.culture4life.luca"
minSdkVersion 21
targetSdkVersion 30
versionCode 76
versionName "1.10.0"
versionCode 77
versionName "1.11.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
signingConfigs {
......@@ -97,6 +97,7 @@ android {
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
coreLibraryDesugaringEnabled true
}
testOptions {
animationsDisabled true
......@@ -119,6 +120,7 @@ play {
}
dependencies {
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5'
implementation project(':decoder')
implementation 'androidx.annotation:annotation:1.2.0'
......
......@@ -495,7 +495,7 @@ public class LucaApplication extends MultiDexApplication {
return dataAccessManager;
}
public DocumentManager getTestingManager() {
public DocumentManager getDocumentManager() {
return documentManager;
}
......
......@@ -160,7 +160,7 @@ public class DataAccessManager extends Manager {
.map(durationSinceLastUpdate -> UPDATE_INTERVAL - durationSinceLastUpdate)
.map(recommendedDelay -> Math.max(0, recommendedDelay))
.doOnSuccess(recommendedDelay -> {
String readableDelay = TimeUtil.getReadableApproximateDuration(recommendedDelay, context).blockingGet();
String readableDelay = TimeUtil.getReadableDurationWithPlural(recommendedDelay, context).blockingGet();
Timber.v("Recommended update delay: %s", readableDelay);
});
}
......
......@@ -68,13 +68,14 @@ public class Document {
}
public static final long MAXIMUM_FAST_TEST_VALIDITY = TimeUnit.DAYS.toMillis(2);
public static final long MAXIMUM_PCR_TEST_VALIDITY = TimeUnit.DAYS.toMillis(3);
public static final long MAXIMUM_RECOVERY_VALIDITY = TimeUnit.DAYS.toMillis(30 * 6);
public static final long MAXIMUM_APPOINTMENT_VALIDITY = TimeUnit.HOURS.toMillis(2);
public static final long TIME_UNTIL_VACCINATION_IS_VALID = TimeUnit.DAYS.toMillis(15);
public static final long MAXIMUM_VACCINATION_VALIDITY = TimeUnit.DAYS.toMillis(365);
public static final long TIME_UNTIL_RECOVERY_IS_VALID = TimeUnit.DAYS.toMillis(15);
public static final long MAXIMUM_RECOVERY_VALIDITY = TimeUnit.DAYS.toMillis(30 * 6);
public static final long MAXIMUM_APPOINTMENT_VALIDITY = TimeUnit.HOURS.toMillis(2);
public static final long MAXIMUM_FAST_TEST_VALIDITY = TimeUnit.DAYS.toMillis(2);
public static final long MAXIMUM_NEGATIVE_PCR_TEST_VALIDITY = TimeUnit.DAYS.toMillis(3);
public static final long MAXIMUM_POSITIVE_PCR_TEST_VALIDITY = MAXIMUM_RECOVERY_VALIDITY;
@IntDef({TYPE_UNKNOWN, TYPE_FAST, TYPE_PCR, TYPE_VACCINATION, TYPE_APPOINTMENT, TYPE_GREEN_PASS, TYPE_RECOVERY})
@Retention(SOURCE)
......@@ -87,6 +88,7 @@ public class Document {
public static final int TYPE_PCR = 2;
public static final int TYPE_VACCINATION = 3;
public static final int TYPE_APPOINTMENT = 4;
@Deprecated
public static final int TYPE_GREEN_PASS = 5;
public static final int TYPE_RECOVERY = 6;
......@@ -160,6 +162,10 @@ public class Document {
@SerializedName("provider")
private String provider;
@Expose
@SerializedName("verified")
private boolean verified = false;
@Expose
@SerializedName("encodedData")
private String encodedData;
......@@ -270,6 +276,14 @@ public class Document {
this.provider = provider;
}
public boolean isVerified() {
return verified;
}
public void setVerified(boolean verified) {
this.verified = verified;
}
public String getEncodedData() {
return encodedData;
}
......@@ -313,6 +327,8 @@ public class Document {
return getTestingTimestamp() + TIME_UNTIL_VACCINATION_IS_VALID;
} else if (type == TYPE_RECOVERY && outcome == OUTCOME_FULLY_IMMUNE) {
return getTestingTimestamp() + TIME_UNTIL_RECOVERY_IS_VALID;
} else if (type == TYPE_PCR && outcome == OUTCOME_POSITIVE) {
return getTestingTimestamp() + TIME_UNTIL_RECOVERY_IS_VALID;
} else {
return getTestingTimestamp();
}
......@@ -333,12 +349,16 @@ public class Document {
* The duration in milliseconds after which a document with the specified {@link Type} should be
* treated as invalid.
*/
public static long getExpirationDuration(@Type int type) {
public long getExpirationDuration(@Type int type) {
switch (type) {
case TYPE_FAST:
return MAXIMUM_FAST_TEST_VALIDITY;
case TYPE_PCR:
return MAXIMUM_PCR_TEST_VALIDITY;
if (outcome == OUTCOME_POSITIVE) {
return MAXIMUM_POSITIVE_PCR_TEST_VALIDITY;
} else {
return MAXIMUM_NEGATIVE_PCR_TEST_VALIDITY;
}
case TYPE_VACCINATION:
return MAXIMUM_VACCINATION_VALIDITY;
case TYPE_RECOVERY:
......@@ -350,6 +370,18 @@ public class Document {
}
}
/**
* @return true if this document is a valid recovery certificate. This is the case when it is a
* positive PCR test older than 14 days but no more than 6 months.
*/
public boolean isValidRecovery() {
if (type == TYPE_RECOVERY || (type == TYPE_PCR && outcome == OUTCOME_POSITIVE)) {
long now = System.currentTimeMillis();
return now > getValidityStartTimestamp() && now < getExpirationTimestamp();
}
return false;
}
@Override
public String toString() {
return "Document{" +
......
......@@ -123,7 +123,9 @@ public class DocumentManager extends Manager {
}
if (document.getOutcome() == Document.OUTCOME_POSITIVE
&& document.getType() != Document.TYPE_GREEN_PASS) {
return Completable.error(new TestResultPositiveException());
if (!document.isValidRecovery()) {
return Completable.error(new TestResultPositiveException());
}
}
if (document.getOutcome() == Document.OUTCOME_UNKNOWN
&& document.getType() != Document.TYPE_GREEN_PASS
......@@ -154,15 +156,18 @@ public class DocumentManager extends Manager {
); // TODO: 07.05.21 add ubirch
}
/**
* Redeem a document so that it can not be imported on another device
*
* @param document document object to redeem
*/
public Completable redeemDocument(@NonNull Document document) {
if (BuildConfig.DEBUG) {
return Completable.complete();
}
return networkManager.getLucaEndpointsV3()
.flatMapCompletable(lucaEndpointsV3 -> Single.zip(generateEncodedDocumentHash(document), generateOrRestoreDocumentTag(document), (hash, tag) -> {
JsonObject jsonObject = new JsonObject();
jsonObject.addProperty("hash", hash);
jsonObject.addProperty("tag", tag);
JsonObject jsonObject = jsonObjectWith(hash, tag);
jsonObject.addProperty("expireAt", TimeUtil.convertToUnixTimestamp(document.getExpirationTimestamp()).blockingGet());
return jsonObject;
}).flatMapCompletable(lucaEndpointsV3::redeemDocument))
......@@ -175,6 +180,43 @@ public class DocumentManager extends Manager {
});
}
/**
* Unredeem the given document so it can be imported again on another device
*
* @param document document object to unredeem
*/
public Completable unredeemDocument(@NonNull Document document) {
return networkManager.getLucaEndpointsV3()
.flatMapCompletable(lucaEndpointsV3 ->
Single.zip(generateEncodedDocumentHash(document), generateOrRestoreDocumentTag(document), (hash, tag) ->
jsonObjectWith(hash, tag)
).flatMapCompletable(body -> lucaEndpointsV3.unredeemDocument(body)
.onErrorResumeNext(throwable -> {
if (NetworkManager.isHttpException(throwable, HttpURLConnection.HTTP_NOT_FOUND)) {
// The route is not yet available on backend or the document was already unredeemed
return Completable.complete();
}
return Completable.error(throwable);
})
));
}
/**
* Unredeem and delete all documents stored so they can be imported again on another device
*/
public Completable unredeemAndDeleteAllDocuments() {
return getOrRestoreDocuments()
.flatMapCompletable(document -> unredeemDocument(document)
.andThen(deleteDocument(document.getId())));
}
private JsonObject jsonObjectWith(String hash, String tag) {
JsonObject jsonObject = new JsonObject();
jsonObject.addProperty("hash", hash);
jsonObject.addProperty("tag", tag);
return jsonObject;
}
protected Single<String> generateEncodedDocumentHash(@NonNull Document document) {
return Single.fromCallable(document::getHashableEncodedData)
.map(hashableEncodedData -> hashableEncodedData.getBytes(StandardCharsets.UTF_8))
......
......@@ -75,6 +75,7 @@ public class BaercodeDocumentProvider extends DocumentProvider<BaercodeDocument>
.map(bytes -> {
BaercodeDocument document = new BaercodeDocument(bytes);
decryptPersonalData(document);
document.getDocument().setVerified(true);
return document;
}).onErrorResumeNext(throwable -> {
if (throwable instanceof DocumentParsingException || throwable instanceof DocumentImportException) {
......
......@@ -15,30 +15,27 @@ import io.reactivex.rxjava3.core.Single
* Provider for the EU Digital COVID Certificate (EUDCC)
*/
class EudccDocumentProvider(val context: Context) : DocumentProvider<EudccResult>() {
private val base45Decoder = Base45Decoder()
private val decoder = DefaultCertificateDecoder(base45Decoder)
override fun canParse(encodedData: String): Single<Boolean> {
return Single.fromCallable {
try {
val withoutPrefix = if (encodedData.startsWith(PREFIX)) encodedData.drop(PREFIX.length) else encodedData
val decompressed = base45Decoder.decode(withoutPrefix).decompressBase45DecodedData()
val cbor = decompressed.decodeCose().cbor
EudccSchemaValidator().validate(cbor)
} catch (t: Throwable) {
false
}
}
val withoutPrefix = if (encodedData.startsWith(PREFIX)) encodedData.drop(PREFIX.length) else encodedData
val decompressed = base45Decoder.decode(withoutPrefix).decompressBase45DecodedData()
val cbor = decompressed.decodeCose().cbor
EudccSchemaValidator().validate(cbor)
}.onErrorReturn { false }
}
override fun parse(encodedData: String): Single<EudccResult> {
return Single.just(EudccResult(encodedData, decoder.decodeCertificate(encodedData)))
.map { it.document.provider = context.getString(R.string.provider_name_eu_dcc); it }
.onErrorResumeNext { throwable ->
if (throwable is DocumentParsingException) {
Single.error<DocumentParsingException>(throwable)
return Single.fromCallable { EudccResult(encodedData, decoder.decodeCertificate(encodedData)) }
.map { it.document.provider = context.getString(R.string.provider_name_eu_dcc); it }
.onErrorResumeNext { throwable ->
if (throwable is DocumentParsingException) {
Single.error<DocumentParsingException>(throwable)
}
Single.error(DocumentParsingException(throwable))
}
Single.error(DocumentParsingException(throwable))
}
}
}
\ No newline at end of file
......@@ -52,7 +52,7 @@ class EudccResult(encodedData: String, result: CertificateDecodingResult) : Prov
}
}
}
throw DocumentParsingException("Could not parse EUDCC: ${error.error}")
throw DocumentParsingException("Could not parse EUDCC: ${error.error}", error.error.error)
}
}
......
......@@ -85,9 +85,6 @@ public class OpenTestCheckDocument extends ProvidedDocument {
}
l = l.trim().replaceAll(" {2,}", System.lineSeparator());
document.setLabName(l);
if (l.startsWith("DFB") && document.getType() == Document.TYPE_UNKNOWN) {
document.setType(Document.TYPE_GREEN_PASS);
}
document.setProvider(l); // will be replaced with test software provider if available
// lab doctor name
......
......@@ -62,6 +62,7 @@ public class OpenTestCheckDocumentProvider extends DocumentProvider<OpenTestChec
.flatMap(openTestCheckDocument -> getProviderName(openTestCheckDocument)
.map(providerName -> {
openTestCheckDocument.getDocument().setProvider(providerName);
openTestCheckDocument.getDocument().setVerified(true);
return openTestCheckDocument;
}))
.onErrorResumeNext(throwable -> Single.error(new DocumentParsingException(throwable)));
......
......@@ -154,6 +154,9 @@ public interface LucaEndpointsV3 {
@POST("tests/redeem")
Completable redeemDocument(@Body JsonObject message);
@HTTP(method = "DELETE", path = "tests/redeem", hasBody = true)
Completable unredeemDocument(@Body JsonObject message);
@GET("testProviders")
Single<DocumentProviderDataList> getDocumentProviders();
......
......@@ -204,7 +204,6 @@ public class RegistrationManager extends Manager {
.doOnSuccess(registrationData -> registrationData.setId(userId))
.flatMapCompletable(this::persistRegistrationData)
));
}
/**
......
......@@ -282,7 +282,8 @@ public abstract class BaseViewModel extends AndroidViewModel {
* successful, show error dialog when an error occurred.
*/
public void deleteAccount() {
modelDisposable.add(application.getRegistrationManager().deleteRegistrationOnBackend()
modelDisposable.add(application.getDocumentManager().unredeemAndDeleteAllDocuments()
.andThen(application.getRegistrationManager().deleteRegistrationOnBackend())
.doOnSubscribe(disposable -> {
updateAsSideEffect(isLoading, true);
removeError(deleteAccountError);
......
......@@ -13,7 +13,7 @@ public class AppointmentItem extends TestResultItem {
public AppointmentItem(@NonNull Context context, @NonNull Document document) {
super(context, document);
this.title = context.getString(R.string.appointment_title);
this.title = context.getString(R.string.appointment_title, document.getFirstName());
this.color = ContextCompat.getColor(context, R.color.appointment);
this.deleteButtonText = context.getString(R.string.delete_appointment_action);
this.provider = null;
......
package de.culture4life.luca.ui.myluca;
import android.content.Context;
import de.culture4life.luca.R;
import de.culture4life.luca.document.Document;
import androidx.annotation.NonNull;
import androidx.core.content.ContextCompat;
public class GreenPassItem extends MyLucaListItem {
protected final Document document;
public GreenPassItem(@NonNull Context context, @NonNull Document document) {
super(TYPE_GREEN_PASS);
this.document = document;
this.title = context.getString(R.string.em_green_pass);
this.provider = getReadableProvider(context, document.getProvider());
this.timestamp = document.getImportTimestamp();
this.barcode = generateQrCode(document.getEncodedData()).blockingGet();
this.color = ContextCompat.getColor(context, R.color.green_pass);
this.imageResource = R.drawable.ic_dfb;
this.deleteButtonText = context.getString(R.string.item_delete_action);
String time = context.getString(R.string.document_result_time, getReadableDate(context, document.getResultTimestamp()));
addTopContent(document.getLabDoctorName(), "");
addTopContent(context.getString(R.string.em_green_pass_valid), time);
addCollapsedContent(context.getString(R.string.document_issued_by), document.getLabName());
}
public Document getDocument() {
return document;
}
}
package de.culture4life.luca.ui.myluca;
import com.google.android.material.button.MaterialButton;
import com.google.android.material.card.MaterialCardView;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import com.google.common.util.concurrent.ListenableFuture;
import android.util.Size;
import android.view.View;
import android.widget.ImageView;
......@@ -7,6 +12,14 @@ import android.widget.ScrollView;
import android.widget.TextView;
import android.widget.Toast;
import de.culture4life.luca.R;
import de.culture4life.luca.document.Document;
import de.culture4life.luca.ui.BaseFragment;
import de.culture4life.luca.ui.dialog.BaseDialogFragment;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executors;
import androidx.annotation.NonNull;
import androidx.camera.core.Camera;
import androidx.camera.core.CameraSelector;
......@@ -18,19 +31,6 @@ import androidx.core.content.ContextCompat;
import androidx.lifecycle.LifecycleOwner;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import com.google.android.material.button.MaterialButton;
import com.google.android.material.card.MaterialCardView;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import com.google.common.util.concurrent.ListenableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executors;
import de.culture4life.luca.R;
import de.culture4life.luca.document.Document;
import de.culture4life.luca.ui.BaseFragment;
import de.culture4life.luca.ui.dialog.BaseDialogFragment;
import io.reactivex.rxjava3.core.Completable;
import io.reactivex.rxjava3.core.Maybe;
import io.reactivex.rxjava3.core.Single;
......@@ -260,6 +260,7 @@ public class MyLucaFragment extends BaseFragment<MyLucaViewModel> implements MyL
})
.setPositiveButton(R.string.action_confirm, (dialog, which) ->
viewDisposable.add(viewModel.deleteListItem(myLucaListItem)
.onErrorComplete()
.subscribeOn(Schedulers.io())
.subscribe()));
new BaseDialogFragment(builder).show();
......
......@@ -9,8 +9,6 @@ import androidx.fragment.app.Fragment;
import androidx.recyclerview.widget.RecyclerView;
import androidx.viewpager2.widget.ViewPager2;
import org.jetbrains.annotations.NotNull;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
......@@ -50,7 +48,7 @@ public class MyLucaListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHol
}
@Override
public RecyclerView.@NotNull ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
public @NonNull RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
if (viewType == SINGLE_ITEM_VIEW_HOLDER) {
ViewGroup view = (ViewGroup) LayoutInflater.from(parent.getContext()).inflate(R.layout.my_luca_list_item_container, parent, false);
return new SingleMyLucaItemViewHolder(view);
......@@ -80,16 +78,12 @@ public class MyLucaListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHol
holder.getConstraintLayoutContainer().addView(singleLucaItemView);
singleLucaItemView.setListeners(expandClickListener, deleteClickListener);
} else {
MyLucaListItemExpandListener expandClickListener = new MyLucaListItemExpandListener() {
@Override
public void onExpand() {
for (int i = 0; i < items.size(); i++) {
MyLucaListItem item = items.get(i);
item.toggleExpanded();
}
notifyItemChanged(position);
MyLucaListItemExpandListener expandClickListener = () -> {
for (int i = 0; i < items.size(); i++) {
MyLucaListItem item = items.get(i);
item.toggleExpanded();
}
notifyItemChanged(position);
};
Integer hashCode = items.hashCode();
MyLucaItemViewPager viewPagerAdapter = new MyLucaItemViewPager(this.fragment, items, expandClickListener, clickListener, position);
......
package de.culture4life.luca.ui.myluca;
import com.google.zxing.EncodeHintType;
import android.content.Context;
import android.graphics.Bitmap;
import android.text.TextUtils;
import android.util.Pair;
import androidx.annotation.ColorInt;
import androidx.annotation.DrawableRes;
import androidx.annotation.IntDef;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.google.zxing.EncodeHintType;
import de.culture4life.luca.R;
import net.glxn.qrgen.android.QRCode;
import java.io.Serializable;
import java.lang.annotation.Retention;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
......@@ -23,7 +18,11 @@ import java.util.Date;
import java.util.List;
import java.util.Locale;
import de.culture4life.luca.R;
import androidx.annotation.ColorInt;
import androidx.annotation.DrawableRes;
import androidx.annotation.IntDef;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import io.reactivex.rxjava3.core.Single;
import static java.lang.annotation.RetentionPolicy.SOURCE;
......@@ -33,6 +32,7 @@ public abstract class MyLucaListItem {
public static final int TYPE_UNKNOWN = 0;
public static final int TYPE_TEST_RESULT = 1;
public static final int TYPE_APPOINTMENT = 2;
@Deprecated
public static final int TYPE_GREEN_PASS = 3;
@IntDef({TYPE_UNKNOWN, TYPE_TEST_RESULT, TYPE_APPOINTMENT, TYPE_GREEN_PASS})
......
......@@ -57,10 +57,11 @@ public class MyLucaViewModel extends BaseViewModel implements ImageAnalysis.Anal
private Disposable imageProcessingDisposable;
private ViewError importError;
private ViewError deleteError;
public MyLucaViewModel(@NonNull Application application) {
super(application);
this.documentManager = this.application.getTestingManager();
this.documentManager = this.application.getDocumentManager();
this.notificationManager = this.application.getNotificationManager();
this.registrationManager = this.application.getRegistrationManager();
this.scanner = BarcodeScanning.getClient();
......@@ -110,9 +111,7 @@ public class MyLucaViewModel extends BaseViewModel implements ImageAnalysis.Anal
private Maybe<MyLucaListItem> createListItem(@NonNull Document document) {
return Maybe.fromCallable(() -> {
if (document.getType() == Document.TYPE_GREEN_PASS) {
return new GreenPassItem(application, document);
} else if (document.getType() == Document.TYPE_APPOINTMENT) {
if (document.getType() == Document.TYPE_APPOINTMENT) {
return new AppointmentItem(application, document);
} else if (document.getType() == Document.TYPE_VACCINATION) {
return new VaccinationItem(application, document);
......@@ -126,15 +125,27 @@ public class MyLucaViewModel extends BaseViewModel implements ImageAnalysis.Anal
public Completable deleteListItem(@NonNull MyLucaListItem myLucaListItem) {
return Completable.defer(() -> {
Document document;
if (myLucaListItem instanceof TestResultItem) {
return documentManager.deleteDocument(((TestResultItem) myLucaListItem).getDocument().getId())
.andThen(invokeListUpdate());
} else if (myLucaListItem instanceof GreenPassItem) {
return documentManager.deleteDocument(((GreenPassItem) myLucaListItem).getDocument().getId())
.andThen(invokeListUpdate());
document = ((TestResultItem) myLucaListItem).getDocument();
} else {