From d474cc903dfc6fab8b7ec26cd1fc3204b8f227e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Sch=C3=BCtz?= Date: Wed, 29 Jul 2020 15:19:22 +0200 Subject: [PATCH] Add read flag to alerts (#140) * Allow to mark alerts as read * Rename field * Rename variable * Update C headers * Typo * Correct name --- .../core/JNIInterfaceBootstrappedTests.kt | 12 + .../java/org/coepi/core/JNIInterfaceTests.kt | 6 +- .../java/org/coepi/core/domain/model/Alert.kt | 3 +- .../main/java/org/coepi/core/jni/JniApi.kt | 7 +- .../java/org/coepi/core/services/AlertsApi.kt | 31 +-- src/android/android_interface.rs | 24 +- src/android/jni_domain_tests.rs | 1 + src/database/alert_dao.rs | 210 +++++++++++++++++- src/ios/c_headers/coepicore.h | 4 + src/ios/ios_interface.rs | 11 + src/reports_update/reports_updater.rs | 3 + 11 files changed, 286 insertions(+), 26 deletions(-) diff --git a/android/core/core/src/androidTest/java/org/coepi/core/JNIInterfaceBootstrappedTests.kt b/android/core/core/src/androidTest/java/org/coepi/core/JNIInterfaceBootstrappedTests.kt index 5281897..e04fc3d 100644 --- a/android/core/core/src/androidTest/java/org/coepi/core/JNIInterfaceBootstrappedTests.kt +++ b/android/core/core/src/androidTest/java/org/coepi/core/JNIInterfaceBootstrappedTests.kt @@ -72,6 +72,18 @@ class JNIInterfaceBootstrappedTests { assertEquals(JniVoidResult(6, "Not found"), value) } + @Test + fun updateAlertIsReadWithTrue() { + val value = JniApi().updateAlertIsRead("1", 1) + assertEquals(JniVoidResult(6, "Not found"), value) + } + + @Test + fun updateAlertIsReadWithFalse() { + val value = JniApi().updateAlertIsRead("1", 0) + assertEquals(JniVoidResult(6, "Not found"), value) + } + @Test fun recordTcn() { val value = JniApi().recordTcn("2485a64b57addcaea3ed1b538d07dbce", 34.03f) diff --git a/android/core/core/src/androidTest/java/org/coepi/core/JNIInterfaceTests.kt b/android/core/core/src/androidTest/java/org/coepi/core/JNIInterfaceTests.kt index b4f77cb..ae99013 100644 --- a/android/core/core/src/androidTest/java/org/coepi/core/JNIInterfaceTests.kt +++ b/android/core/core/src/androidTest/java/org/coepi/core/JNIInterfaceTests.kt @@ -48,7 +48,7 @@ class JNIInterfaceTests { runnyNose = true, other = false, noSymptoms = true - ), 1592567315, 1592567335, 1.2f, 2.1f + ), 1592567315, 1592567335, 1.2f, 2.1f, false ) ), value @@ -75,7 +75,7 @@ class JNIInterfaceTests { runnyNose = true, other = false, noSymptoms = true - ), 1592567315, 1592567335, 1.2f, 2.1f + ), 1592567315, 1592567335, 1.2f, 2.1f, false ), JniAlert( "343356", JniPublicSymptoms( @@ -90,7 +90,7 @@ class JNIInterfaceTests { runnyNose = true, other = false, noSymptoms = true - ), 1592567315, 1592567335, 1.2f, 2.1f + ), 1592567315, 1592567335, 1.2f, 2.1f, false ) ) ), diff --git a/android/core/core/src/main/java/org/coepi/core/domain/model/Alert.kt b/android/core/core/src/main/java/org/coepi/core/domain/model/Alert.kt index 1f5eec2..0868a79 100644 --- a/android/core/core/src/main/java/org/coepi/core/domain/model/Alert.kt +++ b/android/core/core/src/main/java/org/coepi/core/domain/model/Alert.kt @@ -20,7 +20,8 @@ data class Alert( var contactStart: UnixTime, var contactEnd: UnixTime, var minDistance: LengthMeasurement, - var avgDistance: LengthMeasurement + var avgDistance: LengthMeasurement, + var isRead: Boolean ) : Parcelable enum class FeverSeverity { diff --git a/android/core/core/src/main/java/org/coepi/core/jni/JniApi.kt b/android/core/core/src/main/java/org/coepi/core/jni/JniApi.kt index 52af24d..b4c375e 100644 --- a/android/core/core/src/main/java/org/coepi/core/jni/JniApi.kt +++ b/android/core/core/src/main/java/org/coepi/core/jni/JniApi.kt @@ -21,6 +21,8 @@ class JniApi { external fun deleteAlert(id: String): JniVoidResult + external fun updateAlertIsRead(id: String, isRead: Int): JniVoidResult + external fun generateTcn(): String external fun recordTcn(tcn: String, distance: Float): JniVoidResult @@ -134,11 +136,12 @@ data class JniAlertsArrayResult( data class JniAlert( var id: String, - var report: JniPublicSymptoms, + var symptoms: JniPublicSymptoms, var contactStart: Long, var contactEnd: Long, var minDistance: Float, - var avgDistance: Float + var avgDistance: Float, + var isRead: Boolean ) data class JniPublicSymptoms( diff --git a/android/core/core/src/main/java/org/coepi/core/services/AlertsApi.kt b/android/core/core/src/main/java/org/coepi/core/services/AlertsApi.kt index f7b0b44..79bdde0 100644 --- a/android/core/core/src/main/java/org/coepi/core/services/AlertsApi.kt +++ b/android/core/core/src/main/java/org/coepi/core/services/AlertsApi.kt @@ -59,25 +59,26 @@ class AlertsFetcherImpl(private val api: JniApi) : else -> Meters(avgDistance) }, reportTime = when { - report.reportTime < 0 -> error("Invalid report time: ${report.reportTime}") - else -> UnixTime.fromValue(report.reportTime) + symptoms.reportTime < 0 -> error("Invalid report time: ${symptoms.reportTime}") + else -> UnixTime.fromValue(symptoms.reportTime) }, earliestSymptomTime = when { - report.earliestSymptomTime == -1L -> + symptoms.earliestSymptomTime == -1L -> None - report.earliestSymptomTime < -1L -> - error("Invalid earliestSymptomTime: ${report.earliestSymptomTime}") + symptoms.earliestSymptomTime < -1L -> + error("Invalid earliestSymptomTime: ${symptoms.earliestSymptomTime}") else -> - Some(UnixTime.fromValue(report.earliestSymptomTime)) + Some(UnixTime.fromValue(symptoms.earliestSymptomTime)) }, - feverSeverity = toFeverSeverity(report.feverSeverity), - coughSeverity = toCoughSeverity(report.coughSeverity), - breathlessness = report.breathlessness, - muscleAches = report.muscleAches, - lossSmellOrTaste = report.lossSmellOrTaste, - diarrhea = report.diarrhea, - runnyNose = report.runnyNose, - other = report.other, - noSymptoms = report.noSymptoms + feverSeverity = toFeverSeverity(symptoms.feverSeverity), + coughSeverity = toCoughSeverity(symptoms.coughSeverity), + breathlessness = symptoms.breathlessness, + muscleAches = symptoms.muscleAches, + lossSmellOrTaste = symptoms.lossSmellOrTaste, + diarrhea = symptoms.diarrhea, + runnyNose = symptoms.runnyNose, + other = symptoms.other, + noSymptoms = symptoms.noSymptoms, + isRead = isRead ) } diff --git a/src/android/android_interface.rs b/src/android/android_interface.rs index 05e6f1d..4d373e0 100644 --- a/src/android/android_interface.rs +++ b/src/android/android_interface.rs @@ -85,6 +85,16 @@ pub unsafe extern "C" fn Java_org_coepi_core_jni_JniApi_deleteAlert( delete_alert(&env, id).to_void_jni(&env) } +#[no_mangle] +pub unsafe extern "C" fn Java_org_coepi_core_jni_JniApi_updateAlertIsRead( + env: JNIEnv, + _: JClass, + id: JString, + is_read: jint, +) -> jobject { + update_alert_is_read(&env, id, is_read).to_void_jni(&env) +} + #[no_mangle] pub unsafe extern "C" fn Java_org_coepi_core_jni_JniApi_recordTcn( env: JNIEnv, @@ -279,6 +289,15 @@ fn delete_alert(env: &JNIEnv, id: JString) -> Result<(), ServicesError> { dependencies().alert_dao.delete(id_str.to_owned()) } +fn update_alert_is_read(env: &JNIEnv, id: JString, is_read: jint) -> Result<(), ServicesError> { + let id_java_str = env.get_string(id)?; + let id_str = id_java_str.to_str()?; + + dependencies() + .alert_dao + .update_is_read(id_str.to_owned(), is_read == 1) +} + fn record_tcn(env: &JNIEnv, tcn: JString, distance: jfloat) -> Result<(), ServicesError> { let tcn_java_str = env.get_string(tcn)?; let tcn_str = tcn_java_str.to_str()?; @@ -587,6 +606,7 @@ fn placeholder_alert() -> Alert { contact_end: 0, min_distance: 0.0, avg_distance: 0.0, + is_read: false, } } @@ -651,11 +671,12 @@ pub fn alert_to_jobject(alert: Alert, env: &JNIEnv) -> Result = env .new_object( jni_alert_class, - "(Ljava/lang/String;Lorg/coepi/core/jni/JniPublicSymptoms;JJFF)V", + "(Ljava/lang/String;Lorg/coepi/core/jni/JniPublicSymptoms;JJFFZ)V", &[ id_j_value, JValue::from(jni_public_symptoms_obj), @@ -663,6 +684,7 @@ pub fn alert_to_jobject(alert: Alert, env: &JNIEnv) -> Result Alert { contact_end: 1592567335, min_distance: 1.2, avg_distance: 2.1, + is_read: false, } } diff --git a/src/database/alert_dao.rs b/src/database/alert_dao.rs index 3c23230..db87031 100644 --- a/src/database/alert_dao.rs +++ b/src/database/alert_dao.rs @@ -18,6 +18,7 @@ pub trait AlertDao { fn all(&self) -> Result, ServicesError>; fn save(&self, alerts: Vec) -> Result<(), ServicesError>; fn delete(&self, id: String) -> Result<(), ServicesError>; + fn update_is_read(&self, id: String, is_read: bool) -> Result<(), ServicesError>; } pub struct AlertDaoImpl { @@ -52,6 +53,7 @@ impl AlertDaoImpl { other integer not null, no_symptoms integer not null, report_id text not null, + read integer not null, deleted integer )", params![], @@ -126,6 +128,9 @@ impl AlertDaoImpl { let report_id_res = row.get(16); let report_id = expect_log!(report_id_res, "Invalid row: no report_id"); + let read_res = row.get(17); + let read: i8 = expect_log!(read_res, "Invalid row: no read"); + Alert { id, report_id, @@ -148,6 +153,7 @@ impl AlertDaoImpl { contact_end: end as u64, min_distance: min_distance as f32, avg_distance: avg_distance as f32, + is_read: to_bool(read), } } } @@ -173,7 +179,8 @@ impl AlertDao for AlertDaoImpl { runny_nose, other, no_symptoms, - report_id + report_id, + read from alert where deleted is null", NO_PARAMS, |row| Self::to_alert(row), @@ -205,6 +212,31 @@ impl AlertDao for AlertDaoImpl { } } + fn update_is_read(&self, id: String, is_read: bool) -> Result<(), ServicesError> { + debug!("Marking alert as read with id: {}", id); + + let delete_res = self.db.execute_sql( + "update alert set read=? where id=?;", + params![to_db_int(is_read), id], + ); + + match delete_res { + Ok(count) => { + if count > 0 { + debug!("Updated: {} rows", count); + Ok(()) + } else { + error!("Didn't find alert to mark as read: {}", id); + Err(ServicesError::NotFound) + } + } + Err(e) => Err(ServicesError::General(format!( + "Error marking alert as read: {}", + e + ))), + } + } + fn save(&self, alerts: Vec) -> Result<(), ServicesError> { self.db.transaction(|t| { for alert in alerts { @@ -226,8 +258,9 @@ impl AlertDao for AlertDaoImpl { runny_nose, other, no_symptoms, - report_id - ) values(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)", + report_id, + read + ) values(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)", params![ alert.id, alert.contact_start as i64, @@ -249,7 +282,8 @@ impl AlertDao for AlertDaoImpl { to_db_int(alert.symptoms.runny_nose), to_db_int(alert.symptoms.other), to_db_int(alert.symptoms.no_symptoms), - alert.report_id + alert.report_id, + to_db_int(alert.is_read) ], )?; } @@ -315,6 +349,7 @@ mod tests { contact_end: 2000, min_distance: 2.3, avg_distance: 4.3, + is_read: false, }; let save_res = alert_dao.save(vec![alert.clone()]); @@ -358,6 +393,7 @@ mod tests { contact_end: 2000, min_distance: 2.3, avg_distance: 4.3, + is_read: false, }; let alert2 = Alert { @@ -368,6 +404,7 @@ mod tests { contact_end: 2001, min_distance: 2.4, avg_distance: 4.4, + is_read: false, }; let save_res = alert_dao.save(vec![alert1.clone(), alert2.clone()]); @@ -411,6 +448,7 @@ mod tests { contact_end: 2000, min_distance: 2.3, avg_distance: 4.3, + is_read: false, }; let alert2 = Alert { @@ -421,6 +459,7 @@ mod tests { contact_end: 2001, min_distance: 2.4, avg_distance: 4.4, + is_read: true, }; let save_res = alert_dao.save(vec![alert1.clone(), alert2.clone()]); @@ -465,6 +504,7 @@ mod tests { contact_end: 2000, min_distance: 2.3, avg_distance: 4.3, + is_read: false, }; let alert2 = Alert { @@ -475,6 +515,7 @@ mod tests { contact_end: 2001, min_distance: 2.4, avg_distance: 4.4, + is_read: true, }; let save_res = alert_dao.save(vec![alert1.clone(), alert2.clone()]); @@ -521,6 +562,7 @@ mod tests { contact_end: 2000, min_distance: 2.3, avg_distance: 4.3, + is_read: false, }; let alert2 = Alert { @@ -531,6 +573,7 @@ mod tests { contact_end: 2001, min_distance: 2.4, avg_distance: 4.4, + is_read: true, }; let save_res = alert_dao.save(vec![alert1.clone(), alert2.clone()]); @@ -550,4 +593,163 @@ mod tests { assert_eq!(loaded_alerts.len(), 1); assert_eq!(loaded_alerts[0], alert1); } + + #[test] + fn test_marks_alert_as_read() { + let database = Arc::new(Database::new( + Connection::open_in_memory().expect("Couldn't create database!"), + )); + let alert_dao = AlertDaoImpl::new(database.clone()); + + let symptoms = PublicSymptoms { + report_time: UnixTime { value: 0 }, + earliest_symptom_time: UserInput::Some(UnixTime { value: 1590356601 }), + fever_severity: FeverSeverity::Mild, + cough_severity: CoughSeverity::Dry, + breathlessness: true, + muscle_aches: true, + loss_smell_or_taste: false, + diarrhea: false, + runny_nose: true, + other: false, + no_symptoms: true, + }; + + let alert = Alert { + id: "1".to_owned(), + report_id: "1".to_owned(), + symptoms: symptoms.clone(), + contact_start: 1000, + contact_end: 2000, + min_distance: 2.3, + avg_distance: 4.3, + is_read: false, + }; + + let save_res = alert_dao.save(vec![alert.clone()]); + assert!(save_res.is_ok()); + + let update_res = alert_dao.update_is_read("1".to_owned(), true); + assert!(update_res.is_ok()); + + let loaded_alerts_res = alert_dao.all(); + assert!(loaded_alerts_res.is_ok()); + + let loaded_alerts = loaded_alerts_res.unwrap(); + + assert_eq!(loaded_alerts.len(), 1); + assert_eq!( + loaded_alerts[0], + Alert { + is_read: true, + ..alert + } + ); + } + + #[test] + fn test_marks_alert_as_unread() { + let database = Arc::new(Database::new( + Connection::open_in_memory().expect("Couldn't create database!"), + )); + let alert_dao = AlertDaoImpl::new(database.clone()); + + let symptoms = PublicSymptoms { + report_time: UnixTime { value: 0 }, + earliest_symptom_time: UserInput::Some(UnixTime { value: 1590356601 }), + fever_severity: FeverSeverity::Mild, + cough_severity: CoughSeverity::Dry, + breathlessness: true, + muscle_aches: true, + loss_smell_or_taste: false, + diarrhea: false, + runny_nose: true, + other: false, + no_symptoms: true, + }; + + let alert = Alert { + id: "1".to_owned(), + report_id: "1".to_owned(), + symptoms: symptoms.clone(), + contact_start: 1000, + contact_end: 2000, + min_distance: 2.3, + avg_distance: 4.3, + is_read: true, + }; + + let save_res = alert_dao.save(vec![alert.clone()]); + assert!(save_res.is_ok()); + + let update_res = alert_dao.update_is_read("1".to_owned(), false); + assert!(update_res.is_ok()); + + let loaded_alerts_res = alert_dao.all(); + assert!(loaded_alerts_res.is_ok()); + + let loaded_alerts = loaded_alerts_res.unwrap(); + + assert_eq!(loaded_alerts.len(), 1); + assert_eq!( + loaded_alerts[0], + Alert { + is_read: false, + ..alert + } + ); + } + + #[test] + fn test_marks_alert_as_read_if_already_read_does_nothing() { + let database = Arc::new(Database::new( + Connection::open_in_memory().expect("Couldn't create database!"), + )); + let alert_dao = AlertDaoImpl::new(database.clone()); + + let symptoms = PublicSymptoms { + report_time: UnixTime { value: 0 }, + earliest_symptom_time: UserInput::Some(UnixTime { value: 1590356601 }), + fever_severity: FeverSeverity::Mild, + cough_severity: CoughSeverity::Dry, + breathlessness: true, + muscle_aches: true, + loss_smell_or_taste: false, + diarrhea: false, + runny_nose: true, + other: false, + no_symptoms: true, + }; + + let alert1 = Alert { + id: "1".to_owned(), + report_id: "1".to_owned(), + symptoms: symptoms.clone(), + contact_start: 1000, + contact_end: 2000, + min_distance: 2.3, + avg_distance: 4.3, + is_read: true, + }; + + let save_res = alert_dao.save(vec![alert1.clone()]); + assert!(save_res.is_ok()); + + let update_res = alert_dao.update_is_read("1".to_owned(), true); + assert!(update_res.is_ok()); + + let loaded_alerts_res = alert_dao.all(); + assert!(loaded_alerts_res.is_ok()); + + let loaded_alerts = loaded_alerts_res.unwrap(); + + assert_eq!(loaded_alerts.len(), 1); + assert_eq!( + loaded_alerts[0], + Alert { + is_read: true, + ..alert1 + } + ); + } } diff --git a/src/ios/c_headers/coepicore.h b/src/ios/c_headers/coepicore.h index a47a71d..e74ddb0 100644 --- a/src/ios/c_headers/coepicore.h +++ b/src/ios/c_headers/coepicore.h @@ -154,3 +154,7 @@ int32_t trigger_callback(const char *my_str); #if (defined(TARGET_OS_IOS) || defined(TARGET_OS_MACOS)) int32_t trigger_logging_macros(void); #endif + +#if (defined(TARGET_OS_IOS) || defined(TARGET_OS_MACOS)) +CFStringRef update_alert_is_read(const char *id, uint8_t is_read); +#endif diff --git a/src/ios/ios_interface.rs b/src/ios/ios_interface.rs index d9ecf83..18297db 100644 --- a/src/ios/ios_interface.rs +++ b/src/ios/ios_interface.rs @@ -70,6 +70,17 @@ pub unsafe extern "C" fn delete_alert(id: *const c_char) -> CFStringRef { to_result_str(result) } +#[no_mangle] +pub unsafe extern "C" fn update_alert_is_read(id: *const c_char, is_read: u8) -> CFStringRef { + let id_str = cstring_to_str(&id); + let result = id_str.and_then(|id| { + dependencies() + .alert_dao + .update_is_read(id.to_owned(), is_read == 1) + }); + to_result_str(result) +} + #[no_mangle] pub unsafe extern "C" fn record_tcn(c_tcn: *const c_char, distance: f32) -> CFStringRef { let tcn_str = cstring_to_str(&c_tcn); diff --git a/src/reports_update/reports_updater.rs b/src/reports_update/reports_updater.rs index ef0dde2..bb2fc96 100644 --- a/src/reports_update/reports_updater.rs +++ b/src/reports_update/reports_updater.rs @@ -35,6 +35,7 @@ pub struct Alert { pub contact_end: u64, pub min_distance: f32, pub avg_distance: f32, + pub is_read: bool, } pub trait SignedReportExt { @@ -114,6 +115,7 @@ where .collect() } + // Creates a new alert, corresponding to an exposure fn to_alert( &self, signed_report: SignedReport, @@ -140,6 +142,7 @@ where contact_end: measurements.contact_end.value, min_distance: measurements.min_distance, avg_distance: measurements.avg_distance, + is_read: false, }) }