diff --git a/app/build.gradle b/app/build.gradle index 65d7f380..27b62194 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -54,6 +54,7 @@ dependencies { implementation 'androidx.core:core-ktx:1.2.0' implementation 'androidx.constraintlayout:constraintlayout:1.1.3' testImplementation 'junit:junit:4.13' + testImplementation "com.google.truth:truth:1.0.1" androidTestImplementation 'androidx.test.ext:junit:1.1.1' androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0' implementation "androidx.navigation:navigation-fragment-ktx:$nav_version" @@ -98,6 +99,7 @@ dependencies { implementation "androidx.viewpager2:viewpager2:1.0.0" implementation "com.google.android.material:material:1.1.0" - implementation 'com.github.TCNCoalition:tcn-client-android:0.0.3' + implementation 'com.github.TCNCoalition:tcn-client-android:0.0.4' + implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.0.0" } diff --git a/app/src/main/java/org/coepi/android/MainActivity.kt b/app/src/main/java/org/coepi/android/MainActivity.kt index 50fbdda2..8889dc91 100644 --- a/app/src/main/java/org/coepi/android/MainActivity.kt +++ b/app/src/main/java/org/coepi/android/MainActivity.kt @@ -12,7 +12,7 @@ import io.reactivex.rxkotlin.plusAssign import org.coepi.android.R.id.rootNavHostFragment import org.coepi.android.R.layout.activity_main import org.coepi.android.ble.BlePreconditions -import org.coepi.android.cen.BleInitializer +import org.coepi.android.tcn.BleInitializer import org.coepi.android.system.intent.IntentForwarder import org.coepi.android.ui.common.UINotification import org.coepi.android.ui.common.UINotifier @@ -24,9 +24,9 @@ import org.koin.android.ext.android.inject class MainActivity : AppCompatActivity() { private val rootNav: RootNavigation by inject() private val onboardingShower: OnboardingShower by inject() - private val cenManager: BleInitializer by inject() + private val bleInitializer: BleInitializer by inject() private val blePreconditions: BlePreconditions by inject() - private val nonReferencedDependenciesActivator: NonReferencedDependenciesActivator by inject() + private val nonReferencedDependenciesActivator: NotReferencedDependenciesActivator by inject() private val intentForwarder: IntentForwarder by inject() private val uiNotifier: UINotifier by inject() @@ -49,7 +49,7 @@ class MainActivity : AppCompatActivity() { blePreconditions.onActivityCreated(this) AppCenter.start(application, "0bb1bf95-3b14-48a6-a769-db1ff1df0307", Analytics::class.java, Crashes::class.java) - cenManager.start() + bleInitializer.start() } override fun onNewIntent(intent: Intent?) { diff --git a/app/src/main/java/org/coepi/android/NonReferencedDependenciesActivator.kt b/app/src/main/java/org/coepi/android/NotReferencedDependenciesActivator.kt similarity index 71% rename from app/src/main/java/org/coepi/android/NonReferencedDependenciesActivator.kt rename to app/src/main/java/org/coepi/android/NotReferencedDependenciesActivator.kt index a29b0c7f..749b4b41 100644 --- a/app/src/main/java/org/coepi/android/NonReferencedDependenciesActivator.kt +++ b/app/src/main/java/org/coepi/android/NotReferencedDependenciesActivator.kt @@ -1,19 +1,19 @@ package org.coepi.android -import org.coepi.android.cross.ScannedCensHandler +import org.coepi.android.cross.ScannedTcnsHandler import org.coepi.android.system.intent.InfectionsNotificationIntentHandler import org.coepi.android.ui.notifications.AppNotificationChannels -import org.coepi.android.worker.cenfetcher.ContactsFetchManager +import org.coepi.android.worker.tcnfetcher.ContactsFetchManager -class NonReferencedDependenciesActivator( - scannedCensHandler: ScannedCensHandler, +class NotReferencedDependenciesActivator( + scannedTcnsHandler: ScannedTcnsHandler, notificationChannelsInitializer: AppNotificationChannels, contactsFetchManager: ContactsFetchManager, infectionsNotificationIntentHandler: InfectionsNotificationIntentHandler ) { init { listOf( - scannedCensHandler, + scannedTcnsHandler, notificationChannelsInitializer, contactsFetchManager, infectionsNotificationIntentHandler diff --git a/app/src/main/java/org/coepi/android/api/ApiCenReport.kt b/app/src/main/java/org/coepi/android/api/ApiCenReport.kt deleted file mode 100644 index 57d273ed..00000000 --- a/app/src/main/java/org/coepi/android/api/ApiCenReport.kt +++ /dev/null @@ -1,15 +0,0 @@ -package org.coepi.android.api - -import org.coepi.android.cen.CenReport - -data class ApiCenReport( - val reportID: String, - val report: String?, - val reportTimeStamp: Long -) - -fun ApiCenReport.toCenReport(): CenReport = CenReport( - id = reportID, - report = report ?: "", // The api omits the key if the report is empty - timestamp = reportTimeStamp -) diff --git a/app/src/main/java/org/coepi/android/api/CENApi.kt b/app/src/main/java/org/coepi/android/api/CENApi.kt deleted file mode 100644 index 15ee4fa2..00000000 --- a/app/src/main/java/org/coepi/android/api/CENApi.kt +++ /dev/null @@ -1,24 +0,0 @@ -package org.coepi.android.api - -import io.reactivex.Completable -import io.reactivex.Single -import org.coepi.android.api.request.ApiParamsCenReport -import retrofit2.Call -import retrofit2.http.Body -import retrofit2.http.GET -import retrofit2.http.POST -import retrofit2.http.Path - -interface CENApi { - // post CENReport along with CENKeys - @POST("cenreport/") - fun postCENReport(@Body report : ApiParamsCenReport): Completable - - // get recent keys that have CEN Reports - @GET("cenkeys/") - fun cenkeysCheck(): Call> - - // get report based on matched CENkey - @GET("cenreport/{key}") - fun getCenReports(@Path("key") key: String): Call> -} diff --git a/app/src/main/java/org/coepi/android/api/TcnApi.kt b/app/src/main/java/org/coepi/android/api/TcnApi.kt new file mode 100644 index 00000000..465c0e19 --- /dev/null +++ b/app/src/main/java/org/coepi/android/api/TcnApi.kt @@ -0,0 +1,19 @@ +package org.coepi.android.api + +import io.reactivex.Completable +import okhttp3.RequestBody +import retrofit2.Call +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.Query + +interface TcnApi { + + @GET("tcnreport/") + fun getReports(@Query("intervalNumber") intervalNumber: Long, + @Query("intervalLength") intervalLength: Long): Call> + + @POST("tcnreport/") + fun postReport(@Body report: RequestBody): Completable +} diff --git a/app/src/main/java/org/coepi/android/api/request/ApiParamsCenReport.kt b/app/src/main/java/org/coepi/android/api/request/ApiParamsCenReport.kt deleted file mode 100644 index cdb8943b..00000000 --- a/app/src/main/java/org/coepi/android/api/request/ApiParamsCenReport.kt +++ /dev/null @@ -1,8 +0,0 @@ -package org.coepi.android.api.request - -data class ApiParamsCenReport( - var reportID: String, - var report: String, - var cenKeys: String, - var reportTimeStamp: Long // Unix time -) diff --git a/app/src/main/java/org/coepi/android/ble/BleManagerImpl.kt b/app/src/main/java/org/coepi/android/ble/BleManagerImpl.kt index 45511231..8d0c2792 100644 --- a/app/src/main/java/org/coepi/android/ble/BleManagerImpl.kt +++ b/app/src/main/java/org/coepi/android/ble/BleManagerImpl.kt @@ -22,8 +22,8 @@ import io.reactivex.subjects.PublishSubject import io.reactivex.subjects.PublishSubject.create import org.coepi.android.MainActivity import org.coepi.android.R.drawable.ic_launcher_foreground -import org.coepi.android.cen.Cen -import org.coepi.android.cen.MyCenProvider +import org.coepi.android.tcn.Tcn +import org.coepi.android.domain.TcnGenerator import org.coepi.android.system.log.LogTag.BLE import org.coepi.android.system.log.log import org.tcncoalition.tcnclient.bluetooth.BluetoothStateListener @@ -32,7 +32,7 @@ import org.tcncoalition.tcnclient.bluetooth.TcnBluetoothService.LocalBinder import org.tcncoalition.tcnclient.bluetooth.TcnBluetoothServiceCallback interface BleManager { - val observedCens: Observable + val observedTcns: Observable fun startService() fun stopService() @@ -40,10 +40,10 @@ interface BleManager { class BleManagerImpl( private val app: Application, - private val myCenProvider: MyCenProvider + private val tcnGenerator: TcnGenerator ): BleManager, BluetoothStateListener { - override val observedCens: PublishSubject = create() + override val observedTcns: PublishSubject = create() private val intent get() = Intent(app, TcnBluetoothService::class.java) @@ -51,10 +51,10 @@ class BleManagerImpl( inner class BluetoothServiceCallback : TcnBluetoothServiceCallback { override fun generateTcn(): ByteArray = - myCenProvider.generateCen().bytes + tcnGenerator.generateTcn().bytes override fun onTcnFound(tcn: ByteArray, estimatedDistance: Double?) { - observedCens.onNext(Cen(tcn)) + observedTcns.onNext(Tcn(tcn)) } } diff --git a/app/src/main/java/org/coepi/android/ble/BleSimulator.kt b/app/src/main/java/org/coepi/android/ble/BleSimulator.kt index 2abd4b5c..6e8012af 100644 --- a/app/src/main/java/org/coepi/android/ble/BleSimulator.kt +++ b/app/src/main/java/org/coepi/android/ble/BleSimulator.kt @@ -2,43 +2,32 @@ package org.coepi.android.ble import io.reactivex.Observable import io.reactivex.Observable.fromIterable -import org.coepi.android.cen.Cen -import org.coepi.android.cen.CenKey -import org.coepi.android.domain.CenLogic -import org.coepi.android.domain.UnixTime.Companion.now +import org.coepi.android.tcn.Tcn +import org.coepi.android.extensions.base64ToByteArray import org.coepi.android.system.log.log +import org.tcncoalition.tcnclient.crypto.SignedReport -class BleSimulator(cenLogic: CenLogic) : BleManager { +class BleSimulator : BleManager { - // Keys to test locally - private val keys: List = listOf( - CenKey("F2BA29936ED6898157CD839FE50ACF40998533C3A4FECFD2B9FA252E0B10E14B", now()), - CenKey("e7c63d828922422cbba7ffed3f858598d5c97ec34442ec875b74cffdf316edd6", now()) + // Reports used to derive fake observed TCNs from + private val reports: List = listOf( +// "LbSUvv320gtY2qTZbumxno7KJ/BDWnHuHcUH0fNv144p+K1xbPt+YQuxFHzFfo71HoegSspNJLaAz93InuQHHQEAAQAACFJtVjJaWEk9iXj1FGy+r4cmNrS84AzHzx5wS0FZJXzXFFfvqwAogt6qjIe7+6CIJ8mrFrCen3nAVrQo3Bd1jsGe6UjybRUlAA==" + "rSqWpM3ZQm7hfQ3q2x2llnFHiNhyRrUQPKEtJ33VKQcwT7Ly6e4KGaj5ZzjWt0m4c0v5n/VH5HO9UXbPXvsQTgEAQQAALFVtMVdNbHBZU1hOSlJYaDJZek5OWjJJeVdXZFpXRUozV2xoU2NHUkhWVDA9jn0pZAeME6ZBRHJOlfIikyfS0Pjg6l0txhhz6hz4exTxv8ryA3/Z26OebSRwzRfRgLdWBfohaOwOcSaynKqVCg==" ) - private val cens: List = keys.map { - cenLogic.generateCen(it, now().value) + private val tcns: List = reports.mapNotNull { report -> + val signedReport = report.base64ToByteArray()?.let { SignedReport.fromByteArray(it) } + signedReport?.report?.temporaryContactNumbers?.let { + if (it.hasNext()) { it.next() } else { null } + }?.let { Tcn(it.bytes) } } - // Emits all the cens at once and terminates - override val observedCens: Observable = fromIterable(cens) - - // Use this to emit periodically -// private var currentCenIndex = 0 -// override val observedCens: Observable = -// Observable.interval(0, 5, SECONDS) -// .subscribeOn(Schedulers.io()) -// .observeOn(AndroidSchedulers.mainThread()) -// .flatMap { -// if (cens.isEmpty()) { -// empty() -// } else { -// just(cens[currentCenIndex % cens.size]) -// } -// } + // Emits all the TCNs at once and terminates + override val observedTcns: Observable = fromIterable(tcns) init { log.i("Using Bluetooth simulator") + log.i("Bluetooth simulator TCNs: $tcns") } override fun startService() {} diff --git a/app/src/main/java/org/coepi/android/ble/Uuids.kt b/app/src/main/java/org/coepi/android/ble/Uuids.kt deleted file mode 100644 index 32ed2b79..00000000 --- a/app/src/main/java/org/coepi/android/ble/Uuids.kt +++ /dev/null @@ -1,9 +0,0 @@ -package org.coepi.android.ble - -import java.util.UUID - -object Uuids { - val CoEpiServiceUUID = "BC908F39-52DB-416F-A97E-6EAC29F59CA8" - val service: UUID = UUID.fromString(CoEpiServiceUUID) - val characteristic: UUID = UUID.fromString("2ac35b0b-00b5-4af2-a50e-8412bcb94285") -} diff --git a/app/src/main/java/org/coepi/android/cen/ApiModule.kt b/app/src/main/java/org/coepi/android/cen/ApiModule.kt deleted file mode 100644 index dc4e1107..00000000 --- a/app/src/main/java/org/coepi/android/cen/ApiModule.kt +++ /dev/null @@ -1,34 +0,0 @@ -package org.coepi.android.cen - -import okhttp3.OkHttpClient -import okhttp3.logging.HttpLoggingInterceptor -import okhttp3.logging.HttpLoggingInterceptor.Level.BODY -import org.coepi.android.api.CENApi -import org.coepi.android.common.ApiSymptomsMapper -import org.coepi.android.common.ApiSymptomsMapperImpl -import org.koin.dsl.module -import retrofit2.Retrofit -import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory -import retrofit2.converter.gson.GsonConverterFactory - -val apiModule = module { - single { provideRetrofit() } - single { provideCENApi(get()) } - single { ApiSymptomsMapperImpl(get()) } -} - -private fun provideRetrofit() : Retrofit { - val client = OkHttpClient.Builder() - .addInterceptor(HttpLoggingInterceptor().apply { setLevel(BODY) }) - .build() - - return Retrofit.Builder() - .addCallAdapterFactory(RxJava2CallAdapterFactory.create()) - .addConverterFactory(GsonConverterFactory.create()) - .baseUrl("https://v1.api.coepi.org/") - .client(client) - .build() -} - -private fun provideCENApi(retrofit: Retrofit): CENApi = - retrofit.create(CENApi::class.java) diff --git a/app/src/main/java/org/coepi/android/cen/CENModule.kt b/app/src/main/java/org/coepi/android/cen/CENModule.kt deleted file mode 100644 index da42782a..00000000 --- a/app/src/main/java/org/coepi/android/cen/CENModule.kt +++ /dev/null @@ -1,23 +0,0 @@ -package org.coepi.android.cen - -import org.coepi.android.cross.ScannedCensHandler -import org.coepi.android.domain.CenLogic -import org.coepi.android.domain.CenLogicImpl -import org.coepi.android.domain.CenMatcher -import org.coepi.android.domain.CenMatcherImpl -import org.coepi.android.repo.CoEpiRepo -import org.coepi.android.repo.CoepiRepoImpl -import org.koin.dsl.module - -val CENModule = module { - single(createdAtStart = true) { RealmCenDao(get()) } - single(createdAtStart = true) { RealmCenReportDao(get()) } - single(createdAtStart = true) { RealmCenKeyDao(get()) } - single { CenReportRepoImpl(get(), get(), get()) } - single { CenMatcherImpl(get()) } - single { CenLogicImpl() } - single { CoepiRepoImpl(get(), get(), get(), get(), get(), get(), get(), get(), get()) } - single { MyCenProviderImpl(get(), get(), get()) } - single { ScannedCensHandler(get(), get(), get()) } - single { BleInitializer(get(), get()) } -} diff --git a/app/src/main/java/org/coepi/android/cen/CenDao.kt b/app/src/main/java/org/coepi/android/cen/CenDao.kt deleted file mode 100644 index 9c8f6e89..00000000 --- a/app/src/main/java/org/coepi/android/cen/CenDao.kt +++ /dev/null @@ -1,10 +0,0 @@ -package org.coepi.android.cen - -import org.coepi.android.domain.UnixTime - -interface CenDao { - fun all(): List - fun matchCENs(start: UnixTime, end: UnixTime, cens: Array): List - fun findCen(cen: Cen): ReceivedCen? - fun insert(cen: ReceivedCen): Boolean -} diff --git a/app/src/main/java/org/coepi/android/cen/CenKeyDao.kt b/app/src/main/java/org/coepi/android/cen/CenKeyDao.kt deleted file mode 100644 index 378f9ed6..00000000 --- a/app/src/main/java/org/coepi/android/cen/CenKeyDao.kt +++ /dev/null @@ -1,6 +0,0 @@ -package org.coepi.android.cen - -interface CenKeyDao { - fun lastCENKeys(limit : Int): List - fun insert(key: CenKey) -} diff --git a/app/src/main/java/org/coepi/android/cen/CenRepoNew.kt b/app/src/main/java/org/coepi/android/cen/CenRepoNew.kt deleted file mode 100644 index 2e1958d0..00000000 --- a/app/src/main/java/org/coepi/android/cen/CenRepoNew.kt +++ /dev/null @@ -1,2 +0,0 @@ -package org.coepi.android.cen - diff --git a/app/src/main/java/org/coepi/android/cen/CenReportDao.kt b/app/src/main/java/org/coepi/android/cen/CenReportDao.kt deleted file mode 100644 index 91865abc..00000000 --- a/app/src/main/java/org/coepi/android/cen/CenReportDao.kt +++ /dev/null @@ -1,11 +0,0 @@ -package org.coepi.android.cen - -import io.reactivex.Observable - -interface CenReportDao { - val reports: Observable> - - fun all(): List - fun insert(report: CenReport): Boolean - fun delete(report: SymptomReport) -} diff --git a/app/src/main/java/org/coepi/android/cen/CenReportRepo.kt b/app/src/main/java/org/coepi/android/cen/CenReportRepo.kt deleted file mode 100644 index 75965709..00000000 --- a/app/src/main/java/org/coepi/android/cen/CenReportRepo.kt +++ /dev/null @@ -1,38 +0,0 @@ -package org.coepi.android.cen - -import io.reactivex.Observable -import org.coepi.android.common.ApiSymptomsMapper -import org.coepi.android.repo.CoEpiRepo -import org.coepi.android.system.rx.VoidOperationState - -interface CenReportRepo { - val reports: Observable> - - val sendState: Observable - - fun sendReport(report: SymptomReport) - - fun delete(report: SymptomReport) -} - -class CenReportRepoImpl( - private val cenReportDao: CenReportDao, - private val coEpiRepo: CoEpiRepo, - private val symptomsProcessor: ApiSymptomsMapper -) : CenReportRepo { - override val reports: Observable> = cenReportDao.reports.map { reports -> - reports.map { - symptomsProcessor.fromCenReport(it.report) - } - } - - override val sendState: Observable = coEpiRepo.sendReportState - - override fun sendReport(report: SymptomReport) { - coEpiRepo.sendReport(report) - } - - override fun delete(report: SymptomReport) { - cenReportDao.delete(report) - } -} diff --git a/app/src/main/java/org/coepi/android/cen/MyCenProvider.kt b/app/src/main/java/org/coepi/android/cen/MyCenProvider.kt deleted file mode 100644 index 9abe1458..00000000 --- a/app/src/main/java/org/coepi/android/cen/MyCenProvider.kt +++ /dev/null @@ -1,41 +0,0 @@ -package org.coepi.android.cen - -import org.coepi.android.domain.CenLogic -import org.coepi.android.domain.UnixTime -import org.coepi.android.domain.UnixTime.Companion.now -import org.coepi.android.system.log.LogTag.CEN_L -import org.coepi.android.system.log.log -import org.coepi.android.ui.debug.DebugBleObservable - -interface MyCenProvider { - fun generateCen(): Cen -} - -class MyCenProviderImpl( - private val cenLogic: CenLogic, - private val cenKeyDao: CenKeyDao, - private val debugObservable: DebugBleObservable -) : MyCenProvider { - - override fun generateCen(): Cen { - val key: CenKey = retrieveOrGenerateCenKey() - debugObservable.setMyKey(key) - val cen: Cen = cenLogic.generateCen(key, now().value) - debugObservable.setMyCen(cen) - return cen - } - - private fun retrieveOrGenerateCenKey(): CenKey { - val now: UnixTime = now() - return cenKeyDao.lastCENKeys(1).firstOrNull() - ?.takeIf { !cenLogic.shouldGenerateNewCenKey(now, it.timestamp) } - ?: generateAndInsertCenKey(now) - } - - private fun generateAndInsertCenKey(timestamp: UnixTime): CenKey { - val key = cenLogic.generateCenKey(timestamp) - log.i("Generated a new CEN key: $key", CEN_L) - cenKeyDao.insert(key) - return key - } -} diff --git a/app/src/main/java/org/coepi/android/cen/RealmCenDao.kt b/app/src/main/java/org/coepi/android/cen/RealmCenDao.kt deleted file mode 100644 index 789984da..00000000 --- a/app/src/main/java/org/coepi/android/cen/RealmCenDao.kt +++ /dev/null @@ -1,48 +0,0 @@ -package org.coepi.android.cen - -import io.realm.kotlin.createObject -import io.realm.kotlin.oneOf -import io.realm.kotlin.where -import org.coepi.android.domain.UnixTime -import org.coepi.android.extensions.hexToByteArray -import org.coepi.android.repo.RealmProvider - -class RealmCenDao(private val realmProvider: RealmProvider) : CenDao { - private val realm get() = realmProvider.realm - - override fun all(): List = - realm.where() - .findAll() - .map { it.toReceivedCen() } - - override fun matchCENs(start: UnixTime, end: UnixTime, cens: Array): List = - realm.where() - .greaterThanOrEqualTo("timestamp", start.value) - .and() - .lessThanOrEqualTo("timestamp", end.value) - .and() - .oneOf("cen", cens) - .findAll() - .map { it.toReceivedCen() } - - override fun findCen(cen: Cen): ReceivedCen? = - realm.where() - .equalTo("cen", cen.toHex()) - .findAll() - .firstOrNull() - ?.toReceivedCen() - - override fun insert(cen: ReceivedCen): Boolean { - if (findCen(cen.cen) != null) { - return false - } - realm.executeTransaction { - val realmObj = realm.createObject(cen.cen.toHex()) // Create a new object - realmObj.timestamp = cen.timestamp.value - } - return true - } - - private fun RealmReceivedCen.toReceivedCen() = - ReceivedCen(Cen(cen.hexToByteArray()), UnixTime.fromValue(timestamp)) -} diff --git a/app/src/main/java/org/coepi/android/cen/RealmCenKeys.kt b/app/src/main/java/org/coepi/android/cen/RealmCenKeys.kt deleted file mode 100644 index cf622a62..00000000 --- a/app/src/main/java/org/coepi/android/cen/RealmCenKeys.kt +++ /dev/null @@ -1,9 +0,0 @@ -package org.coepi.android.cen - -import io.realm.RealmList -import io.realm.RealmObject - -// ExposureCheckResponse contains a set of CENkeys (base64 encoded), which is used to match against CENs observed by client -open class RealmCenKeys( - var keys : RealmList = RealmList() -): RealmObject() diff --git a/app/src/main/java/org/coepi/android/cen/ReceivedCen.kt b/app/src/main/java/org/coepi/android/cen/ReceivedCen.kt deleted file mode 100644 index 0844fb6d..00000000 --- a/app/src/main/java/org/coepi/android/cen/ReceivedCen.kt +++ /dev/null @@ -1,5 +0,0 @@ -package org.coepi.android.cen - -import org.coepi.android.domain.UnixTime - -data class ReceivedCen(val cen: Cen, val timestamp: UnixTime) diff --git a/app/src/main/java/org/coepi/android/common/ApiSymptomsMapper.kt b/app/src/main/java/org/coepi/android/common/ApiSymptomsMapper.kt index e851e344..f2a4e094 100644 --- a/app/src/main/java/org/coepi/android/common/ApiSymptomsMapper.kt +++ b/app/src/main/java/org/coepi/android/common/ApiSymptomsMapper.kt @@ -1,42 +1,44 @@ package org.coepi.android.common -import org.coepi.android.R.string.alerts_no_symptoms_reported -import org.coepi.android.api.request.ApiParamsCenReport -import org.coepi.android.cen.CenKey -import org.coepi.android.cen.CenReport -import org.coepi.android.cen.SymptomReport +import android.content.Context +import org.coepi.android.tcn.TcnReport +import org.coepi.android.tcn.SymptomReport import org.coepi.android.domain.UnixTime import org.coepi.android.domain.model.Symptom import org.coepi.android.domain.symptomflow.SymptomId.OTHER import org.coepi.android.extensions.base64ToUtf8 -import org.coepi.android.extensions.toBase64 -import org.coepi.android.extensions.toHex +import org.coepi.android.extensions.toBase64String import org.coepi.android.system.Resources import org.coepi.android.system.log.log +import org.tcncoalition.tcnclient.TcnKeys +import java.util.UUID.randomUUID +import org.coepi.android.R.string.alerts_no_symptoms_reported +import org.coepi.android.extensions.toBase64 +import org.tcncoalition.tcnclient.crypto.MemoType +import java.nio.charset.StandardCharsets.UTF_8 interface ApiSymptomsMapper { - fun toApiReport(report: SymptomReport, keys: List): ApiParamsCenReport - fun fromCenReport(report: CenReport): SymptomReport + fun toApiReport(report: SymptomReport): String + fun fromTcnReport(report: TcnReport): SymptomReport } -class ApiSymptomsMapperImpl(private val resources: Resources) : ApiSymptomsMapper { +class ApiSymptomsMapperImpl(context: Context, private val resources: Resources) : ApiSymptomsMapper { + private val tcnKeys: TcnKeys = TcnKeys(context) - override fun toApiReport(report: SymptomReport, keys: List): ApiParamsCenReport = - ApiParamsCenReport( - reportID = report.id.toByteArray().toHex(), - report = report.symptoms.toApiSymptomString(), - cenKeys = keys.joinToString(",") { it.key }, // hex - reportTimeStamp = report.timestamp.value - ) + override fun toApiReport(report: SymptomReport): String = + tcnKeys.createReport( + report.toMemoData(), + MemoType.CoEpiV1 + ).toByteArray().toBase64String() - override fun fromCenReport(report: CenReport): SymptomReport = SymptomReport( + override fun fromTcnReport(report: TcnReport): SymptomReport = SymptomReport( id = report.id, symptoms = fromApiSymptomString(report.report), timestamp = UnixTime.fromValue(report.timestamp) ) - private fun List.toApiSymptomString(): String = - joinToString(", ") { it.name }.toBase64() + private fun SymptomReport.toMemoData(): ByteArray = + symptoms.joinToString(", ") { it.name }.toBase64().toByteArray(UTF_8) // TODO private fun fromApiSymptomString(string: String): List = if (string.isEmpty()) { diff --git a/app/src/main/java/org/coepi/android/common/Result.kt b/app/src/main/java/org/coepi/android/common/Result.kt index 42594efa..70664216 100644 --- a/app/src/main/java/org/coepi/android/common/Result.kt +++ b/app/src/main/java/org/coepi/android/common/Result.kt @@ -51,5 +51,11 @@ fun Result.failureOrNull(): E? = is Failure -> error } +fun Result.isSuccess(): Boolean = + this is Success + +fun Result.isFailure(): Boolean = + this is Failure + fun List>.group(): Pair, List> = Pair(mapNotNull { it.successOrNull() }, mapNotNull { it.failureOrNull() } ) diff --git a/app/src/main/java/org/coepi/android/cross/ScannedCensHandler.kt b/app/src/main/java/org/coepi/android/cross/ScannedTcnsHandler.kt similarity index 51% rename from app/src/main/java/org/coepi/android/cross/ScannedCensHandler.kt rename to app/src/main/java/org/coepi/android/cross/ScannedTcnsHandler.kt index 4dee803d..12984eb0 100644 --- a/app/src/main/java/org/coepi/android/cross/ScannedCensHandler.kt +++ b/app/src/main/java/org/coepi/android/cross/ScannedTcnsHandler.kt @@ -4,25 +4,27 @@ import io.reactivex.disposables.CompositeDisposable import io.reactivex.rxkotlin.plusAssign import io.reactivex.rxkotlin.subscribeBy import org.coepi.android.ble.BleManager -import org.coepi.android.cen.ReceivedCen +import org.coepi.android.tcn.TcnDao +import org.coepi.android.tcn.ReceivedTcn import org.coepi.android.domain.UnixTime.Companion.now -import org.coepi.android.repo.CoEpiRepo import org.coepi.android.system.log.log import org.coepi.android.ui.debug.DebugBleObservable -class ScannedCensHandler( +class ScannedTcnsHandler( bleManager: BleManager, - private val coEpiRepo: CoEpiRepo, - private val debugBleObservable: DebugBleObservable + private val debugBleObservable: DebugBleObservable, + private val tcnDao: TcnDao ) { private val disposables = CompositeDisposable() init { - disposables += bleManager.observedCens - .subscribeBy(onNext = { cen -> - log.d("Storing an observed CEN: $cen") - coEpiRepo.storeObservedCen(ReceivedCen(cen, now())) - debugBleObservable.setObservedCen(cen) + disposables += bleManager.observedTcns + .subscribeBy(onNext = { tcn -> + log.d("Observed TCN: $tcn") + if (tcnDao.insert(ReceivedTcn(tcn, now()))) { + log.v("Inserted observed TCN: $tcn") + } + debugBleObservable.setObservedTcn(tcn) }, onError = { log.e("Error scanning: $it") }) diff --git a/app/src/main/java/org/coepi/android/di/Modules.kt b/app/src/main/java/org/coepi/android/di/Modules.kt index 8a4bc705..829ed4fe 100644 --- a/app/src/main/java/org/coepi/android/di/Modules.kt +++ b/app/src/main/java/org/coepi/android/di/Modules.kt @@ -3,21 +3,25 @@ package org.coepi.android.di import android.app.Application import android.content.Context.MODE_PRIVATE import android.content.SharedPreferences -import org.coepi.android.NonReferencedDependenciesActivator +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import org.coepi.android.NotReferencedDependenciesActivator import org.coepi.android.ble.BleEnabler import org.coepi.android.ble.BleManager import org.coepi.android.ble.BleManagerImpl import org.coepi.android.ble.BlePreconditions import org.coepi.android.ble.BlePreconditionsNotifier import org.coepi.android.ble.BlePreconditionsNotifierImpl -import org.coepi.android.cen.CENModule -import org.coepi.android.cen.apiModule +import org.coepi.android.ble.BleSimulator +import org.coepi.android.tcn.TcnModule +import org.coepi.android.tcn.apiModule import org.coepi.android.repo.repoModule import org.coepi.android.system.Clipboard import org.coepi.android.system.ClipboardImpl import org.coepi.android.system.EnvInfos import org.coepi.android.system.EnvInfosImpl import org.coepi.android.system.Preferences +import org.coepi.android.system.PreferencesImpl import org.coepi.android.system.Resources import org.coepi.android.system.intent.InfectionsNotificationIntentHandler import org.coepi.android.system.intent.IntentForwarder @@ -49,7 +53,7 @@ import org.coepi.android.ui.symptoms.cough.CoughDurationViewModel import org.coepi.android.ui.symptoms.cough.CoughStatusViewModel import org.coepi.android.ui.symptoms.cough.CoughTypeViewModel import org.coepi.android.ui.thanks.ThanksViewModel -import org.coepi.android.worker.cenfetcher.ContactsFetchManager +import org.coepi.android.worker.tcnfetcher.ContactsFetchManager import org.koin.android.ext.koin.androidApplication import org.koin.androidx.viewmodel.dsl.viewModel import org.koin.dsl.module @@ -74,15 +78,15 @@ val viewModelModule = module { val systemModule = module { single { getSharedPrefs(androidApplication()) } - single { Preferences(get()) } + single { PreferencesImpl(get(), get()) } single { OnboardingPermissionsChecker() } single { BlePreconditionsNotifierImpl() } single { BlePreconditions(get(), get(), get()) } single { BleEnabler() } single { Resources(androidApplication()) } single { BleManagerImpl(androidApplication(), get()) } -// single { BleSimulator(get()) } // Disable BleManagerImpl and enable this to use BLE simulator - single { NonReferencedDependenciesActivator(get(), get(), get(), get()) } +// single { BleSimulator() } // Disable BleManagerImpl and enable this to use BLE simulator + single { NotReferencedDependenciesActivator(get(), get(), get(), get()) } single { ContactsFetchManager(get()) } single { DebugBleObservableImpl() } single { ClipboardImpl(get()) } @@ -90,6 +94,7 @@ val systemModule = module { single { IntentForwarderImpl() } single { InfectionsNotificationIntentHandler(get(), get()) } single { UINotifierImpl() } + single { provideGson() } } val uiModule = module { @@ -105,9 +110,14 @@ val appModule = listOf( viewModelModule, systemModule, apiModule, - CENModule, + TcnModule, uiModule ) fun getSharedPrefs(androidApplication: Application): SharedPreferences = androidApplication.getSharedPreferences("default", MODE_PRIVATE) + +private fun provideGson(): Gson = GsonBuilder() + .serializeNulls() + .setLenient() + .create() diff --git a/app/src/main/java/org/coepi/android/domain/CenLogic.kt b/app/src/main/java/org/coepi/android/domain/CenLogic.kt deleted file mode 100644 index 406dec38..00000000 --- a/app/src/main/java/org/coepi/android/domain/CenLogic.kt +++ /dev/null @@ -1,52 +0,0 @@ -package org.coepi.android.domain - -import org.coepi.android.cen.Cen -import org.coepi.android.cen.CenKey -import org.coepi.android.cen.longToByteArray -import org.coepi.android.extensions.hexToByteArray -import org.coepi.android.extensions.toHex -import javax.crypto.Cipher -import javax.crypto.KeyGenerator -import javax.crypto.SecretKey -import javax.crypto.spec.SecretKeySpec - -interface CenLogic { - fun shouldGenerateNewCenKey(curTimestamp: UnixTime, cenTimestamp: UnixTime): Boolean - fun generateCenKey(timestamp: UnixTime): CenKey - - /** - * @param ts Unix time - */ - fun generateCen(cenKey: CenKey, ts: Long): Cen -} - -class CenLogicImpl: CenLogic { - companion object { - const val cenKeyLifetimeInSeconds: Long = 7 * 86400 // every 7 days a new key is generated - const val cenLifetimeInSeconds: Long = 15 * 60 // every 15 mins a new CEN is generated - } - - override fun shouldGenerateNewCenKey(curTimestamp: UnixTime, cenTimestamp: UnixTime): Boolean = - (cenTimestamp.value == 0L) || (roundedTimestamp(curTimestamp.value, cenKeyLifetimeInSeconds) > - roundedTimestamp(cenTimestamp.value, cenKeyLifetimeInSeconds)) - - override fun generateCenKey(timestamp: UnixTime): CenKey { - // generate a new AES Key and store it in local storage - val secretKey = KeyGenerator.getInstance("AES") - .apply { init(256) } // 32 bytes - .generateKey() - return CenKey(secretKey.encoded.toHex(), timestamp) - } - - override fun generateCen(cenKey: CenKey, ts: Long): Cen { - val decodedCENKey = cenKey.key.hexToByteArray() - // rebuild secretKey using SecretKeySpec - val secretKey: SecretKey = SecretKeySpec(decodedCENKey, 0, decodedCENKey.size, "AES") - val cipher: Cipher = Cipher.getInstance("AES/ECB/PKCS5Padding") - cipher.init(Cipher.ENCRYPT_MODE, secretKey) - return Cen(cipher.doFinal(longToByteArray(roundedTimestamp(ts, cenLifetimeInSeconds)))) - } - - private fun roundedTimestamp(ts: Long, interval: Long): Long = - (ts / interval) * interval -} diff --git a/app/src/main/java/org/coepi/android/domain/CenMatcher.kt b/app/src/main/java/org/coepi/android/domain/CenMatcher.kt deleted file mode 100644 index 5a27d5bb..00000000 --- a/app/src/main/java/org/coepi/android/domain/CenMatcher.kt +++ /dev/null @@ -1,57 +0,0 @@ -package org.coepi.android.domain - -import kotlinx.coroutines.Dispatchers.Default -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.runBlocking -import org.coepi.android.cen.Cen -import org.coepi.android.cen.CenKey -import org.coepi.android.domain.CenLogicImpl.Companion.cenLifetimeInSeconds -import org.coepi.android.extensions.toHex - -interface CenMatcher { - fun match(cens: List, keys: List, maxDate: UnixTime): List -} - -class CenMatcherImpl( - private val cenLogic: CenLogic -) : CenMatcher { - - override fun match(cens: List, keys: List, maxDate: UnixTime): List = - if (cens.isEmpty()) { - emptyList() - } else { - runBlocking { - matchSuspended(cens, keys, maxDate) - } - } - - private suspend fun matchSuspended(cens: List, keys: List, - maxDate: UnixTime): List = - coroutineScope { - val censSet: Set = cens.map { it.toHex() }.toHashSet() - keys.distinct().map { key -> - async(Default) { - if (match(censSet, key, maxDate)) { - key - } else { - null - } - } - }.awaitAll().filterNotNull() - } - - private fun match(censSet: Set, key: CenKey, maxDate: UnixTime): Boolean { - val secondsInAWeek: Long = 604800 - val possibleCensCount = (secondsInAWeek / cenLifetimeInSeconds).toInt() - for (i in 0..possibleCensCount) { - val ts = maxDate.value - cenLifetimeInSeconds * i - val cen = cenLogic.generateCen(key, ts) - if (censSet.contains(cen.bytes.toHex())) { - return true - } - } - return false - } -} diff --git a/app/src/main/java/org/coepi/android/domain/TcnGenerator.kt b/app/src/main/java/org/coepi/android/domain/TcnGenerator.kt new file mode 100644 index 00000000..452e2e15 --- /dev/null +++ b/app/src/main/java/org/coepi/android/domain/TcnGenerator.kt @@ -0,0 +1,16 @@ +package org.coepi.android.domain + +import android.content.Context +import org.coepi.android.tcn.Tcn +import org.tcncoalition.tcnclient.TcnKeys + +interface TcnGenerator { + fun generateTcn(): Tcn +} + +class TcnGeneratorImpl(context: Context) : TcnGenerator { + private val tcnKeys: TcnKeys = TcnKeys(context) + + override fun generateTcn(): Tcn = + Tcn(tcnKeys.generateTcn()) +} diff --git a/app/src/main/java/org/coepi/android/domain/TcnMatcher.kt b/app/src/main/java/org/coepi/android/domain/TcnMatcher.kt new file mode 100644 index 00000000..018b5600 --- /dev/null +++ b/app/src/main/java/org/coepi/android/domain/TcnMatcher.kt @@ -0,0 +1,46 @@ +package org.coepi.android.domain + +import kotlinx.coroutines.Dispatchers.Default +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.runBlocking +import org.coepi.android.extensions.toHex +import org.coepi.android.tcn.Tcn +import org.tcncoalition.tcnclient.crypto.SignedReport + +interface TcnMatcher { + fun match(tcns: List, reports: List): List +} + +class TcnMatcherImpl : TcnMatcher { + + override fun match(tcns: List, reports: List): List = + if (tcns.isEmpty()) { + emptyList() + } else { + runBlocking { + matchSuspended(tcns, reports) + } + } + + private suspend fun matchSuspended(tcns: List, reports: List) + : List = + coroutineScope { + val tcnsSet: Set = tcns.map { it.toHex() }.toHashSet() + reports.distinct().map { report -> + async(Default) { + match(tcnsSet, report) + } + }.awaitAll().filterNotNull() + } + + fun match(tcnsSet: Set, signedReport: SignedReport): SignedReport? { + val tcns = signedReport.report.temporaryContactNumbers + return if (tcns.asSequence().any { tcnsSet.contains(it.bytes.toHex()) }) { + signedReport + } else { + null + } + } +} diff --git a/app/src/main/java/org/coepi/android/domain/model/Contact.kt b/app/src/main/java/org/coepi/android/domain/model/Contact.kt deleted file mode 100644 index 4383fe95..00000000 --- a/app/src/main/java/org/coepi/android/domain/model/Contact.kt +++ /dev/null @@ -1,5 +0,0 @@ -package org.coepi.android.domain.model - -import java.util.Date - -data class Contact(val cen: String, val date: Date) diff --git a/app/src/main/java/org/coepi/android/extensions/StringExtensions.kt b/app/src/main/java/org/coepi/android/extensions/StringExtensions.kt index 9146b81d..0b98d585 100644 --- a/app/src/main/java/org/coepi/android/extensions/StringExtensions.kt +++ b/app/src/main/java/org/coepi/android/extensions/StringExtensions.kt @@ -1,12 +1,8 @@ package org.coepi.android.extensions import android.os.Build.VERSION.SDK_INT -import android.os.Build.VERSION_CODES.O -import android.util.Base64.DEFAULT -import android.util.Base64.NO_WRAP import org.coepi.android.system.log.log -import java.nio.charset.Charset -import java.util.Base64 +import org.coepi.android.util.Base64Ext import kotlin.text.Charsets.UTF_8 fun String.hexToByteArray(): ByteArray { @@ -23,23 +19,18 @@ fun String.hexToByteArray(): ByteArray { return res } +fun ByteArray.toBase64String(): String = + Base64Ext.encodeBytes(this) + fun String.toBase64(): String = - if (SDK_INT >= O) { - Base64.getEncoder().encodeToString(toByteArray(UTF_8)) - } else { - android.util.Base64.encodeToString(toByteArray(UTF_8), NO_WRAP) - } + toByteArray(UTF_8).toBase64String() fun String.base64ToUtf8(): String? = - base64ToByteArray()?.toString(Charset.forName("utf-8")) + base64ToByteArray()?.toString(UTF_8) -private fun String.base64ToByteArray(): ByteArray? = +fun String.base64ToByteArray(): ByteArray? = try { - if (SDK_INT >= O) { - Base64.getDecoder().decode(this) - } else { - android.util.Base64.decode(this, DEFAULT) - } + Base64Ext.decode(this) } catch (t: Throwable) { log.e("Couldn't decode string with Base64: $this, api: $SDK_INT") null diff --git a/app/src/main/java/org/coepi/android/repo/AlertsRepo.kt b/app/src/main/java/org/coepi/android/repo/AlertsRepo.kt index 49515779..45fd13d1 100644 --- a/app/src/main/java/org/coepi/android/repo/AlertsRepo.kt +++ b/app/src/main/java/org/coepi/android/repo/AlertsRepo.kt @@ -1,8 +1,9 @@ package org.coepi.android.repo import io.reactivex.Observable -import org.coepi.android.cen.CenReportRepo -import org.coepi.android.cen.SymptomReport +import org.coepi.android.tcn.TcnReportRepo +import org.coepi.android.tcn.SymptomReport +import org.coepi.android.repo.reportsupdate.ReportsUpdater import org.coepi.android.system.rx.OperationState import org.coepi.android.system.rx.VoidOperationState @@ -15,30 +16,20 @@ interface AlertsRepo { } class AlertRepoImpl( - private val cenReportRepo: CenReportRepo, - private val coEpiRepo: CoEpiRepo + private val tcnReportRepo: TcnReportRepo, + private val reportsUpdater: ReportsUpdater ): AlertsRepo { - override val alerts: Observable> = cenReportRepo.reports + override val alerts: Observable> = tcnReportRepo.reports - override val updateReportsState: Observable> = coEpiRepo.updateReportsState - - // Dummy test data -// override val alerts: Observable> = just(listOf( -// CenReport("1", "Report text1", 0), -// CenReport("2", "Report text2", 0), -// CenReport("3", "Report text3", 0), -// CenReport("4", "Report text4", 0), -// CenReport("5", "Report text5", 0), -// CenReport("6", "Report text6", 0), -// CenReport("7", "Report text7", 0) -// ).map { ReceivedCenReport(it) }) + override val updateReportsState: Observable> = reportsUpdater + .updateState override fun removeAlert(alert: SymptomReport) { - cenReportRepo.delete(alert) + tcnReportRepo.delete(alert) } override fun updateReports() { - coEpiRepo.requestUpdateReports() + reportsUpdater.requestUpdateReports() } } diff --git a/app/src/main/java/org/coepi/android/repo/CoEpiRepo.kt b/app/src/main/java/org/coepi/android/repo/CoEpiRepo.kt deleted file mode 100644 index f25c7375..00000000 --- a/app/src/main/java/org/coepi/android/repo/CoEpiRepo.kt +++ /dev/null @@ -1,256 +0,0 @@ -package org.coepi.android.repo - -import io.reactivex.Completable -import io.reactivex.Completable.complete -import io.reactivex.Observable -import io.reactivex.disposables.CompositeDisposable -import io.reactivex.rxkotlin.plusAssign -import io.reactivex.schedulers.Schedulers.io -import io.reactivex.subjects.BehaviorSubject -import io.reactivex.subjects.BehaviorSubject.createDefault -import io.reactivex.subjects.PublishSubject -import io.reactivex.subjects.PublishSubject.create -import org.coepi.android.R.drawable -import org.coepi.android.R.plurals -import org.coepi.android.R.string -import org.coepi.android.api.CENApi -import org.coepi.android.api.request.ApiParamsCenReport -import org.coepi.android.api.toCenReport -import org.coepi.android.cen.Cen -import org.coepi.android.cen.CenKey -import org.coepi.android.cen.CenDao -import org.coepi.android.cen.CenKeyDao -import org.coepi.android.cen.CenReportDao -import org.coepi.android.cen.ReceivedCen -import org.coepi.android.cen.ReceivedCenReport -import org.coepi.android.cen.SymptomReport -import org.coepi.android.common.ApiSymptomsMapper -import org.coepi.android.common.Result -import org.coepi.android.common.Result.Failure -import org.coepi.android.common.Result.Success -import org.coepi.android.common.doIfSuccess -import org.coepi.android.common.flatMap -import org.coepi.android.common.group -import org.coepi.android.common.map -import org.coepi.android.common.successOrNull -import org.coepi.android.domain.CenMatcher -import org.coepi.android.domain.UnixTime -import org.coepi.android.domain.UnixTime.Companion.now -import org.coepi.android.extensions.retrofit.executeSafe -import org.coepi.android.extensions.rx.toObservable -import org.coepi.android.extensions.toResult -import org.coepi.android.system.Resources -import org.coepi.android.system.intent.IntentKey.NOTIFICATION_INFECTION_ARGS -import org.coepi.android.system.intent.IntentNoValue -import org.coepi.android.system.log.LogTag.CEN_MATCHING -import org.coepi.android.system.log.LogTag.NET -import org.coepi.android.system.log.log -import org.coepi.android.system.rx.OperationState -import org.coepi.android.system.rx.OperationState.NotStarted -import org.coepi.android.system.rx.OperationState.Progress -import org.coepi.android.system.rx.OperationStateNotifier -import org.coepi.android.system.rx.VoidOperationState -import org.coepi.android.ui.notifications.AppNotificationChannels -import org.coepi.android.ui.notifications.NotificationConfig -import org.coepi.android.ui.notifications.NotificationIntentArgs -import org.coepi.android.ui.notifications.NotificationPriority.HIGH -import org.coepi.android.ui.notifications.NotificationsShower -import java.lang.System.currentTimeMillis -import io.reactivex.rxkotlin.withLatestFrom - -// TODO remove CoEpiRepo. Create update reports use case and send TCNs directly to DAO -// TODO if Rust library or similar is added later, we can re-add this and inject new use case in it -interface CoEpiRepo { - - // State of send report operation - val sendReportState: Observable - - // Store CEN from other device - fun storeObservedCen(cen: ReceivedCen) - - // Send symptoms report - fun sendReport(report: SymptomReport) - - fun reports(): Result, Throwable> - - // Trigger manually report update - fun requestUpdateReports() - - val updateReportsState: Observable -} - -class CoepiRepoImpl( - private val cenMatcher: CenMatcher, - private val api: CENApi, - private val cenDao: CenDao, - private val cenKeyDao: CenKeyDao, - private val symptomsProcessor: ApiSymptomsMapper, - private val reportsDao: CenReportDao, - private val resources: Resources, - private val notificationChannelsInitializer: AppNotificationChannels, - private val notificationsShower: NotificationsShower -) : CoEpiRepo { - - private var matchingStartTime: Long? = null - - private val disposables = CompositeDisposable() - - private val postSymptomsTrigger: PublishSubject = create() - override val sendReportState: PublishSubject = create() - - override val updateReportsState: BehaviorSubject = createDefault(NotStarted) - - private val reportsUpdateTrigger: PublishSubject = create() - - init { - disposables += postSymptomsTrigger.doOnNext { - sendReportState.onNext(Progress) - } - .flatMap { report -> postReport(report).toObservable(Unit).materialize() } - .subscribe(OperationStateNotifier(sendReportState)) - - disposables += reportsUpdateTrigger - .observeOn(io()) - .withLatestFrom(updateReportsState) - .filter { (_, state) -> state !is Progress } - .subscribe { - updateReports() - } - } - - override fun requestUpdateReports() { - reportsUpdateTrigger.onNext(Unit) - } - - override fun sendReport(report: SymptomReport) { - postSymptomsTrigger.onNext(report) - } - - private fun updateReports() { - val reportsResult = reports() - val reports: List = reportsResult.successOrNull() ?: emptyList() - - val insertedCount = reports.map { - reportsDao.insert(it.report) - }.filter { it }.size - - if (insertedCount >= 0) { - log.d("Added $insertedCount new reports") - } - - if (insertedCount > 0) { - log.d("Showing notification...") - notificationsShower.showNotification(notificationConfiguration(insertedCount)) - } - } - - private fun notificationConfiguration(count: Int): NotificationConfig = NotificationConfig( - drawable.ic_launcher_foreground, - resources.getString(string.infection_notification_title), - resources.getQuantityString(plurals.alerts_new_notifications_count, count), - HIGH, - notificationChannelsInitializer.reportsChannelId, - NotificationIntentArgs(NOTIFICATION_INFECTION_ARGS, IntentNoValue()) - ) - - override fun reports(): Result, Throwable> { - - updateReportsState.onNext(Progress) - - val keysResult = api.cenkeysCheck().executeSafe() - .flatMap { it.toResult() } - .map { keyStrings -> - keyStrings.map { - CenKey(it, now()) - } - } - - keysResult.doIfSuccess { keys -> - log.i("Retrieved ${keys.size} keys. Start matching...", CEN_MATCHING) -// val keyStrings = keys.map { it.key } -// log.v("$keyStrings", CEN_MATCHING) - } - - matchingStartTime = currentTimeMillis() - - val matchedKeysResult: Result, Throwable> = - keysResult.map { filterMatchingKeys(it) } - - matchingStartTime?.let { - val time = (currentTimeMillis() - it) / 1000 - log.i("Took ${time}s to match keys", CEN_MATCHING) - } - matchingStartTime = null - - matchedKeysResult.doIfSuccess { - if (it.isNotEmpty()) { - log.i("Matches found (${it.size}): $it", CEN_MATCHING) - } else { - log.i("No matches found", CEN_MATCHING) - } - } - - return matchedKeysResult.flatMap { reportsFor(it) }.also { result -> - updateReportsState.onNext(when (result) { - is Success -> OperationState.Success(Unit) - is Failure -> OperationState.Failure(result.error).also { - log.e("Error updating reports: ${result.error}") - } - }) - updateReportsState.onNext(NotStarted) - } - } - - private fun reportsFor(keys: List): Result, Throwable> { - // Retrieve reports for keys, group in successful / failed calls - val (successful, failed) = keys.map { key -> - api.getCenReports(key.key).executeSafe() - .flatMap { it.toResult() } - .doIfSuccess { reports -> - log.d("Retrieved ${reports.size} reports for a key") - } - }.group() - - // Log failed calls - failed.forEach { - log.e("Error fetching reports: $it") - } - - // If we only got failure results, return a failure, otherwise return success - // and ignore failures (logged before) - // TODO review / maybe refine this error handling - return if (successful.isEmpty() && failed.isNotEmpty()) { - Failure(Throwable("Couldn't fetch any reports")) - } else { - Success(successful.flatten().map { ReceivedCenReport(it.toCenReport()) }) - } - } - - private fun filterMatchingKeys(keys: List): List { - val maxDate: UnixTime = now() - // TODO delete periodically entries older than ~3 weeks from the db - val cens: List = cenDao.all().map { it.cen } - log.i("DB CENs count: ${cens.size}") - return cenMatcher.match(cens, keys.distinct(), maxDate) - } - - private fun postReport(report: SymptomReport): Completable { - val params: ApiParamsCenReport? = - cenKeyDao.lastCENKeys(3).takeIf { it.isNotEmpty() }?.let { keys -> - symptomsProcessor.toApiReport(report, keys) - } - return if (params != null) { - log.i("Sending CEN report to API: $params", NET) - api.postCENReport(params).subscribeOn(io()) - } else { - log.e("Can't send report. No CEN keys.", NET) - complete() - } - } - - override fun storeObservedCen(cen: ReceivedCen) { - if (cenDao.insert(cen)) { - log.v("Inserted an observed CEN: $cen") - } - } -} diff --git a/app/src/main/java/org/coepi/android/repo/RepoModule.kt b/app/src/main/java/org/coepi/android/repo/RepoModule.kt index d7413a68..1177543a 100644 --- a/app/src/main/java/org/coepi/android/repo/RepoModule.kt +++ b/app/src/main/java/org/coepi/android/repo/RepoModule.kt @@ -5,14 +5,11 @@ import org.coepi.android.domain.symptomflow.SymptomInputsManager import org.coepi.android.domain.symptomflow.SymptomInputsManagerImpl import org.coepi.android.domain.symptomflow.SymptomRouter import org.coepi.android.domain.symptomflow.SymptomRouterImpl -import org.coepi.android.repo.realm.ContactRepo -import org.coepi.android.repo.realm.RealmContactRepo import org.koin.android.ext.koin.androidApplication import org.koin.dsl.module val repoModule = module { single { RealmProvider(androidApplication()) } - single { RealmContactRepo(get()) } single { SymptomRepoImpl(get()) } single { AlertRepoImpl(get(), get()) } single { SymptomInputsManagerImpl() } diff --git a/app/src/main/java/org/coepi/android/repo/SymptomRepo.kt b/app/src/main/java/org/coepi/android/repo/SymptomRepo.kt index df3632a0..8e8d4f18 100644 --- a/app/src/main/java/org/coepi/android/repo/SymptomRepo.kt +++ b/app/src/main/java/org/coepi/android/repo/SymptomRepo.kt @@ -3,9 +3,9 @@ package org.coepi.android.repo import io.reactivex.Observable import io.reactivex.Single import io.reactivex.Single.just -import org.coepi.android.cen.SymptomReport +import org.coepi.android.tcn.TcnReportRepo +import org.coepi.android.tcn.SymptomReport import org.coepi.android.domain.UnixTime.Companion.now -import org.coepi.android.system.rx.VoidOperationState import org.coepi.android.domain.model.Symptom import org.coepi.android.domain.symptomflow.SymptomId.BREATHLESSNESS import org.coepi.android.domain.symptomflow.SymptomId.COUGH @@ -16,6 +16,7 @@ import org.coepi.android.domain.symptomflow.SymptomId.MUSCLE_ACHES import org.coepi.android.domain.symptomflow.SymptomId.NONE import org.coepi.android.domain.symptomflow.SymptomId.OTHER import org.coepi.android.domain.symptomflow.SymptomId.RUNNY_NOSE +import org.coepi.android.system.rx.VoidOperationState import java.util.UUID.randomUUID interface SymptomRepo { @@ -26,10 +27,10 @@ interface SymptomRepo { } class SymptomRepoImpl( - private val coEpiRepo: CoEpiRepo + private val reportRepo: TcnReportRepo ): SymptomRepo { - override val sendReportState: Observable = coEpiRepo.sendReportState.share() + override val sendReportState: Observable = reportRepo.sendState.share() override fun symptoms(): Single> = just(listOf( Symptom(NONE, "I don\'t have any symptoms today"), @@ -44,7 +45,7 @@ class SymptomRepoImpl( )) override fun submitSymptoms(symptoms: List) { - coEpiRepo.sendReport(symptoms.toReport()) + reportRepo.send(symptoms.toReport()) } private fun List.toReport(): SymptomReport = SymptomReport( diff --git a/app/src/main/java/org/coepi/android/repo/model/RealmContact.kt b/app/src/main/java/org/coepi/android/repo/model/RealmContact.kt index a882de41..d346b1f9 100644 --- a/app/src/main/java/org/coepi/android/repo/model/RealmContact.kt +++ b/app/src/main/java/org/coepi/android/repo/model/RealmContact.kt @@ -4,6 +4,6 @@ import io.realm.RealmObject import java.util.Date open class RealmContact( - var cen: String = "", + var tcn: String = "", var date: Date = Date() ): RealmObject() diff --git a/app/src/main/java/org/coepi/android/repo/realm/RealmContactRepo.kt b/app/src/main/java/org/coepi/android/repo/realm/RealmContactRepo.kt deleted file mode 100644 index 5669a1a8..00000000 --- a/app/src/main/java/org/coepi/android/repo/realm/RealmContactRepo.kt +++ /dev/null @@ -1,33 +0,0 @@ -package org.coepi.android.repo.realm - -import io.realm.kotlin.createObject -import io.realm.kotlin.where -import org.coepi.android.domain.model.Contact -import org.coepi.android.repo.RealmProvider -import org.coepi.android.repo.model.RealmContact - -interface ContactRepo { - fun addContact(contact: Contact) - fun retrieveContacts(): List -} - -class RealmContactRepo(private val realmProvider: RealmProvider): ContactRepo { - private val realm get() = realmProvider.realm - - override fun addContact(contact: Contact) { - realm.executeTransaction { realm -> - realm.createObject().apply { - cen = contact.cen - date = contact.date - } - } - } - - // Most simple version - // Ideally executed as part of flow in a background thread (e.g. fetch - grouping - send to api) - // If critical for performance, the mapping to plain objects can be left out - override fun retrieveContacts(): List = - realm.where().findAll().map { - Contact(it.cen, it.date) - } -} diff --git a/app/src/main/java/org/coepi/android/repo/reportsupdate/MatchedReportsChunk.kt b/app/src/main/java/org/coepi/android/repo/reportsupdate/MatchedReportsChunk.kt new file mode 100644 index 00000000..3ddac72b --- /dev/null +++ b/app/src/main/java/org/coepi/android/repo/reportsupdate/MatchedReportsChunk.kt @@ -0,0 +1,7 @@ +package org.coepi.android.repo.reportsupdate + +import org.tcncoalition.tcnclient.crypto.SignedReport + +data class MatchedReportsChunk(val reports: List, val matched: List, + val interval: ReportsInterval +) diff --git a/app/src/main/java/org/coepi/android/repo/reportsupdate/NewAlertsNotificationShower.kt b/app/src/main/java/org/coepi/android/repo/reportsupdate/NewAlertsNotificationShower.kt new file mode 100644 index 00000000..6ae7e6bc --- /dev/null +++ b/app/src/main/java/org/coepi/android/repo/reportsupdate/NewAlertsNotificationShower.kt @@ -0,0 +1,40 @@ +package org.coepi.android.repo.reportsupdate + +import org.coepi.android.R.drawable +import org.coepi.android.R.plurals +import org.coepi.android.R.string +import org.coepi.android.system.Resources +import org.coepi.android.system.intent.IntentKey.NOTIFICATION_INFECTION_ARGS +import org.coepi.android.system.intent.IntentNoValue +import org.coepi.android.system.log.log +import org.coepi.android.ui.notifications.AppNotificationChannels +import org.coepi.android.ui.notifications.NotificationConfig +import org.coepi.android.ui.notifications.NotificationIntentArgs +import org.coepi.android.ui.notifications.NotificationPriority.HIGH +import org.coepi.android.ui.notifications.NotificationsShower + +interface NewAlertsNotificationShower { + fun showNotification(newAlertsCount: Int) +} + +class NewAlertsNotificationShowerImpl( + private val notificationsShower: NotificationsShower, + private val notificationChannelsInitializer: AppNotificationChannels, + private val resources: Resources +) : NewAlertsNotificationShower { + + override fun showNotification(newAlertsCount: Int) { + log.d("Showing notification...") + notificationsShower.showNotification(notificationConfiguration(newAlertsCount)) + } + + private fun notificationConfiguration(newAlertsCount: Int): NotificationConfig = + NotificationConfig( + drawable.ic_launcher_foreground, + resources.getString(string.infection_notification_title), + resources.getQuantityString(plurals.alerts_new_notifications_count, newAlertsCount), + HIGH, + notificationChannelsInitializer.reportsChannelId, + NotificationIntentArgs(NOTIFICATION_INFECTION_ARGS, IntentNoValue()) + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/coepi/android/repo/reportsupdate/ReportsInterval.kt b/app/src/main/java/org/coepi/android/repo/reportsupdate/ReportsInterval.kt new file mode 100644 index 00000000..c13ce2f5 --- /dev/null +++ b/app/src/main/java/org/coepi/android/repo/reportsupdate/ReportsInterval.kt @@ -0,0 +1,34 @@ +package org.coepi.android.repo.reportsupdate + +import org.coepi.android.domain.UnixTime + +// TODO maybe rename in something more generic +data class ReportsInterval(val number: Long, val length: Long) { + init { + if (length <= 0) { + throw IllegalArgumentException("Invalid interval length: $length") + } + } + + fun next(): ReportsInterval = ReportsInterval( + number + 1, + length + ) + + val start: Long = number * length + val end: Long = start + length + + fun startsBefore(time: UnixTime): Boolean = start < time.value + + fun endsBefore(time: UnixTime): Boolean = end < time.value + + fun contains(time: UnixTime): Boolean = time.value in start..end + + companion object { + fun createFor(time: UnixTime, lengthSeconds: Long = 21600L): ReportsInterval = + ReportsInterval( + number = time.value / lengthSeconds, + length = lengthSeconds + ) + } +} diff --git a/app/src/main/java/org/coepi/android/repo/reportsupdate/ReportsUpdater.kt b/app/src/main/java/org/coepi/android/repo/reportsupdate/ReportsUpdater.kt new file mode 100644 index 00000000..27d6ae45 --- /dev/null +++ b/app/src/main/java/org/coepi/android/repo/reportsupdate/ReportsUpdater.kt @@ -0,0 +1,240 @@ +package org.coepi.android.repo.reportsupdate + +import io.reactivex.Observable +import io.reactivex.disposables.CompositeDisposable +import io.reactivex.rxkotlin.plusAssign +import io.reactivex.rxkotlin.withLatestFrom +import io.reactivex.schedulers.Schedulers.io +import io.reactivex.subjects.BehaviorSubject +import io.reactivex.subjects.BehaviorSubject.createDefault +import io.reactivex.subjects.PublishSubject +import io.reactivex.subjects.PublishSubject.create +import org.coepi.android.api.TcnApi +import org.coepi.android.common.Result +import org.coepi.android.common.Result.Failure +import org.coepi.android.common.Result.Success +import org.coepi.android.common.doIfSuccess +import org.coepi.android.common.flatMap +import org.coepi.android.common.map +import org.coepi.android.common.successOrNull +import org.coepi.android.domain.TcnMatcher +import org.coepi.android.domain.UnixTime +import org.coepi.android.domain.UnixTime.Companion.now +import org.coepi.android.extensions.base64ToByteArray +import org.coepi.android.extensions.retrofit.executeSafe +import org.coepi.android.extensions.toHex +import org.coepi.android.extensions.toResult +import org.coepi.android.system.Preferences +import org.coepi.android.system.PreferencesKey.LAST_COMPLETED_REPORTS_INTERVAL +import org.coepi.android.system.log.LogTag.NET +import org.coepi.android.system.log.LogTag.TCN_MATCHING +import org.coepi.android.system.log.log +import org.coepi.android.system.rx.OperationState +import org.coepi.android.system.rx.OperationState.NotStarted +import org.coepi.android.system.rx.OperationState.Progress +import org.coepi.android.system.rx.VoidOperationState +import org.coepi.android.tcn.TcnDao +import org.coepi.android.tcn.TcnReport +import org.coepi.android.tcn.TcnReportDao +import org.tcncoalition.tcnclient.crypto.SignedReport +import java.lang.System.currentTimeMillis +import java.nio.charset.StandardCharsets.UTF_8 + +interface ReportsUpdater { + fun requestUpdateReports() + + val updateState: Observable +} + +class ReportsUpdaterImpl( + private val tcnMatcher: TcnMatcher, + private val api: TcnApi, + private val tcnDao: TcnDao, + private val reportsDao: TcnReportDao, + private val preferences: Preferences, + private val newAlertsNotificationShower: NewAlertsNotificationShower +) : ReportsUpdater { + + private val disposables = CompositeDisposable() + + override val updateState: BehaviorSubject = createDefault(NotStarted) + + private val reportsUpdateTrigger: PublishSubject = create() + + init { + disposables += reportsUpdateTrigger + .observeOn(io()) + .withLatestFrom(updateState) + .filter { (_, state) -> state !is Progress } + .subscribe { + updateReports() + } + } + + override fun requestUpdateReports() { + reportsUpdateTrigger.onNext(Unit) + } + + private fun updateReports() { + val reports: List = retrieveAndMatchNewReports().successOrNull() ?: return + val insertedCount = storeReports(reports) + if (insertedCount > 0) { + newAlertsNotificationShower.showNotification(insertedCount) + } + } + + fun retrieveAndMatchNewReports(): Result, Throwable> { + val now: UnixTime = now() + return matchingReports( + startInterval = determineStartInterval(now), + until = now + ).doIfSuccess { chunks -> + storeLastCompletedInterval(chunks.map { it.interval }, now) + }.map { chunks -> + chunks.flatMap { it.matched } + } + } + + fun determineStartInterval(time: UnixTime): ReportsInterval = + retrieveLastCompletedInterval()?.next() ?: ReportsInterval.createFor(time) + + fun intervalEndingBefore(intervals: List, time: UnixTime) + : ReportsInterval? = + intervals.reversed().find { it.endsBefore(time) } + + /** + * Retrieves paginated reports from api and matches them. + * If fetching/processing one chunk fails, it makes the whole operation fail. + */ + fun matchingReports(startInterval: ReportsInterval, until: UnixTime) + : Result, Throwable> = + generateIntervalsSequence(startInterval, until) + .also { updateState.onNext(Progress) } + .map { retrieveReports(it) } + .map { matchRetrievedReportsResult(it) } + .asIterable() + .map { + when (it) { + is Success -> it.success + is Failure -> return@matchingReports Failure( + Throwable("Error fetching reports: ${it.error}")) + } + } + .let { Success(it) } + + /** + * Returns (lazy) interval sequence starting with from, until the last interval of which the + * start is before until. + * This implies that if an interval starts exactly at until, it will not be included. + */ + fun generateIntervalsSequence(from: ReportsInterval, until: UnixTime): Sequence = + generateSequence(from) { it.next() } + .takeWhile { it.startsBefore(until) } + + fun retrieveReports(interval: ReportsInterval): Result { + val reportsStringsResult: Result, Throwable> = api.getReports(interval.number, + interval.length) + .executeSafe() + .flatMap { it.toResult() } + + reportsStringsResult.doIfSuccess { reports -> + log.i("Retrieved ${reports.size} reports.", NET) + } + + return reportsStringsResult.map { reportStrings -> + SignedReportsChunk( + interval, + reportStrings.mapNotNull { reportString -> + toSignedReport(reportString).also { + if (it == null) { + log.e("Failed to convert report string: $it to report") + } + } + } + ) + } + } + + /** + * Matches the reports and updates operation state observable. + */ + fun matchRetrievedReportsResult(reportsResult: Result) + : Result = + reportsResult.map { chunk -> + toMatchedReportsChunk(chunk) + }.also { + updateOperationStateWithMatchResult(it) + } + + /** + * Maps reports chunk to a new chunk containing possible matches. + */ + fun toMatchedReportsChunk(chunk: SignedReportsChunk): MatchedReportsChunk = + MatchedReportsChunk(chunk.reports, findMatches(chunk.reports), chunk.interval) + + fun findMatches(reports: List): List { + val matchingStartTime = currentTimeMillis() + log.i("Start matching...", TCN_MATCHING) + + val matchedReports: List = tcnDao.all().map { it.tcn }.let { tcns -> + log.i("DB TCNs count: ${tcns.size}") + // TODO review: Do we still need distinct? Does the api still send repeated reports? + tcnMatcher.match(tcns, reports.distinct()) + } + + val time = (currentTimeMillis() - matchingStartTime) / 1000 + log.i("Took ${time}s to match reports", TCN_MATCHING) + + if (matchedReports.isNotEmpty()) { + log.i("Matches found (${matchedReports.size}): $matchedReports", TCN_MATCHING) + } else { + log.i("No matches found", TCN_MATCHING) + } + + return matchedReports + } + + /** + * Stores reports in the database + * @return count of inserted reports. This can differ from reports count, if reports + * are already in the db. + */ + fun storeReports(reports: List): Int { + val insertedCount: Int = reports.map { + reportsDao.insert(TcnReport( + id = it.signature.toByteArray().toHex(), + report = it.report.memoData.toString(UTF_8), + timestamp = now().value // TODO extract this from memo, when protocol implemented + )) + }.filter { it }.size + + if (insertedCount >= 0) { + log.d("Added $insertedCount new reports") + } + + return insertedCount + } + + private fun updateOperationStateWithMatchResult(result: Result) { + updateState.onNext(when (result) { + is Success -> OperationState.Success(Unit) + is Failure -> OperationState.Failure(result.error).also { + log.e("Error updating reports: ${result.error}") + } + }) + updateState.onNext(NotStarted) + } + + private fun toSignedReport(reportString: String): SignedReport? = + reportString.base64ToByteArray()?.let { SignedReport.fromByteArray(it) } + + private fun retrieveLastCompletedInterval(): ReportsInterval? = + preferences.getObject(LAST_COMPLETED_REPORTS_INTERVAL, ReportsInterval::class.java) + + private fun storeLastCompletedInterval(intervals: List, now: UnixTime) { + intervalEndingBefore(intervals, now)?.let { + // TODO (optional) closer inspection of relationship of now to intervals, for better logging/testing + preferences.putObject(LAST_COMPLETED_REPORTS_INTERVAL, it) + } + } +} diff --git a/app/src/main/java/org/coepi/android/repo/reportsupdate/SignedReportsChunk.kt b/app/src/main/java/org/coepi/android/repo/reportsupdate/SignedReportsChunk.kt new file mode 100644 index 00000000..825c36ba --- /dev/null +++ b/app/src/main/java/org/coepi/android/repo/reportsupdate/SignedReportsChunk.kt @@ -0,0 +1,5 @@ +package org.coepi.android.repo.reportsupdate + +import org.tcncoalition.tcnclient.crypto.SignedReport + +data class SignedReportsChunk(val interval: ReportsInterval, val reports: List) diff --git a/app/src/main/java/org/coepi/android/system/Preferences.kt b/app/src/main/java/org/coepi/android/system/Preferences.kt index f987aa5e..1f8f7304 100644 --- a/app/src/main/java/org/coepi/android/system/Preferences.kt +++ b/app/src/main/java/org/coepi/android/system/Preferences.kt @@ -1,35 +1,72 @@ package org.coepi.android.system import android.content.SharedPreferences +import com.google.gson.Gson enum class PreferencesKey { SEEN_ONBOARDING, + LAST_COMPLETED_REPORTS_INTERVAL } -class Preferences(private val sharedPreferences: SharedPreferences) { +interface Preferences { + fun getString(key: PreferencesKey): String? + fun putString(key: PreferencesKey, value: String?) - fun getBoolean(key: PreferencesKey): Boolean = + fun getBoolean(key: PreferencesKey): Boolean + fun putBoolean(key: PreferencesKey, value: Boolean?) + + fun getLong(key: PreferencesKey): Long? + fun putLong(key: PreferencesKey, value: Long?) + + fun putObject(key: PreferencesKey, model: T?) + fun getObject(key: PreferencesKey, clazz: Class): T? +} + +class PreferencesImpl( + private val sharedPreferences: SharedPreferences, + private val gson: Gson +): Preferences { + + override fun getBoolean(key: PreferencesKey): Boolean = sharedPreferences.getBoolean(key.toString(), false) - fun putBoolean(key: PreferencesKey, value: Boolean?) { + override fun putBoolean(key: PreferencesKey, value: Boolean?) { putOrClear(key, value) { sharedPreferences.edit().putBoolean(key.toString(), it).apply() } } - fun getLong(key: PreferencesKey): Long? = + override fun getLong(key: PreferencesKey): Long? = if (sharedPreferences.contains(key.toString())) { sharedPreferences.getLong(key.toString(), -1) } else { null } - fun putLong(key: PreferencesKey, value: Long?) { + override fun putLong(key: PreferencesKey, value: Long?) { putOrClear(key, value) { sharedPreferences.edit().putLong(key.toString(), it).apply() } } + override fun getString(key: PreferencesKey): String? = + sharedPreferences.getString(key.toString(), null) + + override fun putString(key: PreferencesKey, value: String?) { + putOrClear(key, value) { + sharedPreferences.edit().putString(key.toString(), it).apply() + } + } + + override fun putObject(key: PreferencesKey, model: T?) { + putString(key, model?.let { gson.toJson(it) } ) + } + + override fun getObject(key: PreferencesKey, clazz: Class): T? = + getString(key)?.let { + gson.fromJson(it, clazz) + } + private fun putOrClear(key: PreferencesKey, obj: T?, put: (T) -> Unit) { if (obj != null) { put(obj) diff --git a/app/src/main/java/org/coepi/android/system/log/Log.kt b/app/src/main/java/org/coepi/android/system/log/Log.kt index b71b2645..df04f747 100644 --- a/app/src/main/java/org/coepi/android/system/log/Log.kt +++ b/app/src/main/java/org/coepi/android/system/log/Log.kt @@ -8,13 +8,10 @@ val log: Log = CompositeLog( // To filter logcat by multiple tags, use regex, e.g. (tag1)|(tag2) enum class LogTag { - BLE_A, // BLE advertiser - BLE_S, // BLE scanner BLE, // General BLE (can't be assigned to peripheral or central) NET, // Networking DB, // DB - CEN_L, // CEN logic - CEN_MATCHING // Worker updating reports + TCN_MATCHING // Worker updating reports } interface Log { diff --git a/app/src/main/java/org/coepi/android/tcn/ApiModule.kt b/app/src/main/java/org/coepi/android/tcn/ApiModule.kt new file mode 100644 index 00000000..382e52dd --- /dev/null +++ b/app/src/main/java/org/coepi/android/tcn/ApiModule.kt @@ -0,0 +1,44 @@ +package org.coepi.android.tcn + +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import okhttp3.logging.HttpLoggingInterceptor.Level.BODY +import okhttp3.logging.HttpLoggingInterceptor.Logger +import org.coepi.android.api.TcnApi +import org.coepi.android.common.ApiSymptomsMapper +import org.coepi.android.common.ApiSymptomsMapperImpl +import org.coepi.android.system.log.LogTag.NET +import org.coepi.android.system.log.log +import org.koin.android.ext.koin.androidApplication +import org.koin.dsl.module +import retrofit2.Retrofit +import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory +import retrofit2.converter.gson.GsonConverterFactory + +val apiModule = module { + single { provideRetrofit() } + single { provideTcnApi(get()) } + single { ApiSymptomsMapperImpl(androidApplication(), get()) } +} + +private fun provideRetrofit() : Retrofit { + val myLog = log + val client = OkHttpClient.Builder() + .addInterceptor(HttpLoggingInterceptor(object: Logger { + override fun log(message: String) { + myLog.v(message, NET) + } + }) + .apply { setLevel(BODY) }) + .build() + + return Retrofit.Builder() + .addCallAdapterFactory(RxJava2CallAdapterFactory.create()) + .addConverterFactory(GsonConverterFactory.create()) + .baseUrl("https://18ye1iivg6.execute-api.us-west-1.amazonaws.com/v4/") + .client(client) + .build() +} + +private fun provideTcnApi(retrofit: Retrofit): TcnApi = + retrofit.create(TcnApi::class.java) diff --git a/app/src/main/java/org/coepi/android/cen/BleInitializer.kt b/app/src/main/java/org/coepi/android/tcn/BleInitializer.kt similarity index 96% rename from app/src/main/java/org/coepi/android/cen/BleInitializer.kt rename to app/src/main/java/org/coepi/android/tcn/BleInitializer.kt index 75a91877..eaa20034 100644 --- a/app/src/main/java/org/coepi/android/cen/BleInitializer.kt +++ b/app/src/main/java/org/coepi/android/tcn/BleInitializer.kt @@ -1,4 +1,4 @@ -package org.coepi.android.cen +package org.coepi.android.tcn import io.reactivex.disposables.CompositeDisposable import io.reactivex.rxkotlin.plusAssign diff --git a/app/src/main/java/org/coepi/android/cen/RealmReceivedCen.kt b/app/src/main/java/org/coepi/android/tcn/RealmReceivedTcn.kt similarity index 52% rename from app/src/main/java/org/coepi/android/cen/RealmReceivedCen.kt rename to app/src/main/java/org/coepi/android/tcn/RealmReceivedTcn.kt index 062f2ed6..332207bb 100644 --- a/app/src/main/java/org/coepi/android/cen/RealmReceivedCen.kt +++ b/app/src/main/java/org/coepi/android/tcn/RealmReceivedTcn.kt @@ -1,9 +1,9 @@ -package org.coepi.android.cen +package org.coepi.android.tcn import io.realm.RealmObject import io.realm.annotations.PrimaryKey -open class RealmReceivedCen( - @PrimaryKey var cen: String = "", // Hex encoding +open class RealmReceivedTcn( + @PrimaryKey var tcn: String = "", // Hex encoding var timestamp: Long = 0 // Unix time ): RealmObject() diff --git a/app/src/main/java/org/coepi/android/tcn/RealmTcnDao.kt b/app/src/main/java/org/coepi/android/tcn/RealmTcnDao.kt new file mode 100644 index 00000000..65816727 --- /dev/null +++ b/app/src/main/java/org/coepi/android/tcn/RealmTcnDao.kt @@ -0,0 +1,48 @@ +package org.coepi.android.tcn + +import io.realm.kotlin.createObject +import io.realm.kotlin.oneOf +import io.realm.kotlin.where +import org.coepi.android.domain.UnixTime +import org.coepi.android.extensions.hexToByteArray +import org.coepi.android.repo.RealmProvider + +class RealmTcnDao(private val realmProvider: RealmProvider) : TcnDao { + private val realm get() = realmProvider.realm + + override fun all(): List = + realm.where() + .findAll() + .map { it.toReceivedTcn() } + + override fun matchTcns(start: UnixTime, end: UnixTime, tcns: Array): List = + realm.where() + .greaterThanOrEqualTo("timestamp", start.value) + .and() + .lessThanOrEqualTo("timestamp", end.value) + .and() + .oneOf("tcn", tcns) + .findAll() + .map { it.toReceivedTcn() } + + override fun findTcn(tcn: Tcn): ReceivedTcn? = + realm.where() + .equalTo("tcn", tcn.toHex()) + .findAll() + .firstOrNull() + ?.toReceivedTcn() + + override fun insert(tcn: ReceivedTcn): Boolean { + if (findTcn(tcn.tcn) != null) { + return false + } + realm.executeTransaction { + val realmObj = realm.createObject(tcn.tcn.toHex()) // Create a new object + realmObj.timestamp = tcn.timestamp.value + } + return true + } + + private fun RealmReceivedTcn.toReceivedTcn() = + ReceivedTcn(Tcn(tcn.hexToByteArray()), UnixTime.fromValue(timestamp)) +} diff --git a/app/src/main/java/org/coepi/android/cen/RealmCenKey.kt b/app/src/main/java/org/coepi/android/tcn/RealmTcnKey.kt similarity index 61% rename from app/src/main/java/org/coepi/android/cen/RealmCenKey.kt rename to app/src/main/java/org/coepi/android/tcn/RealmTcnKey.kt index 03c10abb..29dc1c5e 100644 --- a/app/src/main/java/org/coepi/android/cen/RealmCenKey.kt +++ b/app/src/main/java/org/coepi/android/tcn/RealmTcnKey.kt @@ -1,12 +1,10 @@ -package org.coepi.android.cen +package org.coepi.android.tcn import io.realm.RealmObject import io.realm.annotations.PrimaryKey import org.coepi.android.domain.UnixTime -open class RealmCenKey( +open class RealmTcnKey( @PrimaryKey var key: String = "", var timestamp: Long = 0 // Unix time ): RealmObject() - -fun RealmCenKey.toCenKey() = CenKey(key, UnixTime.fromValue(timestamp)) diff --git a/app/src/main/java/org/coepi/android/cen/RealmCenKeyDao.kt b/app/src/main/java/org/coepi/android/tcn/RealmTcnKeyDao.kt similarity index 55% rename from app/src/main/java/org/coepi/android/cen/RealmCenKeyDao.kt rename to app/src/main/java/org/coepi/android/tcn/RealmTcnKeyDao.kt index b37e9c70..0c937267 100644 --- a/app/src/main/java/org/coepi/android/cen/RealmCenKeyDao.kt +++ b/app/src/main/java/org/coepi/android/tcn/RealmTcnKeyDao.kt @@ -1,4 +1,4 @@ -package org.coepi.android.cen +package org.coepi.android.tcn import io.realm.Sort.DESCENDING import io.realm.kotlin.createObject @@ -6,19 +6,19 @@ import io.realm.kotlin.where import org.coepi.android.domain.UnixTime import org.coepi.android.repo.RealmProvider -class RealmCenKeyDao(private val realmProvider: RealmProvider) : CenKeyDao { +class RealmTcnKeyDao(private val realmProvider: RealmProvider) : TcnKeyDao { private val realm get() = realmProvider.realm - override fun lastCENKeys(limit : Int): List = - realm.where() + override fun lastTcnKeys(limit: Int): List = + realm.where() .sort("timestamp", DESCENDING) .limit(limit.toLong()) .findAll() - .map { CenKey(it.key, UnixTime.fromValue(it.timestamp)) } + .map { TcnKey(it.key, UnixTime.fromValue(it.timestamp)) } - override fun insert(key: CenKey) { + override fun insert(key: TcnKey) { realm.executeTransaction { - val realmObj = realm.createObject(key.key) + val realmObj = realm.createObject(key.key) realmObj.timestamp = key.timestamp.value } } diff --git a/app/src/main/java/org/coepi/android/cen/RealmCenReport.kt b/app/src/main/java/org/coepi/android/tcn/RealmTcnReport.kt similarity index 67% rename from app/src/main/java/org/coepi/android/cen/RealmCenReport.kt rename to app/src/main/java/org/coepi/android/tcn/RealmTcnReport.kt index 40ef43f6..d1c8bc0f 100644 --- a/app/src/main/java/org/coepi/android/cen/RealmCenReport.kt +++ b/app/src/main/java/org/coepi/android/tcn/RealmTcnReport.kt @@ -1,12 +1,11 @@ -package org.coepi.android.cen +package org.coepi.android.tcn import io.realm.RealmObject import io.realm.annotations.PrimaryKey -import java.util.Date -open class RealmCenReport( +open class RealmTcnReport( @PrimaryKey var id: String = "", var report: String = "", var timestamp: Long = 0, var deleted: Boolean = false -): RealmObject() +) : RealmObject() diff --git a/app/src/main/java/org/coepi/android/cen/RealmCenReportDao.kt b/app/src/main/java/org/coepi/android/tcn/RealmTcnReportDao.kt similarity index 62% rename from app/src/main/java/org/coepi/android/cen/RealmCenReportDao.kt rename to app/src/main/java/org/coepi/android/tcn/RealmTcnReportDao.kt index c4f9d84c..d2595131 100644 --- a/app/src/main/java/org/coepi/android/cen/RealmCenReportDao.kt +++ b/app/src/main/java/org/coepi/android/tcn/RealmTcnReportDao.kt @@ -1,4 +1,4 @@ -package org.coepi.android.cen +package org.coepi.android.tcn import io.reactivex.subjects.BehaviorSubject import io.realm.RealmResults @@ -7,35 +7,35 @@ import io.realm.kotlin.where import org.coepi.android.repo.RealmProvider import org.coepi.android.system.log.log -class RealmCenReportDao(private val realmProvider: RealmProvider) : CenReportDao { +class RealmTcnReportDao(private val realmProvider: RealmProvider) : TcnReportDao { private val realm get() = realmProvider.realm - override val reports: BehaviorSubject> = BehaviorSubject.create() + override val reports: BehaviorSubject> = BehaviorSubject.create() - private val reportsResults: RealmResults + private val reportsResults: RealmResults init { - reportsResults = realm.where() + reportsResults = realm.where() .equalTo("deleted", false) .findAllAsync() reportsResults .addChangeListener { results, _ -> reports.onNext(results.map { - it.toReceivedCenReport() + it.toReceivedTcnReport() }) } } - override fun all(): List = - realm.where() + override fun all(): List = + realm.where() .findAll() - .map { it.toReceivedCenReport() } + .map { it.toReceivedTcnReport() } - override fun insert(report: CenReport): Boolean { + override fun insert(report: TcnReport): Boolean { if (findReportById(report.id) != null) return false realm.executeTransaction { - val realmObj = realm.createObject(report.id) + val realmObj = realm.createObject(report.id) realmObj.report = report.report realmObj.timestamp = report.timestamp } @@ -53,8 +53,8 @@ class RealmCenReportDao(private val realmProvider: RealmProvider) : CenReportDao } } - private fun findReportById(id: String): RealmCenReport? { - val results = realm.where() + private fun findReportById(id: String): RealmTcnReport? { + val results = realm.where() .equalTo("id", id) .findAll() @@ -66,6 +66,6 @@ class RealmCenReportDao(private val realmProvider: RealmProvider) : CenReportDao } } - private fun RealmCenReport.toReceivedCenReport(): ReceivedCenReport = ReceivedCenReport( - CenReport(id = id, report = report, timestamp = timestamp )) + private fun RealmTcnReport.toReceivedTcnReport(): ReceivedTcnReport = ReceivedTcnReport( + TcnReport(id = id, report = report, timestamp = timestamp )) } diff --git a/app/src/main/java/org/coepi/android/tcn/ReceivedTcn.kt b/app/src/main/java/org/coepi/android/tcn/ReceivedTcn.kt new file mode 100644 index 00000000..43144e39 --- /dev/null +++ b/app/src/main/java/org/coepi/android/tcn/ReceivedTcn.kt @@ -0,0 +1,5 @@ +package org.coepi.android.tcn + +import org.coepi.android.domain.UnixTime + +data class ReceivedTcn(val tcn: Tcn, val timestamp: UnixTime) diff --git a/app/src/main/java/org/coepi/android/cen/ReceivedCenReport.kt b/app/src/main/java/org/coepi/android/tcn/ReceivedTcnReport.kt similarity index 53% rename from app/src/main/java/org/coepi/android/cen/ReceivedCenReport.kt rename to app/src/main/java/org/coepi/android/tcn/ReceivedTcnReport.kt index a0f9cfac..abfacd22 100644 --- a/app/src/main/java/org/coepi/android/cen/ReceivedCenReport.kt +++ b/app/src/main/java/org/coepi/android/tcn/ReceivedTcnReport.kt @@ -1,9 +1,9 @@ -package org.coepi.android.cen +package org.coepi.android.tcn import android.os.Parcelable import kotlinx.android.parcel.Parcelize @Parcelize -data class ReceivedCenReport( - val report: CenReport +data class ReceivedTcnReport( + val report: TcnReport ) : Parcelable diff --git a/app/src/main/java/org/coepi/android/cen/SymptomReport.kt b/app/src/main/java/org/coepi/android/tcn/SymptomReport.kt similarity index 90% rename from app/src/main/java/org/coepi/android/cen/SymptomReport.kt rename to app/src/main/java/org/coepi/android/tcn/SymptomReport.kt index ebe0fa58..2960d06b 100644 --- a/app/src/main/java/org/coepi/android/cen/SymptomReport.kt +++ b/app/src/main/java/org/coepi/android/tcn/SymptomReport.kt @@ -1,4 +1,4 @@ -package org.coepi.android.cen +package org.coepi.android.tcn import android.os.Parcelable import kotlinx.android.parcel.Parcelize diff --git a/app/src/main/java/org/coepi/android/cen/Cen.kt b/app/src/main/java/org/coepi/android/tcn/Tcn.kt similarity index 50% rename from app/src/main/java/org/coepi/android/cen/Cen.kt rename to app/src/main/java/org/coepi/android/tcn/Tcn.kt index 2b0dadad..412768e0 100644 --- a/app/src/main/java/org/coepi/android/cen/Cen.kt +++ b/app/src/main/java/org/coepi/android/tcn/Tcn.kt @@ -1,12 +1,15 @@ -package org.coepi.android.cen +package org.coepi.android.tcn import org.coepi.android.extensions.toHex -data class Cen(val bytes: ByteArray) { +data class Tcn(val bytes: ByteArray) { fun toHex(): String = bytes.toHex() override fun toString(): String = toHex() + override fun hashCode(): Int = + toHex().hashCode() + override fun equals(other: Any?): Boolean = - toString() == other.toString() + other is Tcn && toHex() == other.toHex() } diff --git a/app/src/main/java/org/coepi/android/tcn/TcnDao.kt b/app/src/main/java/org/coepi/android/tcn/TcnDao.kt new file mode 100644 index 00000000..f55d576d --- /dev/null +++ b/app/src/main/java/org/coepi/android/tcn/TcnDao.kt @@ -0,0 +1,10 @@ +package org.coepi.android.tcn + +import org.coepi.android.domain.UnixTime + +interface TcnDao { + fun all(): List + fun matchTcns(start: UnixTime, end: UnixTime, tcns: Array): List + fun findTcn(tcn: Tcn): ReceivedTcn? + fun insert(tcn: ReceivedTcn): Boolean +} diff --git a/app/src/main/java/org/coepi/android/cen/CenKey.kt b/app/src/main/java/org/coepi/android/tcn/TcnKey.kt similarity index 67% rename from app/src/main/java/org/coepi/android/cen/CenKey.kt rename to app/src/main/java/org/coepi/android/tcn/TcnKey.kt index cfafcd30..5a05c4ec 100644 --- a/app/src/main/java/org/coepi/android/cen/CenKey.kt +++ b/app/src/main/java/org/coepi/android/tcn/TcnKey.kt @@ -1,8 +1,8 @@ -package org.coepi.android.cen +package org.coepi.android.tcn import org.coepi.android.domain.UnixTime -data class CenKey( +data class TcnKey( val key: String, // Hex val timestamp: UnixTime ) diff --git a/app/src/main/java/org/coepi/android/tcn/TcnKeyDao.kt b/app/src/main/java/org/coepi/android/tcn/TcnKeyDao.kt new file mode 100644 index 00000000..b08546cb --- /dev/null +++ b/app/src/main/java/org/coepi/android/tcn/TcnKeyDao.kt @@ -0,0 +1,6 @@ +package org.coepi.android.tcn + +interface TcnKeyDao { + fun lastTcnKeys(limit : Int): List + fun insert(key: TcnKey) +} diff --git a/app/src/main/java/org/coepi/android/tcn/TcnModule.kt b/app/src/main/java/org/coepi/android/tcn/TcnModule.kt new file mode 100644 index 00000000..903e97b4 --- /dev/null +++ b/app/src/main/java/org/coepi/android/tcn/TcnModule.kt @@ -0,0 +1,26 @@ +package org.coepi.android.tcn + +import org.coepi.android.cross.ScannedTcnsHandler +import org.coepi.android.domain.TcnMatcher +import org.coepi.android.domain.TcnMatcherImpl +import org.coepi.android.domain.TcnGenerator +import org.coepi.android.domain.TcnGeneratorImpl +import org.coepi.android.repo.reportsupdate.NewAlertsNotificationShower +import org.coepi.android.repo.reportsupdate.NewAlertsNotificationShowerImpl +import org.coepi.android.repo.reportsupdate.ReportsUpdater +import org.coepi.android.repo.reportsupdate.ReportsUpdaterImpl +import org.koin.android.ext.koin.androidApplication +import org.koin.dsl.module + +val TcnModule = module { + single(createdAtStart = true) { RealmTcnDao(get()) } + single(createdAtStart = true) { RealmTcnReportDao(get()) } + single(createdAtStart = true) { RealmTcnKeyDao(get()) } + single { TcnReportRepoImpl(get(), get(), get()) } + single { ReportsUpdaterImpl(get(), get(), get(), get(), get(), get()) } + single { NewAlertsNotificationShowerImpl(get(), get(), get()) } + single { TcnMatcherImpl() } + single { TcnGeneratorImpl(androidApplication()) } + single { ScannedTcnsHandler(get(), get(), get()) } + single { BleInitializer(get(), get()) } +} diff --git a/app/src/main/java/org/coepi/android/cen/CenReport.kt b/app/src/main/java/org/coepi/android/tcn/TcnReport.kt similarity index 62% rename from app/src/main/java/org/coepi/android/cen/CenReport.kt rename to app/src/main/java/org/coepi/android/tcn/TcnReport.kt index 29191d4e..4dc2ad35 100644 --- a/app/src/main/java/org/coepi/android/cen/CenReport.kt +++ b/app/src/main/java/org/coepi/android/tcn/TcnReport.kt @@ -1,11 +1,11 @@ -package org.coepi.android.cen +package org.coepi.android.tcn import android.os.Parcelable import kotlinx.android.parcel.Parcelize @Parcelize -data class CenReport( +data class TcnReport( val id: String, - val report: String, + val report: String, // Base64 val timestamp: Long ) : Parcelable diff --git a/app/src/main/java/org/coepi/android/tcn/TcnReportDao.kt b/app/src/main/java/org/coepi/android/tcn/TcnReportDao.kt new file mode 100644 index 00000000..862ab7f3 --- /dev/null +++ b/app/src/main/java/org/coepi/android/tcn/TcnReportDao.kt @@ -0,0 +1,11 @@ +package org.coepi.android.tcn + +import io.reactivex.Observable + +interface TcnReportDao { + val reports: Observable> + + fun all(): List + fun insert(report: TcnReport): Boolean + fun delete(report: SymptomReport) +} diff --git a/app/src/main/java/org/coepi/android/tcn/TcnReportRepo.kt b/app/src/main/java/org/coepi/android/tcn/TcnReportRepo.kt new file mode 100644 index 00000000..314dc04b --- /dev/null +++ b/app/src/main/java/org/coepi/android/tcn/TcnReportRepo.kt @@ -0,0 +1,71 @@ +package org.coepi.android.tcn + +import io.reactivex.Completable +import io.reactivex.Observable +import io.reactivex.disposables.CompositeDisposable +import io.reactivex.rxkotlin.plusAssign +import io.reactivex.schedulers.Schedulers +import io.reactivex.subjects.PublishSubject +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.RequestBody.Companion.toRequestBody +import org.coepi.android.api.TcnApi +import org.coepi.android.common.ApiSymptomsMapper +import org.coepi.android.extensions.rx.toObservable +import org.coepi.android.system.log.log +import org.coepi.android.system.rx.OperationState.Progress +import org.coepi.android.system.rx.OperationStateNotifier +import org.coepi.android.system.rx.VoidOperationState + +interface TcnReportRepo { + val reports: Observable> + + val sendState: Observable + + fun send(report: SymptomReport) + + fun delete(report: SymptomReport) +} + +class TcnReportRepoImpl( + private val tcnReportDao: TcnReportDao, + private val symptomsProcessor: ApiSymptomsMapper, + private val api: TcnApi +) : TcnReportRepo { + private val disposables = CompositeDisposable() + + private val postSymptomsTrigger: PublishSubject = PublishSubject.create() + + override val reports: Observable> = tcnReportDao.reports.map { reports -> + reports.map { + symptomsProcessor.fromTcnReport(it.report) + } + } + + override val sendState: PublishSubject = PublishSubject.create() + + init { + disposables += postSymptomsTrigger + .doOnNext { sendState.onNext(Progress) } + .flatMap { report -> post(report) + .doOnError { log.e("Error posting report: ${it.message}") } + .toObservable(Unit) + .materialize() + } + .subscribe(OperationStateNotifier(sendState)) + } + + override fun send(report: SymptomReport) { + postSymptomsTrigger.onNext(report) + } + + private fun post(report: SymptomReport): Completable = + symptomsProcessor.toApiReport(report).let { apiReport -> + // NOTE: Needs to be sent as text/plain to not add quotes + val requestBody = apiReport.toRequestBody("text/plain".toMediaType()) + api.postReport(requestBody).subscribeOn(Schedulers.io()) + } + + override fun delete(report: SymptomReport) { + tcnReportDao.delete(report) + } +} diff --git a/app/src/main/java/org/coepi/android/cen/Utils.kt b/app/src/main/java/org/coepi/android/tcn/Utils.kt similarity index 98% rename from app/src/main/java/org/coepi/android/cen/Utils.kt rename to app/src/main/java/org/coepi/android/tcn/Utils.kt index 0d359f3d..c9a53e6d 100644 --- a/app/src/main/java/org/coepi/android/cen/Utils.kt +++ b/app/src/main/java/org/coepi/android/tcn/Utils.kt @@ -1,4 +1,4 @@ -package org.coepi.android.cen +package org.coepi.android.tcn import java.nio.ByteBuffer import java.nio.ByteOrder diff --git a/app/src/main/java/org/coepi/android/ui/alerts/AlertViewData.kt b/app/src/main/java/org/coepi/android/ui/alerts/AlertViewData.kt index c2a932b0..a450f3b5 100644 --- a/app/src/main/java/org/coepi/android/ui/alerts/AlertViewData.kt +++ b/app/src/main/java/org/coepi/android/ui/alerts/AlertViewData.kt @@ -1,5 +1,5 @@ package org.coepi.android.ui.alerts -import org.coepi.android.cen.SymptomReport +import org.coepi.android.tcn.SymptomReport data class AlertViewData(val exposureType: String, val time: String, val report: SymptomReport) diff --git a/app/src/main/java/org/coepi/android/ui/alerts/AlertsViewModel.kt b/app/src/main/java/org/coepi/android/ui/alerts/AlertsViewModel.kt index 355763e7..de9a8e91 100644 --- a/app/src/main/java/org/coepi/android/ui/alerts/AlertsViewModel.kt +++ b/app/src/main/java/org/coepi/android/ui/alerts/AlertsViewModel.kt @@ -4,7 +4,7 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.ViewModel import io.reactivex.android.schedulers.AndroidSchedulers.mainThread import org.coepi.android.R.plurals.alerts_new_notifications_count -import org.coepi.android.cen.SymptomReport +import org.coepi.android.tcn.SymptomReport import org.coepi.android.extensions.rx.toLiveData import org.coepi.android.repo.AlertsRepo import org.coepi.android.system.Resources diff --git a/app/src/main/java/org/coepi/android/ui/alertsdetails/AlertsDetailsFragment.kt b/app/src/main/java/org/coepi/android/ui/alertsdetails/AlertsDetailsFragment.kt index cb51cd2a..9f6be6e4 100644 --- a/app/src/main/java/org/coepi/android/ui/alertsdetails/AlertsDetailsFragment.kt +++ b/app/src/main/java/org/coepi/android/ui/alertsdetails/AlertsDetailsFragment.kt @@ -9,8 +9,7 @@ import androidx.fragment.app.Fragment import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView.VERTICAL import kotlinx.android.parcel.Parcelize -import org.coepi.android.cen.ReceivedCenReport -import org.coepi.android.cen.SymptomReport +import org.coepi.android.tcn.SymptomReport import org.coepi.android.databinding.FragmentAlertsDetailsBinding.inflate import org.coepi.android.extensions.observeWith import org.coepi.android.ui.alertsdetails.AlertsDetailsFragmentArgs.Companion.fromBundle diff --git a/app/src/main/java/org/coepi/android/ui/alertsdetails/AlertsDetailsViewModel.kt b/app/src/main/java/org/coepi/android/ui/alertsdetails/AlertsDetailsViewModel.kt index 57daef52..01cf0065 100644 --- a/app/src/main/java/org/coepi/android/ui/alertsdetails/AlertsDetailsViewModel.kt +++ b/app/src/main/java/org/coepi/android/ui/alertsdetails/AlertsDetailsViewModel.kt @@ -4,7 +4,7 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.ViewModel import io.reactivex.Observable.just import org.coepi.android.R.drawable.ic_alert -import org.coepi.android.cen.SymptomReport +import org.coepi.android.tcn.SymptomReport import org.coepi.android.domain.model.Symptom import org.coepi.android.extensions.rx.toLiveData diff --git a/app/src/main/java/org/coepi/android/ui/debug/DebugBleObservable.kt b/app/src/main/java/org/coepi/android/ui/debug/DebugBleObservable.kt index 48ae3836..f28f2674 100644 --- a/app/src/main/java/org/coepi/android/ui/debug/DebugBleObservable.kt +++ b/app/src/main/java/org/coepi/android/ui/debug/DebugBleObservable.kt @@ -3,34 +3,33 @@ package org.coepi.android.ui.debug import io.reactivex.Observable import io.reactivex.subjects.BehaviorSubject import io.reactivex.subjects.BehaviorSubject.create -import org.coepi.android.ble.BleManager -import org.coepi.android.cen.Cen -import org.coepi.android.cen.CenKey +import org.coepi.android.tcn.Tcn +import org.coepi.android.tcn.TcnKey interface DebugBleObservable { - val myKey: Observable - val myCen: Observable - val observedCens: Observable + val myKey: Observable + val myTcn: Observable + val observedTcns: Observable - fun setMyKey(key: CenKey) - fun setMyCen(cen: Cen) - fun setObservedCen(cen: Cen) + fun setMyKey(key: TcnKey) + fun setMyTcn(tcn: Tcn) + fun setObservedTcn(tcn: Tcn) } class DebugBleObservableImpl: DebugBleObservable { - override val myKey: BehaviorSubject = create() - override val myCen: BehaviorSubject = create() - override val observedCens: BehaviorSubject = create() + override val myKey: BehaviorSubject = create() + override val myTcn: BehaviorSubject = create() + override val observedTcns: BehaviorSubject = create() - override fun setMyKey(key: CenKey) { + override fun setMyKey(key: TcnKey) { myKey.onNext(key) } - override fun setMyCen(cen: Cen) { - myCen.onNext(cen) + override fun setMyTcn(tcn: Tcn) { + myTcn.onNext(tcn) } - override fun setObservedCen(cen: Cen) { - observedCens.onNext(cen) + override fun setObservedTcn(tcn: Tcn) { + observedTcns.onNext(tcn) } } diff --git a/app/src/main/java/org/coepi/android/ui/debug/ble/DebugBleViewModel.kt b/app/src/main/java/org/coepi/android/ui/debug/ble/DebugBleViewModel.kt index ffe6c525..93e10ed6 100644 --- a/app/src/main/java/org/coepi/android/ui/debug/ble/DebugBleViewModel.kt +++ b/app/src/main/java/org/coepi/android/ui/debug/ble/DebugBleViewModel.kt @@ -13,15 +13,15 @@ class DebugBleViewModel(debugBleObservable: DebugBleObservable) : ViewModel() { val items: LiveData> = combineLatest( debugBleObservable.myKey.asSequence().map { it.distinct() }, - debugBleObservable.myCen.asSequence().map { it.distinct() }, - debugBleObservable.observedCens.asSequence().map { it.distinct() } + debugBleObservable.myTcn.asSequence().map { it.distinct() }, + debugBleObservable.observedTcns.asSequence().map { it.distinct() } - ).map { (keys, myCens, observedCens) -> + ).map { (keys, myTcns, observedTcns) -> listOf(Header("My key")) + keys.map { Item(it.key) } + - listOf(Header("My CEN")) + - myCens.map { Item(it.toHex()) } + + listOf(Header("My TCN")) + + myTcns.map { Item(it.toHex()) } + listOf(Header("Discovered")) + - observedCens.map { Item(it.toHex()) } + observedTcns.map { Item(it.toHex()) } }.toLiveData() } diff --git a/app/src/main/java/org/coepi/android/util/Base64Ext.java b/app/src/main/java/org/coepi/android/util/Base64Ext.java new file mode 100644 index 00000000..e51ce20b --- /dev/null +++ b/app/src/main/java/org/coepi/android/util/Base64Ext.java @@ -0,0 +1,2067 @@ +package org.coepi.android.util; + +// CoEpi note: Added this because Andorid's Base64 doesn't work in Unit Tests + +/** + *

Encodes and decodes to and from Base64 notation.

+ *

Homepage: http://iharder.net/base64.

+ * + *

Example:

+ * + * String encoded = Base64.encode( myByteArray ); + *
+ * byte[] myByteArray = Base64.decode( encoded ); + * + *

The options parameter, which appears in a few places, is used to pass + * several pieces of information to the encoder. In the "higher level" methods such as + * encodeBytes( bytes, options ) the options parameter can be used to indicate such + * things as first gzipping the bytes before encoding them, not inserting linefeeds, + * and encoding using the URL-safe and Ordered dialects.

+ * + *

Note, according to RFC3548, + * Section 2.1, implementations should not add line feeds unless explicitly told + * to do so. I've got Base64 set to this behavior now, although earlier versions + * broke lines by default.

+ * + *

The constants defined in Base64 can be OR-ed together to combine options, so you + * might make a call like this:

+ * + * String encoded = Base64.encodeBytes( mybytes, Base64.GZIP | Base64.DO_BREAK_LINES ); + *

to compress the data before encoding it and then making the output have newline characters.

+ *

Also...

+ * String encoded = Base64.encodeBytes( crazyString.getBytes() ); + * + * + * + *

+ * Change Log: + *

+ *
    + *
  • v2.3.7 - Fixed subtle bug when base 64 input stream contained the + * value 01111111, which is an invalid base 64 character but should not + * throw an ArrayIndexOutOfBoundsException either. Led to discovery of + * mishandling (or potential for better handling) of other bad input + * characters. You should now get an IOException if you try decoding + * something that has bad characters in it.
  • + *
  • v2.3.6 - Fixed bug when breaking lines and the final byte of the encoded + * string ended in the last column; the buffer was not properly shrunk and + * contained an extra (null) byte that made it into the string.
  • + *
  • v2.3.5 - Fixed bug in {@link #encodeFromFile} where estimated buffer size + * was wrong for files of size 31, 34, and 37 bytes.
  • + *
  • v2.3.4 - Fixed bug when working with gzipped streams whereby flushing + * the Base64.OutputStream closed the Base64 encoding (by padding with equals + * signs) too soon. Also added an option to suppress the automatic decoding + * of gzipped streams. Also added experimental support for specifying a + * class loader when using the + * {@link #decodeToObject(String, int, ClassLoader)} + * method.
  • + *
  • v2.3.3 - Changed default char encoding to US-ASCII which reduces the internal Java + * footprint with its CharEncoders and so forth. Fixed some javadocs that were + * inconsistent. Removed imports and specified things like java.io.IOException + * explicitly inline.
  • + *
  • v2.3.2 - Reduced memory footprint! Finally refined the "guessing" of how big the + * final encoded data will be so that the code doesn't have to create two output + * arrays: an oversized initial one and then a final, exact-sized one. Big win + * when using the {@link #encodeBytesToBytes(byte[])} family of methods (and not + * using the gzip options which uses a different mechanism with streams and stuff).
  • + *
  • v2.3.1 - Added {@link #encodeBytesToBytes(byte[], int, int, int)} and some + * similar helper methods to be more efficient with memory by not returning a + * String but just a byte array.
  • + *
  • v2.3 - This is not a drop-in replacement! This is two years of comments + * and bug fixes queued up and finally executed. Thanks to everyone who sent + * me stuff, and I'm sorry I wasn't able to distribute your fixes to everyone else. + * Much bad coding was cleaned up including throwing exceptions where necessary + * instead of returning null values or something similar. Here are some changes + * that may affect you: + *
      + *
    • Does not break lines, by default. This is to keep in compliance with + * RFC3548.
    • + *
    • Throws exceptions instead of returning null values. Because some operations + * (especially those that may permit the GZIP option) use IO streams, there + * is a possiblity of an java.io.IOException being thrown. After some discussion and + * thought, I've changed the behavior of the methods to throw java.io.IOExceptions + * rather than return null if ever there's an error. I think this is more + * appropriate, though it will require some changes to your code. Sorry, + * it should have been done this way to begin with.
    • + *
    • Removed all references to System.out, System.err, and the like. + * Shame on me. All I can say is sorry they were ever there.
    • + *
    • Throws NullPointerExceptions and IllegalArgumentExceptions as needed + * such as when passed arrays are null or offsets are invalid.
    • + *
    • Cleaned up as much javadoc as I could to avoid any javadoc warnings. + * This was especially annoying before for people who were thorough in their + * own projects and then had gobs of javadoc warnings on this file.
    • + *
    + *
  • v2.2.1 - Fixed bug using URL_SAFE and ORDERED encodings. Fixed bug + * when using very small files (~< 40 bytes).
  • + *
  • v2.2 - Added some helper methods for encoding/decoding directly from + * one file to the next. Also added a main() method to support command line + * encoding/decoding from one file to the next. Also added these Base64 dialects: + *
      + *
    1. The default is RFC3548 format.
    2. + *
    3. Calling Base64.setFormat(Base64.BASE64_FORMAT.URLSAFE_FORMAT) generates + * URL and file name friendly format as described in Section 4 of RFC3548. + * http://www.faqs.org/rfcs/rfc3548.html
    4. + *
    5. Calling Base64.setFormat(Base64.BASE64_FORMAT.ORDERED_FORMAT) generates + * URL and file name friendly format that preserves lexical ordering as described + * in http://www.faqs.org/qa/rfcc-1940.html
    6. + *
    + * Special thanks to Jim Kellerman at http://www.powerset.com/ + * for contributing the new Base64 dialects. + *
  • + * + *
  • v2.1 - Cleaned up javadoc comments and unused variables and methods. Added + * some convenience methods for reading and writing to and from files.
  • + *
  • v2.0.2 - Now specifies UTF-8 encoding in places where the code fails on systems + * with other encodings (like EBCDIC).
  • + *
  • v2.0.1 - Fixed an error when decoding a single byte, that is, when the + * encoded data was a single byte.
  • + *
  • v2.0 - I got rid of methods that used booleans to set options. + * Now everything is more consolidated and cleaner. The code now detects + * when data that's being decoded is gzip-compressed and will decompress it + * automatically. Generally things are cleaner. You'll probably have to + * change some method calls that you were making to support the new + * options format (ints that you "OR" together).
  • + *
  • v1.5.1 - Fixed bug when decompressing and decoding to a + * byte[] using decode( String s, boolean gzipCompressed ). + * Added the ability to "suspend" encoding in the Output Stream so + * you can turn on and off the encoding if you need to embed base64 + * data in an otherwise "normal" stream (like an XML file).
  • + *
  • v1.5 - Output stream pases on flush() command but doesn't do anything itself. + * This helps when using GZIP streams. + * Added the ability to GZip-compress objects before encoding them.
  • + *
  • v1.4 - Added helper methods to read/write files.
  • + *
  • v1.3.6 - Fixed OutputStream.flush() so that 'position' is reset.
  • + *
  • v1.3.5 - Added flag to turn on and off line breaks. Fixed bug in input stream + * where last buffer being read, if not completely full, was not returned.
  • + *
  • v1.3.4 - Fixed when "improperly padded stream" error was thrown at the wrong time.
  • + *
  • v1.3.3 - Fixed I/O streams which were totally messed up.
  • + *
+ * + *

+ * I am placing this code in the Public Domain. Do with it as you will. + * This software comes with no guarantees or warranties but with + * plenty of well-wishing instead! + * Please visit http://iharder.net/base64 + * periodically to check for updates or to contribute improvements. + *

+ * + * @author Robert Harder + * @author rob@iharder.net + * @version 2.3.7 + */ +public class Base64Ext +{ + +/* ******** P U B L I C F I E L D S ******** */ + + + /** No options specified. Value is zero. */ + public final static int NO_OPTIONS = 0; + + /** Specify encoding in first bit. Value is one. */ + public final static int ENCODE = 1; + + + /** Specify decoding in first bit. Value is zero. */ + public final static int DECODE = 0; + + + /** Specify that data should be gzip-compressed in second bit. Value is two. */ + public final static int GZIP = 2; + + /** Specify that gzipped data should not be automatically gunzipped. */ + public final static int DONT_GUNZIP = 4; + + + /** Do break lines when encoding. Value is 8. */ + public final static int DO_BREAK_LINES = 8; + + /** + * Encode using Base64-like encoding that is URL- and Filename-safe as described + * in Section 4 of RFC3548: + * http://www.faqs.org/rfcs/rfc3548.html. + * It is important to note that data encoded this way is not officially valid Base64, + * or at the very least should not be called Base64 without also specifying that is + * was encoded using the URL- and Filename-safe dialect. + */ + public final static int URL_SAFE = 16; + + + /** + * Encode using the special "ordered" dialect of Base64 described here: + * http://www.faqs.org/qa/rfcc-1940.html. + */ + public final static int ORDERED = 32; + + +/* ******** P R I V A T E F I E L D S ******** */ + + + /** Maximum line length (76) of Base64 output. */ + private final static int MAX_LINE_LENGTH = 76; + + + /** The equals sign (=) as a byte. */ + private final static byte EQUALS_SIGN = (byte)'='; + + + /** The new line character (\n) as a byte. */ + private final static byte NEW_LINE = (byte)'\n'; + + + /** Preferred encoding. */ + private final static String PREFERRED_ENCODING = "US-ASCII"; + + + private final static byte WHITE_SPACE_ENC = -5; // Indicates white space in encoding + private final static byte EQUALS_SIGN_ENC = -1; // Indicates equals sign in encoding + + +/* ******** S T A N D A R D B A S E 6 4 A L P H A B E T ******** */ + + /** The 64 valid Base64 values. */ + /* Host platform me be something funny like EBCDIC, so we hardcode these values. */ + private final static byte[] _STANDARD_ALPHABET = { + (byte)'A', (byte)'B', (byte)'C', (byte)'D', (byte)'E', (byte)'F', (byte)'G', + (byte)'H', (byte)'I', (byte)'J', (byte)'K', (byte)'L', (byte)'M', (byte)'N', + (byte)'O', (byte)'P', (byte)'Q', (byte)'R', (byte)'S', (byte)'T', (byte)'U', + (byte)'V', (byte)'W', (byte)'X', (byte)'Y', (byte)'Z', + (byte)'a', (byte)'b', (byte)'c', (byte)'d', (byte)'e', (byte)'f', (byte)'g', + (byte)'h', (byte)'i', (byte)'j', (byte)'k', (byte)'l', (byte)'m', (byte)'n', + (byte)'o', (byte)'p', (byte)'q', (byte)'r', (byte)'s', (byte)'t', (byte)'u', + (byte)'v', (byte)'w', (byte)'x', (byte)'y', (byte)'z', + (byte)'0', (byte)'1', (byte)'2', (byte)'3', (byte)'4', (byte)'5', + (byte)'6', (byte)'7', (byte)'8', (byte)'9', (byte)'+', (byte)'/' + }; + + + /** + * Translates a Base64 value to either its 6-bit reconstruction value + * or a negative number indicating some other meaning. + **/ + private final static byte[] _STANDARD_DECODABET = { + -9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 0 - 8 + -5,-5, // Whitespace: Tab and Linefeed + -9,-9, // Decimal 11 - 12 + -5, // Whitespace: Carriage Return + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 14 - 26 + -9,-9,-9,-9,-9, // Decimal 27 - 31 + -5, // Whitespace: Space + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 33 - 42 + 62, // Plus sign at decimal 43 + -9,-9,-9, // Decimal 44 - 46 + 63, // Slash at decimal 47 + 52,53,54,55,56,57,58,59,60,61, // Numbers zero through nine + -9,-9,-9, // Decimal 58 - 60 + -1, // Equals sign at decimal 61 + -9,-9,-9, // Decimal 62 - 64 + 0,1,2,3,4,5,6,7,8,9,10,11,12,13, // Letters 'A' through 'N' + 14,15,16,17,18,19,20,21,22,23,24,25, // Letters 'O' through 'Z' + -9,-9,-9,-9,-9,-9, // Decimal 91 - 96 + 26,27,28,29,30,31,32,33,34,35,36,37,38, // Letters 'a' through 'm' + 39,40,41,42,43,44,45,46,47,48,49,50,51, // Letters 'n' through 'z' + -9,-9,-9,-9,-9 // Decimal 123 - 127 + ,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 128 - 139 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 140 - 152 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 153 - 165 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 166 - 178 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 179 - 191 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 192 - 204 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 205 - 217 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 218 - 230 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 231 - 243 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9 // Decimal 244 - 255 + }; + + +/* ******** U R L S A F E B A S E 6 4 A L P H A B E T ******** */ + + /** + * Used in the URL- and Filename-safe dialect described in Section 4 of RFC3548: + * http://www.faqs.org/rfcs/rfc3548.html. + * Notice that the last two bytes become "hyphen" and "underscore" instead of "plus" and "slash." + */ + private final static byte[] _URL_SAFE_ALPHABET = { + (byte)'A', (byte)'B', (byte)'C', (byte)'D', (byte)'E', (byte)'F', (byte)'G', + (byte)'H', (byte)'I', (byte)'J', (byte)'K', (byte)'L', (byte)'M', (byte)'N', + (byte)'O', (byte)'P', (byte)'Q', (byte)'R', (byte)'S', (byte)'T', (byte)'U', + (byte)'V', (byte)'W', (byte)'X', (byte)'Y', (byte)'Z', + (byte)'a', (byte)'b', (byte)'c', (byte)'d', (byte)'e', (byte)'f', (byte)'g', + (byte)'h', (byte)'i', (byte)'j', (byte)'k', (byte)'l', (byte)'m', (byte)'n', + (byte)'o', (byte)'p', (byte)'q', (byte)'r', (byte)'s', (byte)'t', (byte)'u', + (byte)'v', (byte)'w', (byte)'x', (byte)'y', (byte)'z', + (byte)'0', (byte)'1', (byte)'2', (byte)'3', (byte)'4', (byte)'5', + (byte)'6', (byte)'7', (byte)'8', (byte)'9', (byte)'-', (byte)'_' + }; + + /** + * Used in decoding URL- and Filename-safe dialects of Base64. + */ + private final static byte[] _URL_SAFE_DECODABET = { + -9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 0 - 8 + -5,-5, // Whitespace: Tab and Linefeed + -9,-9, // Decimal 11 - 12 + -5, // Whitespace: Carriage Return + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 14 - 26 + -9,-9,-9,-9,-9, // Decimal 27 - 31 + -5, // Whitespace: Space + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 33 - 42 + -9, // Plus sign at decimal 43 + -9, // Decimal 44 + 62, // Minus sign at decimal 45 + -9, // Decimal 46 + -9, // Slash at decimal 47 + 52,53,54,55,56,57,58,59,60,61, // Numbers zero through nine + -9,-9,-9, // Decimal 58 - 60 + -1, // Equals sign at decimal 61 + -9,-9,-9, // Decimal 62 - 64 + 0,1,2,3,4,5,6,7,8,9,10,11,12,13, // Letters 'A' through 'N' + 14,15,16,17,18,19,20,21,22,23,24,25, // Letters 'O' through 'Z' + -9,-9,-9,-9, // Decimal 91 - 94 + 63, // Underscore at decimal 95 + -9, // Decimal 96 + 26,27,28,29,30,31,32,33,34,35,36,37,38, // Letters 'a' through 'm' + 39,40,41,42,43,44,45,46,47,48,49,50,51, // Letters 'n' through 'z' + -9,-9,-9,-9,-9 // Decimal 123 - 127 + ,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 128 - 139 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 140 - 152 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 153 - 165 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 166 - 178 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 179 - 191 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 192 - 204 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 205 - 217 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 218 - 230 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 231 - 243 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9 // Decimal 244 - 255 + }; + + + +/* ******** O R D E R E D B A S E 6 4 A L P H A B E T ******** */ + + /** + * I don't get the point of this technique, but someone requested it, + * and it is described here: + * http://www.faqs.org/qa/rfcc-1940.html. + */ + private final static byte[] _ORDERED_ALPHABET = { + (byte)'-', + (byte)'0', (byte)'1', (byte)'2', (byte)'3', (byte)'4', + (byte)'5', (byte)'6', (byte)'7', (byte)'8', (byte)'9', + (byte)'A', (byte)'B', (byte)'C', (byte)'D', (byte)'E', (byte)'F', (byte)'G', + (byte)'H', (byte)'I', (byte)'J', (byte)'K', (byte)'L', (byte)'M', (byte)'N', + (byte)'O', (byte)'P', (byte)'Q', (byte)'R', (byte)'S', (byte)'T', (byte)'U', + (byte)'V', (byte)'W', (byte)'X', (byte)'Y', (byte)'Z', + (byte)'_', + (byte)'a', (byte)'b', (byte)'c', (byte)'d', (byte)'e', (byte)'f', (byte)'g', + (byte)'h', (byte)'i', (byte)'j', (byte)'k', (byte)'l', (byte)'m', (byte)'n', + (byte)'o', (byte)'p', (byte)'q', (byte)'r', (byte)'s', (byte)'t', (byte)'u', + (byte)'v', (byte)'w', (byte)'x', (byte)'y', (byte)'z' + }; + + /** + * Used in decoding the "ordered" dialect of Base64. + */ + private final static byte[] _ORDERED_DECODABET = { + -9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 0 - 8 + -5,-5, // Whitespace: Tab and Linefeed + -9,-9, // Decimal 11 - 12 + -5, // Whitespace: Carriage Return + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 14 - 26 + -9,-9,-9,-9,-9, // Decimal 27 - 31 + -5, // Whitespace: Space + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 33 - 42 + -9, // Plus sign at decimal 43 + -9, // Decimal 44 + 0, // Minus sign at decimal 45 + -9, // Decimal 46 + -9, // Slash at decimal 47 + 1,2,3,4,5,6,7,8,9,10, // Numbers zero through nine + -9,-9,-9, // Decimal 58 - 60 + -1, // Equals sign at decimal 61 + -9,-9,-9, // Decimal 62 - 64 + 11,12,13,14,15,16,17,18,19,20,21,22,23, // Letters 'A' through 'M' + 24,25,26,27,28,29,30,31,32,33,34,35,36, // Letters 'N' through 'Z' + -9,-9,-9,-9, // Decimal 91 - 94 + 37, // Underscore at decimal 95 + -9, // Decimal 96 + 38,39,40,41,42,43,44,45,46,47,48,49,50, // Letters 'a' through 'm' + 51,52,53,54,55,56,57,58,59,60,61,62,63, // Letters 'n' through 'z' + -9,-9,-9,-9,-9 // Decimal 123 - 127 + ,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 128 - 139 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 140 - 152 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 153 - 165 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 166 - 178 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 179 - 191 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 192 - 204 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 205 - 217 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 218 - 230 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 231 - 243 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9 // Decimal 244 - 255 + }; + + +/* ******** D E T E R M I N E W H I C H A L H A B E T ******** */ + + + /** + * Returns one of the _SOMETHING_ALPHABET byte arrays depending on + * the options specified. + * It's possible, though silly, to specify ORDERED and URLSAFE + * in which case one of them will be picked, though there is + * no guarantee as to which one will be picked. + */ + private final static byte[] getAlphabet( int options ) { + if ((options & URL_SAFE) == URL_SAFE) { + return _URL_SAFE_ALPHABET; + } else if ((options & ORDERED) == ORDERED) { + return _ORDERED_ALPHABET; + } else { + return _STANDARD_ALPHABET; + } + } // end getAlphabet + + + /** + * Returns one of the _SOMETHING_DECODABET byte arrays depending on + * the options specified. + * It's possible, though silly, to specify ORDERED and URL_SAFE + * in which case one of them will be picked, though there is + * no guarantee as to which one will be picked. + */ + private final static byte[] getDecodabet( int options ) { + if( (options & URL_SAFE) == URL_SAFE) { + return _URL_SAFE_DECODABET; + } else if ((options & ORDERED) == ORDERED) { + return _ORDERED_DECODABET; + } else { + return _STANDARD_DECODABET; + } + } // end getAlphabet + + + + /** Defeats instantiation. */ + private Base64Ext(){} + + + + +/* ******** E N C O D I N G M E T H O D S ******** */ + + + /** + * Encodes up to the first three bytes of array threeBytes + * and returns a four-byte array in Base64 notation. + * The actual number of significant bytes in your array is + * given by numSigBytes. + * The array threeBytes needs only be as big as + * numSigBytes. + * Code can reuse a byte array by passing a four-byte array as b4. + * + * @param b4 A reusable byte array to reduce array instantiation + * @param threeBytes the array to convert + * @param numSigBytes the number of significant bytes in your array + * @return four byte array in Base64 notation. + * @since 1.5.1 + */ + private static byte[] encode3to4( byte[] b4, byte[] threeBytes, int numSigBytes, int options ) { + encode3to4( threeBytes, 0, numSigBytes, b4, 0, options ); + return b4; + } // end encode3to4 + + + /** + *

Encodes up to three bytes of the array source + * and writes the resulting four Base64 bytes to destination. + * The source and destination arrays can be manipulated + * anywhere along their length by specifying + * srcOffset and destOffset. + * This method does not check to make sure your arrays + * are large enough to accomodate srcOffset + 3 for + * the source array or destOffset + 4 for + * the destination array. + * The actual number of significant bytes in your array is + * given by numSigBytes.

+ *

This is the lowest level of the encoding methods with + * all possible parameters.

+ * + * @param source the array to convert + * @param srcOffset the index where conversion begins + * @param numSigBytes the number of significant bytes in your array + * @param destination the array to hold the conversion + * @param destOffset the index where output will be put + * @return the destination array + * @since 1.3 + */ + private static byte[] encode3to4( + byte[] source, int srcOffset, int numSigBytes, + byte[] destination, int destOffset, int options ) { + + byte[] ALPHABET = getAlphabet( options ); + + // 1 2 3 + // 01234567890123456789012345678901 Bit position + // --------000000001111111122222222 Array position from threeBytes + // --------| || || || | Six bit groups to index ALPHABET + // >>18 >>12 >> 6 >> 0 Right shift necessary + // 0x3f 0x3f 0x3f Additional AND + + // Create buffer with zero-padding if there are only one or two + // significant bytes passed in the array. + // We have to shift left 24 in order to flush out the 1's that appear + // when Java treats a value as negative that is cast from a byte to an int. + int inBuff = ( numSigBytes > 0 ? ((source[ srcOffset ] << 24) >>> 8) : 0 ) + | ( numSigBytes > 1 ? ((source[ srcOffset + 1 ] << 24) >>> 16) : 0 ) + | ( numSigBytes > 2 ? ((source[ srcOffset + 2 ] << 24) >>> 24) : 0 ); + + switch( numSigBytes ) + { + case 3: + destination[ destOffset ] = ALPHABET[ (inBuff >>> 18) ]; + destination[ destOffset + 1 ] = ALPHABET[ (inBuff >>> 12) & 0x3f ]; + destination[ destOffset + 2 ] = ALPHABET[ (inBuff >>> 6) & 0x3f ]; + destination[ destOffset + 3 ] = ALPHABET[ (inBuff ) & 0x3f ]; + return destination; + + case 2: + destination[ destOffset ] = ALPHABET[ (inBuff >>> 18) ]; + destination[ destOffset + 1 ] = ALPHABET[ (inBuff >>> 12) & 0x3f ]; + destination[ destOffset + 2 ] = ALPHABET[ (inBuff >>> 6) & 0x3f ]; + destination[ destOffset + 3 ] = EQUALS_SIGN; + return destination; + + case 1: + destination[ destOffset ] = ALPHABET[ (inBuff >>> 18) ]; + destination[ destOffset + 1 ] = ALPHABET[ (inBuff >>> 12) & 0x3f ]; + destination[ destOffset + 2 ] = EQUALS_SIGN; + destination[ destOffset + 3 ] = EQUALS_SIGN; + return destination; + + default: + return destination; + } // end switch + } // end encode3to4 + + + + /** + * Performs Base64 encoding on the raw ByteBuffer, + * writing it to the encoded ByteBuffer. + * This is an experimental feature. Currently it does not + * pass along any options (such as {@link #DO_BREAK_LINES} + * or {@link #GZIP}. + * + * @param raw input buffer + * @param encoded output buffer + * @since 2.3 + */ + public static void encode( java.nio.ByteBuffer raw, java.nio.ByteBuffer encoded ){ + byte[] raw3 = new byte[3]; + byte[] enc4 = new byte[4]; + + while( raw.hasRemaining() ){ + int rem = Math.min(3,raw.remaining()); + raw.get(raw3,0,rem); + Base64Ext.encode3to4(enc4, raw3, rem, Base64Ext.NO_OPTIONS ); + encoded.put(enc4); + } // end input remaining + } + + + /** + * Performs Base64 encoding on the raw ByteBuffer, + * writing it to the encoded CharBuffer. + * This is an experimental feature. Currently it does not + * pass along any options (such as {@link #DO_BREAK_LINES} + * or {@link #GZIP}. + * + * @param raw input buffer + * @param encoded output buffer + * @since 2.3 + */ + public static void encode( java.nio.ByteBuffer raw, java.nio.CharBuffer encoded ){ + byte[] raw3 = new byte[3]; + byte[] enc4 = new byte[4]; + + while( raw.hasRemaining() ){ + int rem = Math.min(3,raw.remaining()); + raw.get(raw3,0,rem); + Base64Ext.encode3to4(enc4, raw3, rem, Base64Ext.NO_OPTIONS ); + for( int i = 0; i < 4; i++ ){ + encoded.put( (char)(enc4[i] & 0xFF) ); + } + } // end input remaining + } + + + + + /** + * Serializes an object and returns the Base64-encoded + * version of that serialized object. + * + *

As of v 2.3, if the object + * cannot be serialized or there is another error, + * the method will throw an java.io.IOException. This is new to v2.3! + * In earlier versions, it just returned a null value, but + * in retrospect that's a pretty poor way to handle it.

+ * + * The object is not GZip-compressed before being encoded. + * + * @param serializableObject The object to encode + * @return The Base64-encoded object + * @throws java.io.IOException if there is an error + * @throws NullPointerException if serializedObject is null + * @since 1.4 + */ + public static String encodeObject( java.io.Serializable serializableObject ) + throws java.io.IOException { + return encodeObject( serializableObject, NO_OPTIONS ); + } // end encodeObject + + + + /** + * Serializes an object and returns the Base64-encoded + * version of that serialized object. + * + *

As of v 2.3, if the object + * cannot be serialized or there is another error, + * the method will throw an java.io.IOException. This is new to v2.3! + * In earlier versions, it just returned a null value, but + * in retrospect that's a pretty poor way to handle it.

+ * + * The object is not GZip-compressed before being encoded. + *

+ * Example options:

+     *   GZIP: gzip-compresses object before encoding it.
+     *   DO_BREAK_LINES: break lines at 76 characters
+     * 
+ *

+ * Example: encodeObject( myObj, Base64.GZIP ) or + *

+ * Example: encodeObject( myObj, Base64.GZIP | Base64.DO_BREAK_LINES ) + * + * @param serializableObject The object to encode + * @param options Specified options + * @return The Base64-encoded object + * @see Base64Ext#GZIP + * @see Base64Ext#DO_BREAK_LINES + * @throws java.io.IOException if there is an error + * @since 2.0 + */ + public static String encodeObject( java.io.Serializable serializableObject, int options ) + throws java.io.IOException { + + if( serializableObject == null ){ + throw new NullPointerException( "Cannot serialize a null object." ); + } // end if: null + + // Streams + java.io.ByteArrayOutputStream baos = null; + java.io.OutputStream b64os = null; + java.util.zip.GZIPOutputStream gzos = null; + java.io.ObjectOutputStream oos = null; + + + try { + // ObjectOutputStream -> (GZIP) -> Base64 -> ByteArrayOutputStream + baos = new java.io.ByteArrayOutputStream(); + b64os = new OutputStream( baos, ENCODE | options ); + if( (options & GZIP) != 0 ){ + // Gzip + gzos = new java.util.zip.GZIPOutputStream(b64os); + oos = new java.io.ObjectOutputStream( gzos ); + } else { + // Not gzipped + oos = new java.io.ObjectOutputStream( b64os ); + } + oos.writeObject( serializableObject ); + } // end try + catch( java.io.IOException e ) { + // Catch it and then throw it immediately so that + // the finally{} block is called for cleanup. + throw e; + } // end catch + finally { + try{ oos.close(); } catch( Exception e ){} + try{ gzos.close(); } catch( Exception e ){} + try{ b64os.close(); } catch( Exception e ){} + try{ baos.close(); } catch( Exception e ){} + } // end finally + + // Return value according to relevant encoding. + try { + return new String( baos.toByteArray(), PREFERRED_ENCODING ); + } // end try + catch (java.io.UnsupportedEncodingException uue){ + // Fall back to some Java default + return new String( baos.toByteArray() ); + } // end catch + + } // end encode + + + + /** + * Encodes a byte array into Base64 notation. + * Does not GZip-compress data. + * + * @param source The data to convert + * @return The data in Base64-encoded form + * @throws NullPointerException if source array is null + * @since 1.4 + */ + public static String encodeBytes( byte[] source ) { + // Since we're not going to have the GZIP encoding turned on, + // we're not going to have an java.io.IOException thrown, so + // we should not force the user to have to catch it. + String encoded = null; + try { + encoded = encodeBytes(source, 0, source.length, NO_OPTIONS); + } catch (java.io.IOException ex) { + assert false : ex.getMessage(); + } // end catch + assert encoded != null; + return encoded; + } // end encodeBytes + + + + /** + * Encodes a byte array into Base64 notation. + *

+ * Example options:

+     *   GZIP: gzip-compresses object before encoding it.
+     *   DO_BREAK_LINES: break lines at 76 characters
+     *     Note: Technically, this makes your encoding non-compliant.
+     * 
+ *

+ * Example: encodeBytes( myData, Base64.GZIP ) or + *

+ * Example: encodeBytes( myData, Base64.GZIP | Base64.DO_BREAK_LINES ) + * + * + *

As of v 2.3, if there is an error with the GZIP stream, + * the method will throw an java.io.IOException. This is new to v2.3! + * In earlier versions, it just returned a null value, but + * in retrospect that's a pretty poor way to handle it.

+ * + * + * @param source The data to convert + * @param options Specified options + * @return The Base64-encoded data as a String + * @see Base64Ext#GZIP + * @see Base64Ext#DO_BREAK_LINES + * @throws java.io.IOException if there is an error + * @throws NullPointerException if source array is null + * @since 2.0 + */ + public static String encodeBytes( byte[] source, int options ) throws java.io.IOException { + return encodeBytes( source, 0, source.length, options ); + } // end encodeBytes + + + /** + * Encodes a byte array into Base64 notation. + * Does not GZip-compress data. + * + *

As of v 2.3, if there is an error, + * the method will throw an java.io.IOException. This is new to v2.3! + * In earlier versions, it just returned a null value, but + * in retrospect that's a pretty poor way to handle it.

+ * + * + * @param source The data to convert + * @param off Offset in array where conversion should begin + * @param len Length of data to convert + * @return The Base64-encoded data as a String + * @throws NullPointerException if source array is null + * @throws IllegalArgumentException if source array, offset, or length are invalid + * @since 1.4 + */ + public static String encodeBytes( byte[] source, int off, int len ) { + // Since we're not going to have the GZIP encoding turned on, + // we're not going to have an java.io.IOException thrown, so + // we should not force the user to have to catch it. + String encoded = null; + try { + encoded = encodeBytes( source, off, len, NO_OPTIONS ); + } catch (java.io.IOException ex) { + assert false : ex.getMessage(); + } // end catch + assert encoded != null; + return encoded; + } // end encodeBytes + + + + /** + * Encodes a byte array into Base64 notation. + *

+ * Example options:

+     *   GZIP: gzip-compresses object before encoding it.
+     *   DO_BREAK_LINES: break lines at 76 characters
+     *     Note: Technically, this makes your encoding non-compliant.
+     * 
+ *

+ * Example: encodeBytes( myData, Base64.GZIP ) or + *

+ * Example: encodeBytes( myData, Base64.GZIP | Base64.DO_BREAK_LINES ) + * + * + *

As of v 2.3, if there is an error with the GZIP stream, + * the method will throw an java.io.IOException. This is new to v2.3! + * In earlier versions, it just returned a null value, but + * in retrospect that's a pretty poor way to handle it.

+ * + * + * @param source The data to convert + * @param off Offset in array where conversion should begin + * @param len Length of data to convert + * @param options Specified options + * @return The Base64-encoded data as a String + * @see Base64Ext#GZIP + * @see Base64Ext#DO_BREAK_LINES + * @throws java.io.IOException if there is an error + * @throws NullPointerException if source array is null + * @throws IllegalArgumentException if source array, offset, or length are invalid + * @since 2.0 + */ + public static String encodeBytes( byte[] source, int off, int len, int options ) throws java.io.IOException { + byte[] encoded = encodeBytesToBytes( source, off, len, options ); + + // Return value according to relevant encoding. + try { + return new String( encoded, PREFERRED_ENCODING ); + } // end try + catch (java.io.UnsupportedEncodingException uue) { + return new String( encoded ); + } // end catch + + } // end encodeBytes + + + + + /** + * Similar to {@link #encodeBytes(byte[])} but returns + * a byte array instead of instantiating a String. This is more efficient + * if you're working with I/O streams and have large data sets to encode. + * + * + * @param source The data to convert + * @return The Base64-encoded data as a byte[] (of ASCII characters) + * @throws NullPointerException if source array is null + * @since 2.3.1 + */ + public static byte[] encodeBytesToBytes( byte[] source ) { + byte[] encoded = null; + try { + encoded = encodeBytesToBytes( source, 0, source.length, Base64Ext.NO_OPTIONS ); + } catch( java.io.IOException ex ) { + assert false : "IOExceptions only come from GZipping, which is turned off: " + ex.getMessage(); + } + return encoded; + } + + + /** + * Similar to {@link #encodeBytes(byte[], int, int, int)} but returns + * a byte array instead of instantiating a String. This is more efficient + * if you're working with I/O streams and have large data sets to encode. + * + * + * @param source The data to convert + * @param off Offset in array where conversion should begin + * @param len Length of data to convert + * @param options Specified options + * @return The Base64-encoded data as a String + * @see Base64Ext#GZIP + * @see Base64Ext#DO_BREAK_LINES + * @throws java.io.IOException if there is an error + * @throws NullPointerException if source array is null + * @throws IllegalArgumentException if source array, offset, or length are invalid + * @since 2.3.1 + */ + public static byte[] encodeBytesToBytes( byte[] source, int off, int len, int options ) throws java.io.IOException { + + if( source == null ){ + throw new NullPointerException( "Cannot serialize a null array." ); + } // end if: null + + if( off < 0 ){ + throw new IllegalArgumentException( "Cannot have negative offset: " + off ); + } // end if: off < 0 + + if( len < 0 ){ + throw new IllegalArgumentException( "Cannot have length offset: " + len ); + } // end if: len < 0 + + if( off + len > source.length ){ + throw new IllegalArgumentException( + String.format( "Cannot have offset of %d and length of %d with array of length %d", off,len,source.length)); + } // end if: off < 0 + + + + // Compress? + if( (options & GZIP) != 0 ) { + java.io.ByteArrayOutputStream baos = null; + java.util.zip.GZIPOutputStream gzos = null; + OutputStream b64os = null; + + try { + // GZip -> Base64 -> ByteArray + baos = new java.io.ByteArrayOutputStream(); + b64os = new OutputStream( baos, ENCODE | options ); + gzos = new java.util.zip.GZIPOutputStream( b64os ); + + gzos.write( source, off, len ); + gzos.close(); + } // end try + catch( java.io.IOException e ) { + // Catch it and then throw it immediately so that + // the finally{} block is called for cleanup. + throw e; + } // end catch + finally { + try{ gzos.close(); } catch( Exception e ){} + try{ b64os.close(); } catch( Exception e ){} + try{ baos.close(); } catch( Exception e ){} + } // end finally + + return baos.toByteArray(); + } // end if: compress + + // Else, don't compress. Better not to use streams at all then. + else { + boolean breakLines = (options & DO_BREAK_LINES) != 0; + + //int len43 = len * 4 / 3; + //byte[] outBuff = new byte[ ( len43 ) // Main 4:3 + // + ( (len % 3) > 0 ? 4 : 0 ) // Account for padding + // + (breakLines ? ( len43 / MAX_LINE_LENGTH ) : 0) ]; // New lines + // Try to determine more precisely how big the array needs to be. + // If we get it right, we don't have to do an array copy, and + // we save a bunch of memory. + int encLen = ( len / 3 ) * 4 + ( len % 3 > 0 ? 4 : 0 ); // Bytes needed for actual encoding + if( breakLines ){ + encLen += encLen / MAX_LINE_LENGTH; // Plus extra newline characters + } + byte[] outBuff = new byte[ encLen ]; + + + int d = 0; + int e = 0; + int len2 = len - 2; + int lineLength = 0; + for( ; d < len2; d+=3, e+=4 ) { + encode3to4( source, d+off, 3, outBuff, e, options ); + + lineLength += 4; + if( breakLines && lineLength >= MAX_LINE_LENGTH ) + { + outBuff[e+4] = NEW_LINE; + e++; + lineLength = 0; + } // end if: end of line + } // en dfor: each piece of array + + if( d < len ) { + encode3to4( source, d+off, len - d, outBuff, e, options ); + e += 4; + } // end if: some padding needed + + + // Only resize array if we didn't guess it right. + if( e <= outBuff.length - 1 ){ + // If breaking lines and the last byte falls right at + // the line length (76 bytes per line), there will be + // one extra byte, and the array will need to be resized. + // Not too bad of an estimate on array size, I'd say. + byte[] finalOut = new byte[e]; + System.arraycopy(outBuff,0, finalOut,0,e); + //System.err.println("Having to resize array from " + outBuff.length + " to " + e ); + return finalOut; + } else { + //System.err.println("No need to resize array."); + return outBuff; + } + + } // end else: don't compress + + } // end encodeBytesToBytes + + + + + +/* ******** D E C O D I N G M E T H O D S ******** */ + + + /** + * Decodes four bytes from array source + * and writes the resulting bytes (up to three of them) + * to destination. + * The source and destination arrays can be manipulated + * anywhere along their length by specifying + * srcOffset and destOffset. + * This method does not check to make sure your arrays + * are large enough to accomodate srcOffset + 4 for + * the source array or destOffset + 3 for + * the destination array. + * This method returns the actual number of bytes that + * were converted from the Base64 encoding. + *

This is the lowest level of the decoding methods with + * all possible parameters.

+ * + * + * @param source the array to convert + * @param srcOffset the index where conversion begins + * @param destination the array to hold the conversion + * @param destOffset the index where output will be put + * @param options alphabet type is pulled from this (standard, url-safe, ordered) + * @return the number of decoded bytes converted + * @throws NullPointerException if source or destination arrays are null + * @throws IllegalArgumentException if srcOffset or destOffset are invalid + * or there is not enough room in the array. + * @since 1.3 + */ + private static int decode4to3( + byte[] source, int srcOffset, + byte[] destination, int destOffset, int options ) { + + // Lots of error checking and exception throwing + if( source == null ){ + throw new NullPointerException( "Source array was null." ); + } // end if + if( destination == null ){ + throw new NullPointerException( "Destination array was null." ); + } // end if + if( srcOffset < 0 || srcOffset + 3 >= source.length ){ + throw new IllegalArgumentException( String.format( + "Source array with length %d cannot have offset of %d and still process four bytes.", source.length, srcOffset ) ); + } // end if + if( destOffset < 0 || destOffset +2 >= destination.length ){ + throw new IllegalArgumentException( String.format( + "Destination array with length %d cannot have offset of %d and still store three bytes.", destination.length, destOffset ) ); + } // end if + + + byte[] DECODABET = getDecodabet( options ); + + // Example: Dk== + if( source[ srcOffset + 2] == EQUALS_SIGN ) { + // Two ways to do the same thing. Don't know which way I like best. + //int outBuff = ( ( DECODABET[ source[ srcOffset ] ] << 24 ) >>> 6 ) + // | ( ( DECODABET[ source[ srcOffset + 1] ] << 24 ) >>> 12 ); + int outBuff = ( ( DECODABET[ source[ srcOffset ] ] & 0xFF ) << 18 ) + | ( ( DECODABET[ source[ srcOffset + 1] ] & 0xFF ) << 12 ); + + destination[ destOffset ] = (byte)( outBuff >>> 16 ); + return 1; + } + + // Example: DkL= + else if( source[ srcOffset + 3 ] == EQUALS_SIGN ) { + // Two ways to do the same thing. Don't know which way I like best. + //int outBuff = ( ( DECODABET[ source[ srcOffset ] ] << 24 ) >>> 6 ) + // | ( ( DECODABET[ source[ srcOffset + 1 ] ] << 24 ) >>> 12 ) + // | ( ( DECODABET[ source[ srcOffset + 2 ] ] << 24 ) >>> 18 ); + int outBuff = ( ( DECODABET[ source[ srcOffset ] ] & 0xFF ) << 18 ) + | ( ( DECODABET[ source[ srcOffset + 1 ] ] & 0xFF ) << 12 ) + | ( ( DECODABET[ source[ srcOffset + 2 ] ] & 0xFF ) << 6 ); + + destination[ destOffset ] = (byte)( outBuff >>> 16 ); + destination[ destOffset + 1 ] = (byte)( outBuff >>> 8 ); + return 2; + } + + // Example: DkLE + else { + // Two ways to do the same thing. Don't know which way I like best. + //int outBuff = ( ( DECODABET[ source[ srcOffset ] ] << 24 ) >>> 6 ) + // | ( ( DECODABET[ source[ srcOffset + 1 ] ] << 24 ) >>> 12 ) + // | ( ( DECODABET[ source[ srcOffset + 2 ] ] << 24 ) >>> 18 ) + // | ( ( DECODABET[ source[ srcOffset + 3 ] ] << 24 ) >>> 24 ); + int outBuff = ( ( DECODABET[ source[ srcOffset ] ] & 0xFF ) << 18 ) + | ( ( DECODABET[ source[ srcOffset + 1 ] ] & 0xFF ) << 12 ) + | ( ( DECODABET[ source[ srcOffset + 2 ] ] & 0xFF ) << 6) + | ( ( DECODABET[ source[ srcOffset + 3 ] ] & 0xFF ) ); + + + destination[ destOffset ] = (byte)( outBuff >> 16 ); + destination[ destOffset + 1 ] = (byte)( outBuff >> 8 ); + destination[ destOffset + 2 ] = (byte)( outBuff ); + + return 3; + } + } // end decodeToBytes + + + + + + /** + * Low-level access to decoding ASCII characters in + * the form of a byte array. Ignores GUNZIP option, if + * it's set. This is not generally a recommended method, + * although it is used internally as part of the decoding process. + * Special case: if len = 0, an empty array is returned. Still, + * if you need more speed and reduced memory footprint (and aren't + * gzipping), consider this method. + * + * @param source The Base64 encoded data + * @return decoded data + * @since 2.3.1 + */ + public static byte[] decode( byte[] source ) + throws java.io.IOException { + byte[] decoded = null; +// try { + decoded = decode( source, 0, source.length, Base64Ext.NO_OPTIONS ); +// } catch( java.io.IOException ex ) { +// assert false : "IOExceptions only come from GZipping, which is turned off: " + ex.getMessage(); +// } + return decoded; + } + + + + /** + * Low-level access to decoding ASCII characters in + * the form of a byte array. Ignores GUNZIP option, if + * it's set. This is not generally a recommended method, + * although it is used internally as part of the decoding process. + * Special case: if len = 0, an empty array is returned. Still, + * if you need more speed and reduced memory footprint (and aren't + * gzipping), consider this method. + * + * @param source The Base64 encoded data + * @param off The offset of where to begin decoding + * @param len The length of characters to decode + * @param options Can specify options such as alphabet type to use + * @return decoded data + * @throws java.io.IOException If bogus characters exist in source data + * @since 1.3 + */ + public static byte[] decode( byte[] source, int off, int len, int options ) + throws java.io.IOException { + + // Lots of error checking and exception throwing + if( source == null ){ + throw new NullPointerException( "Cannot decode null source array." ); + } // end if + if( off < 0 || off + len > source.length ){ + throw new IllegalArgumentException( String.format( + "Source array with length %d cannot have offset of %d and process %d bytes.", source.length, off, len ) ); + } // end if + + if( len == 0 ){ + return new byte[0]; + }else if( len < 4 ){ + throw new IllegalArgumentException( + "Base64-encoded string must have at least four characters, but length specified was " + len ); + } // end if + + byte[] DECODABET = getDecodabet( options ); + + int len34 = len * 3 / 4; // Estimate on array size + byte[] outBuff = new byte[ len34 ]; // Upper limit on size of output + int outBuffPosn = 0; // Keep track of where we're writing + + byte[] b4 = new byte[4]; // Four byte buffer from source, eliminating white space + int b4Posn = 0; // Keep track of four byte input buffer + int i = 0; // Source array counter + byte sbiDecode = 0; // Special value from DECODABET + + for( i = off; i < off+len; i++ ) { // Loop through source + + sbiDecode = DECODABET[ source[i]&0xFF ]; + + // White space, Equals sign, or legit Base64 character + // Note the values such as -5 and -9 in the + // DECODABETs at the top of the file. + if( sbiDecode >= WHITE_SPACE_ENC ) { + if( sbiDecode >= EQUALS_SIGN_ENC ) { + b4[ b4Posn++ ] = source[i]; // Save non-whitespace + if( b4Posn > 3 ) { // Time to decode? + outBuffPosn += decode4to3( b4, 0, outBuff, outBuffPosn, options ); + b4Posn = 0; + + // If that was the equals sign, break out of 'for' loop + if( source[i] == EQUALS_SIGN ) { + break; + } // end if: equals sign + } // end if: quartet built + } // end if: equals sign or better + } // end if: white space, equals sign or better + else { + // There's a bad input character in the Base64 stream. + throw new java.io.IOException( String.format( + "Bad Base64 input character decimal %d in array position %d", ((int)source[i])&0xFF, i ) ); + } // end else: + } // each input character + + byte[] out = new byte[ outBuffPosn ]; + System.arraycopy( outBuff, 0, out, 0, outBuffPosn ); + return out; + } // end decode + + + + + /** + * Decodes data from Base64 notation, automatically + * detecting gzip-compressed data and decompressing it. + * + * @param s the string to decode + * @return the decoded data + * @throws java.io.IOException If there is a problem + * @since 1.4 + */ + public static byte[] decode( String s ) throws java.io.IOException { + return decode( s, NO_OPTIONS ); + } + + + + /** + * Decodes data from Base64 notation, automatically + * detecting gzip-compressed data and decompressing it. + * + * @param s the string to decode + * @param options encode options such as URL_SAFE + * @return the decoded data + * @throws java.io.IOException if there is an error + * @throws NullPointerException if s is null + * @since 1.4 + */ + public static byte[] decode( String s, int options ) throws java.io.IOException { + + if( s == null ){ + throw new NullPointerException( "Input string was null." ); + } // end if + + byte[] bytes; + try { + bytes = s.getBytes( PREFERRED_ENCODING ); + } // end try + catch( java.io.UnsupportedEncodingException uee ) { + bytes = s.getBytes(); + } // end catch + // + + // Decode + bytes = decode( bytes, 0, bytes.length, options ); + + // Check to see if it's gzip-compressed + // GZIP Magic Two-Byte Number: 0x8b1f (35615) + boolean dontGunzip = (options & DONT_GUNZIP) != 0; + if( (bytes != null) && (bytes.length >= 4) && (!dontGunzip) ) { + + int head = ((int)bytes[0] & 0xff) | ((bytes[1] << 8) & 0xff00); + if( java.util.zip.GZIPInputStream.GZIP_MAGIC == head ) { + java.io.ByteArrayInputStream bais = null; + java.util.zip.GZIPInputStream gzis = null; + java.io.ByteArrayOutputStream baos = null; + byte[] buffer = new byte[2048]; + int length = 0; + + try { + baos = new java.io.ByteArrayOutputStream(); + bais = new java.io.ByteArrayInputStream( bytes ); + gzis = new java.util.zip.GZIPInputStream( bais ); + + while( ( length = gzis.read( buffer ) ) >= 0 ) { + baos.write(buffer,0,length); + } // end while: reading input + + // No error? Get new bytes. + bytes = baos.toByteArray(); + + } // end try + catch( java.io.IOException e ) { + e.printStackTrace(); + // Just return originally-decoded bytes + } // end catch + finally { + try{ baos.close(); } catch( Exception e ){} + try{ gzis.close(); } catch( Exception e ){} + try{ bais.close(); } catch( Exception e ){} + } // end finally + + } // end if: gzipped + } // end if: bytes.length >= 2 + + return bytes; + } // end decode + + + + /** + * Attempts to decode Base64 data and deserialize a Java + * Object within. Returns null if there was an error. + * + * @param encodedObject The Base64 data to decode + * @return The decoded and deserialized object + * @throws NullPointerException if encodedObject is null + * @throws java.io.IOException if there is a general error + * @throws ClassNotFoundException if the decoded object is of a + * class that cannot be found by the JVM + * @since 1.5 + */ + public static Object decodeToObject( String encodedObject ) + throws java.io.IOException, ClassNotFoundException { + return decodeToObject(encodedObject,NO_OPTIONS,null); + } + + + /** + * Attempts to decode Base64 data and deserialize a Java + * Object within. Returns null if there was an error. + * If loader is not null, it will be the class loader + * used when deserializing. + * + * @param encodedObject The Base64 data to decode + * @param options Various parameters related to decoding + * @param loader Optional class loader to use in deserializing classes. + * @return The decoded and deserialized object + * @throws NullPointerException if encodedObject is null + * @throws java.io.IOException if there is a general error + * @throws ClassNotFoundException if the decoded object is of a + * class that cannot be found by the JVM + * @since 2.3.4 + */ + public static Object decodeToObject( + String encodedObject, int options, final ClassLoader loader ) + throws java.io.IOException, ClassNotFoundException { + + // Decode and gunzip if necessary + byte[] objBytes = decode( encodedObject, options ); + + java.io.ByteArrayInputStream bais = null; + java.io.ObjectInputStream ois = null; + Object obj = null; + + try { + bais = new java.io.ByteArrayInputStream( objBytes ); + + // If no custom class loader is provided, use Java's builtin OIS. + if( loader == null ){ + ois = new java.io.ObjectInputStream( bais ); + } // end if: no loader provided + + // Else make a customized object input stream that uses + // the provided class loader. + else { + ois = new java.io.ObjectInputStream(bais){ + @Override + public Class resolveClass(java.io.ObjectStreamClass streamClass) + throws java.io.IOException, ClassNotFoundException { + Class c = Class.forName(streamClass.getName(), false, loader); + if( c == null ){ + return super.resolveClass(streamClass); + } else { + return c; // Class loader knows of this class. + } // end else: not null + } // end resolveClass + }; // end ois + } // end else: no custom class loader + + obj = ois.readObject(); + } // end try + catch( java.io.IOException e ) { + throw e; // Catch and throw in order to execute finally{} + } // end catch + catch( ClassNotFoundException e ) { + throw e; // Catch and throw in order to execute finally{} + } // end catch + finally { + try{ bais.close(); } catch( Exception e ){} + try{ ois.close(); } catch( Exception e ){} + } // end finally + + return obj; + } // end decodeObject + + + + /** + * Convenience method for encoding data to a file. + * + *

As of v 2.3, if there is a error, + * the method will throw an java.io.IOException. This is new to v2.3! + * In earlier versions, it just returned false, but + * in retrospect that's a pretty poor way to handle it.

+ * + * @param dataToEncode byte array of data to encode in base64 form + * @param filename Filename for saving encoded data + * @throws java.io.IOException if there is an error + * @throws NullPointerException if dataToEncode is null + * @since 2.1 + */ + public static void encodeToFile( byte[] dataToEncode, String filename ) + throws java.io.IOException { + + if( dataToEncode == null ){ + throw new NullPointerException( "Data to encode was null." ); + } // end iff + + OutputStream bos = null; + try { + bos = new OutputStream( + new java.io.FileOutputStream( filename ), Base64Ext.ENCODE ); + bos.write( dataToEncode ); + } // end try + catch( java.io.IOException e ) { + throw e; // Catch and throw to execute finally{} block + } // end catch: java.io.IOException + finally { + try{ bos.close(); } catch( Exception e ){} + } // end finally + + } // end encodeToFile + + + /** + * Convenience method for decoding data to a file. + * + *

As of v 2.3, if there is a error, + * the method will throw an java.io.IOException. This is new to v2.3! + * In earlier versions, it just returned false, but + * in retrospect that's a pretty poor way to handle it.

+ * + * @param dataToDecode Base64-encoded data as a string + * @param filename Filename for saving decoded data + * @throws java.io.IOException if there is an error + * @since 2.1 + */ + public static void decodeToFile( String dataToDecode, String filename ) + throws java.io.IOException { + + OutputStream bos = null; + try{ + bos = new OutputStream( + new java.io.FileOutputStream( filename ), Base64Ext.DECODE ); + bos.write( dataToDecode.getBytes( PREFERRED_ENCODING ) ); + } // end try + catch( java.io.IOException e ) { + throw e; // Catch and throw to execute finally{} block + } // end catch: java.io.IOException + finally { + try{ bos.close(); } catch( Exception e ){} + } // end finally + + } // end decodeToFile + + + + + /** + * Convenience method for reading a base64-encoded + * file and decoding it. + * + *

As of v 2.3, if there is a error, + * the method will throw an java.io.IOException. This is new to v2.3! + * In earlier versions, it just returned false, but + * in retrospect that's a pretty poor way to handle it.

+ * + * @param filename Filename for reading encoded data + * @return decoded byte array + * @throws java.io.IOException if there is an error + * @since 2.1 + */ + public static byte[] decodeFromFile( String filename ) + throws java.io.IOException { + + byte[] decodedData = null; + InputStream bis = null; + try + { + // Set up some useful variables + java.io.File file = new java.io.File( filename ); + byte[] buffer = null; + int length = 0; + int numBytes = 0; + + // Check for size of file + if( file.length() > Integer.MAX_VALUE ) + { + throw new java.io.IOException( "File is too big for this convenience method (" + file.length() + " bytes)." ); + } // end if: file too big for int index + buffer = new byte[ (int)file.length() ]; + + // Open a stream + bis = new InputStream( + new java.io.BufferedInputStream( + new java.io.FileInputStream( file ) ), Base64Ext.DECODE ); + + // Read until done + while( ( numBytes = bis.read( buffer, length, 4096 ) ) >= 0 ) { + length += numBytes; + } // end while + + // Save in a variable to return + decodedData = new byte[ length ]; + System.arraycopy( buffer, 0, decodedData, 0, length ); + + } // end try + catch( java.io.IOException e ) { + throw e; // Catch and release to execute finally{} + } // end catch: java.io.IOException + finally { + try{ bis.close(); } catch( Exception e) {} + } // end finally + + return decodedData; + } // end decodeFromFile + + + + /** + * Convenience method for reading a binary file + * and base64-encoding it. + * + *

As of v 2.3, if there is a error, + * the method will throw an java.io.IOException. This is new to v2.3! + * In earlier versions, it just returned false, but + * in retrospect that's a pretty poor way to handle it.

+ * + * @param filename Filename for reading binary data + * @return base64-encoded string + * @throws java.io.IOException if there is an error + * @since 2.1 + */ + public static String encodeFromFile( String filename ) + throws java.io.IOException { + + String encodedData = null; + InputStream bis = null; + try + { + // Set up some useful variables + java.io.File file = new java.io.File( filename ); + byte[] buffer = new byte[ Math.max((int)(file.length() * 1.4+1),40) ]; // Need max() for math on small files (v2.2.1); Need +1 for a few corner cases (v2.3.5) + int length = 0; + int numBytes = 0; + + // Open a stream + bis = new InputStream( + new java.io.BufferedInputStream( + new java.io.FileInputStream( file ) ), Base64Ext.ENCODE ); + + // Read until done + while( ( numBytes = bis.read( buffer, length, 4096 ) ) >= 0 ) { + length += numBytes; + } // end while + + // Save in a variable to return + encodedData = new String( buffer, 0, length, Base64Ext.PREFERRED_ENCODING ); + + } // end try + catch( java.io.IOException e ) { + throw e; // Catch and release to execute finally{} + } // end catch: java.io.IOException + finally { + try{ bis.close(); } catch( Exception e) {} + } // end finally + + return encodedData; + } // end encodeFromFile + + /** + * Reads infile and encodes it to outfile. + * + * @param infile Input file + * @param outfile Output file + * @throws java.io.IOException if there is an error + * @since 2.2 + */ + public static void encodeFileToFile( String infile, String outfile ) + throws java.io.IOException { + + String encoded = Base64Ext.encodeFromFile( infile ); + java.io.OutputStream out = null; + try{ + out = new java.io.BufferedOutputStream( + new java.io.FileOutputStream( outfile ) ); + out.write( encoded.getBytes("US-ASCII") ); // Strict, 7-bit output. + } // end try + catch( java.io.IOException e ) { + throw e; // Catch and release to execute finally{} + } // end catch + finally { + try { out.close(); } + catch( Exception ex ){} + } // end finally + } // end encodeFileToFile + + + /** + * Reads infile and decodes it to outfile. + * + * @param infile Input file + * @param outfile Output file + * @throws java.io.IOException if there is an error + * @since 2.2 + */ + public static void decodeFileToFile( String infile, String outfile ) + throws java.io.IOException { + + byte[] decoded = Base64Ext.decodeFromFile( infile ); + java.io.OutputStream out = null; + try{ + out = new java.io.BufferedOutputStream( + new java.io.FileOutputStream( outfile ) ); + out.write( decoded ); + } // end try + catch( java.io.IOException e ) { + throw e; // Catch and release to execute finally{} + } // end catch + finally { + try { out.close(); } + catch( Exception ex ){} + } // end finally + } // end decodeFileToFile + + + /* ******** I N N E R C L A S S I N P U T S T R E A M ******** */ + + + + /** + * A {@link InputStream} will read data from another + * java.io.InputStream, given in the constructor, + * and encode/decode to/from Base64 notation on the fly. + * + * @see Base64Ext + * @since 1.3 + */ + public static class InputStream extends java.io.FilterInputStream { + + private boolean encode; // Encoding or decoding + private int position; // Current position in the buffer + private byte[] buffer; // Small buffer holding converted data + private int bufferLength; // Length of buffer (3 or 4) + private int numSigBytes; // Number of meaningful bytes in the buffer + private int lineLength; + private boolean breakLines; // Break lines at less than 80 characters + private int options; // Record options used to create the stream. + private byte[] decodabet; // Local copies to avoid extra method calls + + + /** + * Constructs a {@link InputStream} in DECODE mode. + * + * @param in the java.io.InputStream from which to read data. + * @since 1.3 + */ + public InputStream( java.io.InputStream in ) { + this( in, DECODE ); + } // end constructor + + + /** + * Constructs a {@link InputStream} in + * either ENCODE or DECODE mode. + *

+ * Valid options:

+         *   ENCODE or DECODE: Encode or Decode as data is read.
+         *   DO_BREAK_LINES: break lines at 76 characters
+         *     (only meaningful when encoding)
+         * 
+ *

+ * Example: new Base64.InputStream( in, Base64.DECODE ) + * + * + * @param in the java.io.InputStream from which to read data. + * @param options Specified options + * @see Base64Ext#ENCODE + * @see Base64Ext#DECODE + * @see Base64Ext#DO_BREAK_LINES + * @since 2.0 + */ + public InputStream( java.io.InputStream in, int options ) { + + super( in ); + this.options = options; // Record for later + this.breakLines = (options & DO_BREAK_LINES) > 0; + this.encode = (options & ENCODE) > 0; + this.bufferLength = encode ? 4 : 3; + this.buffer = new byte[ bufferLength ]; + this.position = -1; + this.lineLength = 0; + this.decodabet = getDecodabet(options); + } // end constructor + + /** + * Reads enough of the input stream to convert + * to/from Base64 and returns the next byte. + * + * @return next byte + * @since 1.3 + */ + @Override + public int read() throws java.io.IOException { + + // Do we need to get data? + if( position < 0 ) { + if( encode ) { + byte[] b3 = new byte[3]; + int numBinaryBytes = 0; + for( int i = 0; i < 3; i++ ) { + int b = in.read(); + + // If end of stream, b is -1. + if( b >= 0 ) { + b3[i] = (byte)b; + numBinaryBytes++; + } else { + break; // out of for loop + } // end else: end of stream + + } // end for: each needed input byte + + if( numBinaryBytes > 0 ) { + encode3to4( b3, 0, numBinaryBytes, buffer, 0, options ); + position = 0; + numSigBytes = 4; + } // end if: got data + else { + return -1; // Must be end of stream + } // end else + } // end if: encoding + + // Else decoding + else { + byte[] b4 = new byte[4]; + int i = 0; + for( i = 0; i < 4; i++ ) { + // Read four "meaningful" bytes: + int b = 0; + do{ b = in.read(); } + while( b >= 0 && decodabet[ b & 0x7f ] <= WHITE_SPACE_ENC ); + + if( b < 0 ) { + break; // Reads a -1 if end of stream + } // end if: end of stream + + b4[i] = (byte)b; + } // end for: each needed input byte + + if( i == 4 ) { + numSigBytes = decode4to3( b4, 0, buffer, 0, options ); + position = 0; + } // end if: got four characters + else if( i == 0 ){ + return -1; + } // end else if: also padded correctly + else { + // Must have broken out from above. + throw new java.io.IOException( "Improperly padded Base64 input." ); + } // end + + } // end else: decode + } // end else: get data + + // Got data? + if( position >= 0 ) { + // End of relevant data? + if( /*!encode &&*/ position >= numSigBytes ){ + return -1; + } // end if: got data + + if( encode && breakLines && lineLength >= MAX_LINE_LENGTH ) { + lineLength = 0; + return '\n'; + } // end if + else { + lineLength++; // This isn't important when decoding + // but throwing an extra "if" seems + // just as wasteful. + + int b = buffer[ position++ ]; + + if( position >= bufferLength ) { + position = -1; + } // end if: end + + return b & 0xFF; // This is how you "cast" a byte that's + // intended to be unsigned. + } // end else + } // end if: position >= 0 + + // Else error + else { + throw new java.io.IOException( "Error in Base64 code reading stream." ); + } // end else + } // end read + + + /** + * Calls {@link #read()} repeatedly until the end of stream + * is reached or len bytes are read. + * Returns number of bytes read into array or -1 if + * end of stream is encountered. + * + * @param dest array to hold values + * @param off offset for array + * @param len max number of bytes to read into array + * @return bytes read into array or -1 if end of stream is encountered. + * @since 1.3 + */ + @Override + public int read( byte[] dest, int off, int len ) + throws java.io.IOException { + int i; + int b; + for( i = 0; i < len; i++ ) { + b = read(); + + if( b >= 0 ) { + dest[off + i] = (byte) b; + } + else if( i == 0 ) { + return -1; + } + else { + break; // Out of 'for' loop + } // Out of 'for' loop + } // end for: each byte read + return i; + } // end read + + } // end inner class InputStream + + + + + + + /* ******** I N N E R C L A S S O U T P U T S T R E A M ******** */ + + + + /** + * A {@link OutputStream} will write data to another + * java.io.OutputStream, given in the constructor, + * and encode/decode to/from Base64 notation on the fly. + * + * @see Base64Ext + * @since 1.3 + */ + public static class OutputStream extends java.io.FilterOutputStream { + + private boolean encode; + private int position; + private byte[] buffer; + private int bufferLength; + private int lineLength; + private boolean breakLines; + private byte[] b4; // Scratch used in a few places + private boolean suspendEncoding; + private int options; // Record for later + private byte[] decodabet; // Local copies to avoid extra method calls + + /** + * Constructs a {@link OutputStream} in ENCODE mode. + * + * @param out the java.io.OutputStream to which data will be written. + * @since 1.3 + */ + public OutputStream( java.io.OutputStream out ) { + this( out, ENCODE ); + } // end constructor + + + /** + * Constructs a {@link OutputStream} in + * either ENCODE or DECODE mode. + *

+ * Valid options:

+         *   ENCODE or DECODE: Encode or Decode as data is read.
+         *   DO_BREAK_LINES: don't break lines at 76 characters
+         *     (only meaningful when encoding)
+         * 
+ *

+ * Example: new Base64.OutputStream( out, Base64.ENCODE ) + * + * @param out the java.io.OutputStream to which data will be written. + * @param options Specified options. + * @see Base64Ext#ENCODE + * @see Base64Ext#DECODE + * @see Base64Ext#DO_BREAK_LINES + * @since 1.3 + */ + public OutputStream( java.io.OutputStream out, int options ) { + super( out ); + this.breakLines = (options & DO_BREAK_LINES) != 0; + this.encode = (options & ENCODE) != 0; + this.bufferLength = encode ? 3 : 4; + this.buffer = new byte[ bufferLength ]; + this.position = 0; + this.lineLength = 0; + this.suspendEncoding = false; + this.b4 = new byte[4]; + this.options = options; + this.decodabet = getDecodabet(options); + } // end constructor + + + /** + * Writes the byte to the output stream after + * converting to/from Base64 notation. + * When encoding, bytes are buffered three + * at a time before the output stream actually + * gets a write() call. + * When decoding, bytes are buffered four + * at a time. + * + * @param theByte the byte to write + * @since 1.3 + */ + @Override + public void write(int theByte) + throws java.io.IOException { + // Encoding suspended? + if( suspendEncoding ) { + this.out.write( theByte ); + return; + } // end if: supsended + + // Encode? + if( encode ) { + buffer[ position++ ] = (byte)theByte; + if( position >= bufferLength ) { // Enough to encode. + + this.out.write( encode3to4( b4, buffer, bufferLength, options ) ); + + lineLength += 4; + if( breakLines && lineLength >= MAX_LINE_LENGTH ) { + this.out.write( NEW_LINE ); + lineLength = 0; + } // end if: end of line + + position = 0; + } // end if: enough to output + } // end if: encoding + + // Else, Decoding + else { + // Meaningful Base64 character? + if( decodabet[ theByte & 0x7f ] > WHITE_SPACE_ENC ) { + buffer[ position++ ] = (byte)theByte; + if( position >= bufferLength ) { // Enough to output. + + int len = Base64Ext.decode4to3( buffer, 0, b4, 0, options ); + out.write( b4, 0, len ); + position = 0; + } // end if: enough to output + } // end if: meaningful base64 character + else if( decodabet[ theByte & 0x7f ] != WHITE_SPACE_ENC ) { + throw new java.io.IOException( "Invalid character in Base64 data." ); + } // end else: not white space either + } // end else: decoding + } // end write + + + + /** + * Calls {@link #write(int)} repeatedly until len + * bytes are written. + * + * @param theBytes array from which to read bytes + * @param off offset for array + * @param len max number of bytes to read into array + * @since 1.3 + */ + @Override + public void write( byte[] theBytes, int off, int len ) + throws java.io.IOException { + // Encoding suspended? + if( suspendEncoding ) { + this.out.write( theBytes, off, len ); + return; + } // end if: supsended + + for( int i = 0; i < len; i++ ) { + write( theBytes[ off + i ] ); + } // end for: each byte written + + } // end write + + + + /** + * Method added by PHIL. [Thanks, PHIL. -Rob] + * This pads the buffer without closing the stream. + * @throws java.io.IOException if there's an error. + */ + public void flushBase64() throws java.io.IOException { + if( position > 0 ) { + if( encode ) { + out.write( encode3to4( b4, buffer, position, options ) ); + position = 0; + } // end if: encoding + else { + throw new java.io.IOException( "Base64 input not properly padded." ); + } // end else: decoding + } // end if: buffer partially full + + } // end flush + + + /** + * Flushes and closes (I think, in the superclass) the stream. + * + * @since 1.3 + */ + @Override + public void close() throws java.io.IOException { + // 1. Ensure that pending characters are written + flushBase64(); + + // 2. Actually close the stream + // Base class both flushes and closes. + super.close(); + + buffer = null; + out = null; + } // end close + + + + /** + * Suspends encoding of the stream. + * May be helpful if you need to embed a piece of + * base64-encoded data in a stream. + * + * @throws java.io.IOException if there's an error flushing + * @since 1.5.1 + */ + public void suspendEncoding() throws java.io.IOException { + flushBase64(); + this.suspendEncoding = true; + } // end suspendEncoding + + + /** + * Resumes encoding of the stream. + * May be helpful if you need to embed a piece of + * base64-encoded data in a stream. + * + * @since 1.5.1 + */ + public void resumeEncoding() { + this.suspendEncoding = false; + } // end resumeEncoding + + + + } // end inner class OutputStream + + +} // end class Base64 diff --git a/app/src/main/java/org/coepi/android/worker/cenfetcher/ContactsFetchManager.kt b/app/src/main/java/org/coepi/android/worker/tcnfetcher/ContactsFetchManager.kt similarity index 83% rename from app/src/main/java/org/coepi/android/worker/cenfetcher/ContactsFetchManager.kt rename to app/src/main/java/org/coepi/android/worker/tcnfetcher/ContactsFetchManager.kt index 335af9cc..f414fe79 100644 --- a/app/src/main/java/org/coepi/android/worker/cenfetcher/ContactsFetchManager.kt +++ b/app/src/main/java/org/coepi/android/worker/tcnfetcher/ContactsFetchManager.kt @@ -1,4 +1,4 @@ -package org.coepi.android.worker.cenfetcher +package org.coepi.android.worker.tcnfetcher import android.content.Context import androidx.work.Constraints @@ -11,11 +11,10 @@ import java.util.concurrent.TimeUnit.MINUTES class ContactsFetchManager(context: Context) { - val workName = "contacts_fetch_worker" - init { val workManager = WorkManager.getInstance(context) - workManager.enqueueUniquePeriodicWork(workName, REPLACE, createWorkerRequest()) + workManager.enqueueUniquePeriodicWork("tcns_fetch_worker", REPLACE, + createWorkerRequest()) } private fun createWorkerRequest(): PeriodicWorkRequest { diff --git a/app/src/main/java/org/coepi/android/worker/cenfetcher/ContactsFetchWorker.kt b/app/src/main/java/org/coepi/android/worker/tcnfetcher/ContactsFetchWorker.kt similarity index 55% rename from app/src/main/java/org/coepi/android/worker/cenfetcher/ContactsFetchWorker.kt rename to app/src/main/java/org/coepi/android/worker/tcnfetcher/ContactsFetchWorker.kt index 541e27f0..1a7d787b 100644 --- a/app/src/main/java/org/coepi/android/worker/cenfetcher/ContactsFetchWorker.kt +++ b/app/src/main/java/org/coepi/android/worker/tcnfetcher/ContactsFetchWorker.kt @@ -1,12 +1,11 @@ -package org.coepi.android.worker.cenfetcher +package org.coepi.android.worker.tcnfetcher import android.content.Context import androidx.work.CoroutineWorker import androidx.work.ListenableWorker.Result.success import androidx.work.WorkerParameters -import org.coepi.android.cen.CenReportDao -import org.coepi.android.repo.CoEpiRepo -import org.coepi.android.system.log.LogTag.CEN_MATCHING +import org.coepi.android.repo.reportsupdate.ReportsUpdater +import org.coepi.android.system.log.LogTag.TCN_MATCHING import org.coepi.android.system.log.log import org.koin.core.KoinComponent import org.koin.core.inject @@ -16,12 +15,12 @@ class ContactsFetchWorker( workerParams: WorkerParameters ) : CoroutineWorker(appContext, workerParams), KoinComponent { - private val coEpiRepo: CoEpiRepo by inject() + private val reportsUpdater: ReportsUpdater by inject() override suspend fun doWork(): Result { - log.d("Contacts fetch worker started.", CEN_MATCHING) - coEpiRepo.requestUpdateReports() - log.d("Contacts fetch worker finished.", CEN_MATCHING) + log.d("Contacts fetch worker started.", TCN_MATCHING) + reportsUpdater.requestUpdateReports() + log.d("Contacts fetch worker finished.", TCN_MATCHING) return success() } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3bad7969..d5607445 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -22,18 +22,6 @@ Symptom Report API - - CEN Protocol for Privacy-Preserving Distributed Contact Tracing - Settings - Computing New CEN... - GET CENKeys - POST Report - - Apps use secret CENKeys to broadcast 128-bit CENs using BLE - CEN Apps records neighbor CENs in close proximity to the user - CENKeys retrieved from servers power discovery of contact tracing: - Users share reports with revealed CENKeys to reduce spread of disease - What symptoms do you have today? Select all that apply diff --git a/app/src/test/java/android/util/Log.kt b/app/src/test/java/android/util/Log.kt new file mode 100644 index 00000000..c22f011c --- /dev/null +++ b/app/src/test/java/android/util/Log.kt @@ -0,0 +1,36 @@ +@file:JvmName("Log") + +/** + * Mocking Android log https://stackoverflow.com/questions/36787449/how-to-mock-method-e-in-log + * Using this exceptionally for the log, as it's inconvenient to inject it everywhere. + */ +package android.util + +fun v(tag: String, msg: String): Int { + println("VERBOSE: $msg") + return 0 +} + +fun d(tag: String, msg: String): Int { + println("DEBUG: $msg") + return 0 +} + +fun i(tag: String, msg: String): Int { + println("INFO: $msg") + return 0 +} + +fun w(tag: String, msg: String): Int { + println("WARN: $msg") + return 0 +} + +fun e(tag: String, msg: String, t: Throwable): Int { + println("ERROR: $msg") + return 0 +} + +fun e(tag: String, msg: String): Int { + return 0 +} diff --git a/app/src/test/java/org/coepi/android/ExampleUnitTest.kt b/app/src/test/java/org/coepi/android/ExampleUnitTest.kt deleted file mode 100644 index a7a50e7e..00000000 --- a/app/src/test/java/org/coepi/android/ExampleUnitTest.kt +++ /dev/null @@ -1,17 +0,0 @@ -package org.coepi.android - -import org.junit.Test - -import org.junit.Assert.* - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -} diff --git a/app/src/test/java/org/coepi/android/ReportsUpdaterImplUnitTests.kt b/app/src/test/java/org/coepi/android/ReportsUpdaterImplUnitTests.kt new file mode 100644 index 00000000..6b68be12 --- /dev/null +++ b/app/src/test/java/org/coepi/android/ReportsUpdaterImplUnitTests.kt @@ -0,0 +1,190 @@ +package org.coepi.android + +import com.google.common.truth.Truth.assertThat +import org.coepi.android.common.isSuccess +import org.coepi.android.common.successOrNull +import org.coepi.android.components.PreferencesReturningObject +import org.coepi.android.components.TcnApiReturningReports +import org.coepi.android.components.TcnDaoReturningAll +import org.coepi.android.components.noop.NoOpNewAlertsNotificationShower +import org.coepi.android.components.noop.NoOpPreferences +import org.coepi.android.components.noop.NoOpTcnApi +import org.coepi.android.components.noop.NoOpTcnMatcher +import org.coepi.android.components.noop.NoOpTcnReportsDao +import org.coepi.android.components.noop.NoOptTcnDao +import org.coepi.android.domain.TcnMatcherImpl +import org.coepi.android.domain.UnixTime +import org.coepi.android.extensions.base64ToByteArray +import org.coepi.android.repo.reportsupdate.ReportsInterval +import org.coepi.android.repo.reportsupdate.ReportsUpdaterImpl +import org.coepi.android.tcn.ReceivedTcn +import org.coepi.android.tcn.Tcn +import org.junit.Test +import org.tcncoalition.tcnclient.crypto.SignedReport + +class ReportsUpdaterImplUnitTests { + // A fixed "now" for replicable tests + private val now: UnixTime = UnixTime.fromValue(1588779297) + + @Test + fun retrieves_reports() { + val apiReportStrings = listOf( + "rSqWpM3ZQm7hfQ3q2x2llnFHiNhyRrUQPKEtJ33VKQcwT7Ly6e4KGaj5ZzjWt0m4c0v5n/VH5HO9UXbPXvsQTgEAQQAALFVtMVdNbHBZU1hOSlJYaDJZek5OWjJJeVdXZFpXRUozV2xoU2NHUkhWVDA9jn0pZAeME6ZBRHJOlfIikyfS0Pjg6l0txhhz6hz4exTxv8ryA3/Z26OebSRwzRfRgLdWBfohaOwOcSaynKqVCg==", + "LbSUvv320gtY2qTZbumxno7KJ/BDWnHuHcUH0fNv144p+K1xbPt+YQuxFHzFfo71HoegSspNJLaAz93InuQHHQEAAQAACFJtVjJaWEk9iXj1FGy+r4cmNrS84AzHzx5wS0FZJXzXFFfvqwAogt6qjIe7+6CIJ8mrFrCen3nAVrQo3Bd1jsGe6UjybRUlAA==" + ) + + val reportsUpdater = ReportsUpdaterImpl( + NoOpTcnMatcher(), + TcnApiReturningReports(apiReportStrings), NoOptTcnDao(), + NoOpTcnReportsDao(), NoOpPreferences(), NoOpNewAlertsNotificationShower() + ) + + val interval = ReportsInterval(0, 20) + + val reportsResult = reportsUpdater.retrieveReports(interval) + + assertThat(reportsResult.isSuccess()) + + val chunk = reportsResult.successOrNull()!! + assertThat(chunk.interval).isEqualTo(interval) + assertThat(chunk.reports.size).isEqualTo(2) + assertThat(chunk.reports[0].signature).isEqualTo(toSignedReport(apiReportStrings[0])?.signature) + assertThat(chunk.reports[1].signature).isEqualTo(toSignedReport(apiReportStrings[1])?.signature) + } + + @Test + fun find_match() { + val reports = listOf( + "rSqWpM3ZQm7hfQ3q2x2llnFHiNhyRrUQPKEtJ33VKQcwT7Ly6e4KGaj5ZzjWt0m4c0v5n/VH5HO9UXbPXvsQTgEAQQAALFVtMVdNbHBZU1hOSlJYaDJZek5OWjJJeVdXZFpXRUozV2xoU2NHUkhWVDA9jn0pZAeME6ZBRHJOlfIikyfS0Pjg6l0txhhz6hz4exTxv8ryA3/Z26OebSRwzRfRgLdWBfohaOwOcSaynKqVCg==", + "LbSUvv320gtY2qTZbumxno7KJ/BDWnHuHcUH0fNv144p+K1xbPt+YQuxFHzFfo71HoegSspNJLaAz93InuQHHQEAAQAACFJtVjJaWEk9iXj1FGy+r4cmNrS84AzHzx5wS0FZJXzXFFfvqwAogt6qjIe7+6CIJ8mrFrCen3nAVrQo3Bd1jsGe6UjybRUlAA==" + ).map { + toSignedReport(it)!! + } + + val reportsUpdater = ReportsUpdaterImpl( + TcnMatcherImpl(), NoOpTcnApi(), + TcnDaoReturningAll( + // We have a TCN stored belonging to the first report + listOf(ReceivedTcn(reports.first().generateFirstTcn()!!, now)) + ), + NoOpTcnReportsDao(), NoOpPreferences(), NoOpNewAlertsNotificationShower() + ) + + val matches = reportsUpdater.findMatches(reports) + + assertThat(matches.count()).isEqualTo(1) + assertThat(matches[0].signature).isEqualTo(reports[0].signature) + } + + @Test + fun start_interval_contains_time_if_no_last_interval_stored() { + val reportsUpdater = ReportsUpdaterImpl( + NoOpTcnMatcher(), NoOpTcnApi(), NoOptTcnDao(), NoOpTcnReportsDao(), + PreferencesReturningObject(null), NoOpNewAlertsNotificationShower() + ) + val interval = reportsUpdater.determineStartInterval(now) + + assertThat(interval.contains(now)).isTrue() + } + + @Test + fun start_interval_uses_interval_after_stored() { + val storedInterval = ReportsInterval(1, 10) + + val reportsUpdater = ReportsUpdaterImpl( + NoOpTcnMatcher(), NoOpTcnApi(), NoOptTcnDao(), NoOpTcnReportsDao(), + PreferencesReturningObject( + storedInterval + ), NoOpNewAlertsNotificationShower() + ) + + val interval = reportsUpdater.determineStartInterval(now) + assertThat(interval).isEqualTo(storedInterval.next()) + } + + @Test + fun interval_sequence_is_empty_if_starts_same_time_as_until() { + val reportsUpdater = ReportsUpdaterImpl( + NoOpTcnMatcher(), NoOpTcnApi(), NoOptTcnDao(), NoOpTcnReportsDao(), NoOpPreferences(), + NoOpNewAlertsNotificationShower() + ) + + val from = ReportsInterval(0, 20) + val until = UnixTime.fromValue(0) + val intervals = reportsUpdater.generateIntervalsSequence(from, until).toList() + + // from doesn't start before until, so sequence is empty + assertThat(intervals.size).isEqualTo(0) + } + + @Test + fun interval_sequence_is_empty_if_starts_starts_before_until() { + val reportsUpdater = ReportsUpdaterImpl( + NoOpTcnMatcher(), NoOpTcnApi(), NoOptTcnDao(), NoOpTcnReportsDao(), NoOpPreferences(), + NoOpNewAlertsNotificationShower() + ) + + val from = ReportsInterval(20, 20) + val until = UnixTime.fromValue(10) + val intervals = reportsUpdater.generateIntervalsSequence(from, until).toList() + + // from doesn't start before until, so sequence is empty + assertThat(intervals.size).isEqualTo(0) + } + + @Test + fun interval_sequence_has_one_element_with_until() { + val reportsUpdater = ReportsUpdaterImpl( + NoOpTcnMatcher(), NoOpTcnApi(), NoOptTcnDao(), NoOpTcnReportsDao(), NoOpPreferences(), + NoOpNewAlertsNotificationShower() + ) + + val from = ReportsInterval(0, 20) + val until = UnixTime.fromValue(1) + val intervals = reportsUpdater.generateIntervalsSequence(from, until).toList() + + assertThat(intervals.size).isEqualTo(1) + } + + @Test + fun interval_sequence_has_two_intervals_including_until() { + val reportsUpdater = ReportsUpdaterImpl( + NoOpTcnMatcher(), NoOpTcnApi(), NoOptTcnDao(), NoOpTcnReportsDao(), NoOpPreferences(), + NoOpNewAlertsNotificationShower() + ) + + val from = ReportsInterval(0, 20) + val until = UnixTime.fromValue(21) + val intervals = reportsUpdater.generateIntervalsSequence(from, until).toList() + + assertThat(intervals.size).isEqualTo(2) + assertThat(intervals[0]).isEqualTo(from) + assertThat(intervals[1]).isEqualTo(ReportsInterval(1, 20)) + } + + @Test + fun interval_sequence_has_multiple_intervals_including_until() { + val reportsUpdater = ReportsUpdaterImpl( + NoOpTcnMatcher(), NoOpTcnApi(), NoOptTcnDao(), NoOpTcnReportsDao(), NoOpPreferences(), + NoOpNewAlertsNotificationShower() + ) + + val from = ReportsInterval(1234, 20) + val until = UnixTime.fromValue(12345678) + val intervals = reportsUpdater.generateIntervalsSequence(from, until).toList() + + assertThat(intervals.size).isEqualTo((until.value - from.start) / from.length + 1) + assertThat(intervals.first()).isEqualTo(from) + assertThat(intervals.last()).isEqualTo(ReportsInterval(until.value / from.length, + from.length)) + } + + // TODO centralize, maybe create Reports wrapper to hide the TCN lib implementation + private fun toSignedReport(reportString: String): SignedReport? = + reportString.base64ToByteArray()?.let { SignedReport.fromByteArray(it) } + + private fun SignedReport.generateFirstTcn(): Tcn? = + report.temporaryContactNumbers.let { + if (it.hasNext()) { it.next() } else { null } + }?.let { Tcn(it.bytes) } +} diff --git a/app/src/test/java/org/coepi/android/components/CallReturning.kt b/app/src/test/java/org/coepi/android/components/CallReturning.kt new file mode 100644 index 00000000..db673d44 --- /dev/null +++ b/app/src/test/java/org/coepi/android/components/CallReturning.kt @@ -0,0 +1,21 @@ +package org.coepi.android.components + +import okhttp3.Request +import okhttp3.Request.Builder +import org.coepi.android.components.noop.NoOpCall +import retrofit2.Call +import retrofit2.Callback +import retrofit2.Response + +class CallReturning(private val obj: T) : Call { + override fun enqueue(callback: Callback) {} + override fun isExecuted(): Boolean = false + override fun clone(): Call = + NoOpCall() + override fun isCanceled(): Boolean = false + override fun cancel() {} + override fun execute(): Response = + Response.success(obj) + override fun request(): Request = Builder() + .build() +} diff --git a/app/src/test/java/org/coepi/android/components/PreferencesReturningObject.kt b/app/src/test/java/org/coepi/android/components/PreferencesReturningObject.kt new file mode 100644 index 00000000..7332a202 --- /dev/null +++ b/app/src/test/java/org/coepi/android/components/PreferencesReturningObject.kt @@ -0,0 +1,17 @@ +package org.coepi.android.components + +import org.coepi.android.system.Preferences +import org.coepi.android.system.PreferencesKey + +class PreferencesReturningObject(private val obj: Any?): + Preferences { + override fun getString(key: PreferencesKey): String? = null + override fun putString(key: PreferencesKey, value: String?) {} + override fun getBoolean(key: PreferencesKey): Boolean = false + override fun putBoolean(key: PreferencesKey, value: Boolean?) {} + override fun getLong(key: PreferencesKey): Long? = null + override fun putLong(key: PreferencesKey, value: Long?) {} + override fun putObject(key: PreferencesKey, model: T?) {} + @Suppress("UNCHECKED_CAST") + override fun getObject(key: PreferencesKey, clazz: Class): T? = obj as T? +} diff --git a/app/src/test/java/org/coepi/android/components/TcnApiReturningReports.kt b/app/src/test/java/org/coepi/android/components/TcnApiReturningReports.kt new file mode 100644 index 00000000..b7e9b1ef --- /dev/null +++ b/app/src/test/java/org/coepi/android/components/TcnApiReturningReports.kt @@ -0,0 +1,16 @@ +package org.coepi.android.components + +import io.reactivex.Completable +import okhttp3.RequestBody +import org.coepi.android.api.TcnApi +import org.coepi.android.components.CallReturning +import retrofit2.Call + +class TcnApiReturningReports(private val reports: List) : + TcnApi { + override fun getReports(intervalNumber: Long, intervalLength: Long): Call> = + CallReturning(reports) + + override fun postReport(report: RequestBody): Completable = + Completable.complete() +} diff --git a/app/src/test/java/org/coepi/android/components/TcnDaoReturningAll.kt b/app/src/test/java/org/coepi/android/components/TcnDaoReturningAll.kt new file mode 100644 index 00000000..2f8d91ff --- /dev/null +++ b/app/src/test/java/org/coepi/android/components/TcnDaoReturningAll.kt @@ -0,0 +1,14 @@ +package org.coepi.android.components + +import org.coepi.android.domain.UnixTime +import org.coepi.android.tcn.ReceivedTcn +import org.coepi.android.tcn.Tcn +import org.coepi.android.tcn.TcnDao + +class TcnDaoReturningAll(private val tcns: List): + TcnDao { + override fun all(): List = tcns + override fun matchTcns(start: UnixTime, end: UnixTime, tcns: Array): List = emptyList() + override fun findTcn(tcn: Tcn): ReceivedTcn? = null + override fun insert(tcn: ReceivedTcn): Boolean = false +} diff --git a/app/src/test/java/org/coepi/android/components/noop/NoOpCall.kt b/app/src/test/java/org/coepi/android/components/noop/NoOpCall.kt new file mode 100644 index 00000000..532ba24c --- /dev/null +++ b/app/src/test/java/org/coepi/android/components/noop/NoOpCall.kt @@ -0,0 +1,20 @@ +package org.coepi.android.components.noop + +import okhttp3.Request +import okhttp3.Request.Builder +import retrofit2.Call +import retrofit2.Callback +import retrofit2.Response + +class NoOpCall : Call { + override fun enqueue(callback: Callback) {} + override fun isExecuted(): Boolean = false + override fun clone(): Call = + NoOpCall() + override fun isCanceled(): Boolean = false + override fun cancel() {} + override fun execute(): Response = + Response.success(null) + override fun request(): Request = Builder() + .build() +} diff --git a/app/src/test/java/org/coepi/android/components/noop/NoOpNewAlertsNotificationShower.kt b/app/src/test/java/org/coepi/android/components/noop/NoOpNewAlertsNotificationShower.kt new file mode 100644 index 00000000..27bf91c4 --- /dev/null +++ b/app/src/test/java/org/coepi/android/components/noop/NoOpNewAlertsNotificationShower.kt @@ -0,0 +1,7 @@ +package org.coepi.android.components.noop + +import org.coepi.android.repo.reportsupdate.NewAlertsNotificationShower + +class NoOpNewAlertsNotificationShower: NewAlertsNotificationShower { + override fun showNotification(newAlertsCount: Int) {} +} diff --git a/app/src/test/java/org/coepi/android/components/noop/NoOpPreferences.kt b/app/src/test/java/org/coepi/android/components/noop/NoOpPreferences.kt new file mode 100644 index 00000000..2077420e --- /dev/null +++ b/app/src/test/java/org/coepi/android/components/noop/NoOpPreferences.kt @@ -0,0 +1,15 @@ +package org.coepi.android.components.noop + +import org.coepi.android.system.Preferences +import org.coepi.android.system.PreferencesKey + +class NoOpPreferences: Preferences { + override fun getString(key: PreferencesKey): String? = null + override fun putString(key: PreferencesKey, value: String?) {} + override fun getBoolean(key: PreferencesKey): Boolean = false + override fun putBoolean(key: PreferencesKey, value: Boolean?) {} + override fun getLong(key: PreferencesKey): Long? = null + override fun putLong(key: PreferencesKey, value: Long?) {} + override fun putObject(key: PreferencesKey, model: T?) {} + override fun getObject(key: PreferencesKey, clazz: Class): T? = null +} diff --git a/app/src/test/java/org/coepi/android/components/noop/NoOpTcnApi.kt b/app/src/test/java/org/coepi/android/components/noop/NoOpTcnApi.kt new file mode 100644 index 00000000..7398518a --- /dev/null +++ b/app/src/test/java/org/coepi/android/components/noop/NoOpTcnApi.kt @@ -0,0 +1,13 @@ +package org.coepi.android.components.noop + +import io.reactivex.Completable +import okhttp3.RequestBody +import org.coepi.android.api.TcnApi +import retrofit2.Call + +class NoOpTcnApi : TcnApi { + override fun getReports(intervalNumber: Long, intervalLength: Long): Call> = + NoOpCall() + override fun postReport(report: RequestBody): Completable = + Completable.complete() +} diff --git a/app/src/test/java/org/coepi/android/components/noop/NoOpTcnMatcher.kt b/app/src/test/java/org/coepi/android/components/noop/NoOpTcnMatcher.kt new file mode 100644 index 00000000..c956894c --- /dev/null +++ b/app/src/test/java/org/coepi/android/components/noop/NoOpTcnMatcher.kt @@ -0,0 +1,10 @@ +package org.coepi.android.components.noop + +import org.coepi.android.domain.TcnMatcher +import org.coepi.android.tcn.Tcn +import org.tcncoalition.tcnclient.crypto.SignedReport + +class NoOpTcnMatcher : TcnMatcher { + override fun match(tcns: List, reports: List): List = + emptyList() +} diff --git a/app/src/test/java/org/coepi/android/components/noop/NoOpTcnReportsDao.kt b/app/src/test/java/org/coepi/android/components/noop/NoOpTcnReportsDao.kt new file mode 100644 index 00000000..bf2cd1bc --- /dev/null +++ b/app/src/test/java/org/coepi/android/components/noop/NoOpTcnReportsDao.kt @@ -0,0 +1,15 @@ +package org.coepi.android.components.noop + +import io.reactivex.Observable +import org.coepi.android.tcn.ReceivedTcnReport +import org.coepi.android.tcn.SymptomReport +import org.coepi.android.tcn.TcnReport +import org.coepi.android.tcn.TcnReportDao + +class NoOpTcnReportsDao: TcnReportDao { + override val reports: Observable> = + Observable.just(emptyList()) + override fun all(): List = emptyList() + override fun insert(report: TcnReport): Boolean = false + override fun delete(report: SymptomReport) {} +} diff --git a/app/src/test/java/org/coepi/android/components/noop/NoOptTcnDao.kt b/app/src/test/java/org/coepi/android/components/noop/NoOptTcnDao.kt new file mode 100644 index 00000000..5e2a80d5 --- /dev/null +++ b/app/src/test/java/org/coepi/android/components/noop/NoOptTcnDao.kt @@ -0,0 +1,20 @@ +package org.coepi.android.components.noop + +import org.coepi.android.domain.UnixTime +import org.coepi.android.tcn.ReceivedTcn +import org.coepi.android.tcn.Tcn +import org.coepi.android.tcn.TcnDao + +class NoOptTcnDao: TcnDao { + override fun all(): List = emptyList() + + override fun matchTcns( + start: UnixTime, + end: UnixTime, + tcns: Array + ): List = emptyList() + + override fun findTcn(tcn: Tcn): ReceivedTcn? = null + + override fun insert(tcn: ReceivedTcn): Boolean = false +}