COVIDSafe code from version 1.0.16

This commit is contained in:
covidsafe-support 2020-05-08 15:23:03 +10:00
commit b827cf3cce
341 changed files with 28036 additions and 0 deletions

20
LICENSE.md Normal file
View file

@ -0,0 +1,20 @@
# Terms and Conditions for access to COVIDSafe App code
By accessing the App Code I accept and agree to the following terms:
1. If I distribute the App Code to anyone else, I will ensure these terms are provided to them and are not deleted.
2. I agree to access the App Code for the purpose of obtaining information about the COVIDSafe App only.
3. I understand and agree that the App Code is provided on an as is where is basis, that the App Code may be updated over time, and that the DTA and the Commonwealth have no liability whatsoever in connection with my access to or use of the App Code.
4. I agree to stop all access and use of the App Code if requested by the DTA.
5. I will not use the App Code for any product development purposes.
6. I will promptly report to the DTA on any actual or potential security vulnerabilities I become aware of in respect of the COVIDSafe App.
7. I am responsible for any costs of third party claims associated with my access to the App Code, and must pay those claims on request.
8. I understand and agree that:
**a.** the DTA will collect information about me and my access to the App Code, and any feedback, comments, or other information that I post on GitHub in connection with the App Code (and I understand that this information may also be seen or accessed by other users of GitHub who have been given access to the App Code);
**b.** the DTA may use that information for the purposes of managing my access to the App Code, and to consider any feedback, comments or other information that I provide in relation to the App Code or the COVIDSafe App;
**c.** the DTA may disclose that information to other Commonwealth agencies and their contractors for the purposes of improving the App Code or the COVIDSafe App, or as required for public accountability and reporting purposes, but DTA will de-identify personal information before disclosure wherever reasonable and practicable (GitHub, a company based in the US, may also handle your personal information in accordance with the GitHub Terms and Conditions); and
**d.** further information about how DTA will handle personal information, and my rights to complain or access or correct my personal information, is available at DTA's Privacy Policy.

22
README.md Normal file
View file

@ -0,0 +1,22 @@
# COVIDSafe app
# [Terms and Conditions for access to COVIDSafe App code](https://github.com/AU-COVIDSafe/mobile-android/blob/master/LICENSE.md)
By accessing the App Code I accept and agree to the following terms:
1. If I distribute the App Code to anyone else, I will ensure these terms are provided to them and are not deleted.
2. I agree to access the App Code for the purpose of obtaining information about the COVIDSafe App only.
3. I understand and agree that the App Code is provided on an as is where is basis, that the App Code may be updated over time, and that the DTA and the Commonwealth have no liability whatsoever in connection with my access to or use of the App Code.
4. I agree to stop all access and use of the App Code if requested by the DTA.
5. I will not use the App Code for any product development purposes.
6. I will promptly report to the DTA on any actual or potential security vulnerabilities I become aware of in respect of the COVIDSafe App.
7. I am responsible for any costs of third party claims associated with my access to the App Code, and must pay those claims on request.
8. I understand and agree that:
**a.** the DTA will collect information about me and my access to the App Code, and any feedback, comments, or other information that I post on GitHub in connection with the App Code (and I understand that this information may also be seen or accessed by other users of GitHub who have been given access to the App Code);
**b.** the DTA may use that information for the purposes of managing my access to the App Code, and to consider any feedback, comments or other information that I provide in relation to the App Code or the COVIDSafe App;
**c.** the DTA may disclose that information to other Commonwealth agencies and their contractors for the purposes of improving the App Code or the COVIDSafe App, or as required for public accountability and reporting purposes, but DTA will de-identify personal information before disclosure wherever reasonable and practicable (GitHub, a company based in the US, may also handle your personal information in accordance with the GitHub Terms and Conditions); and
**d.** further information about how DTA will handle personal information, and my rights to complain or access or correct my personal information, is available at DTA's Privacy Policy.

1
app/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

217
app/build.gradle Normal file
View file

@ -0,0 +1,217 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
apply plugin: 'kotlin-kapt'
apply plugin: "androidx.navigation.safeargs.kotlin"
buildscript {
repositories {
google()
}
}
def getGitHash = { ->
def stdout = new ByteArrayOutputStream()
exec {
commandLine 'git', 'rev-parse', '--short', 'HEAD'
standardOutput = stdout
}
return stdout.toString().trim()
}
android {
compileSdkVersion 29
buildToolsVersion "29.0.3"
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
defaultConfig {
applicationId "au.gov.health.covidsafe"
resValue "string", "build_config_package", "au.gov.health.covidsafe"
minSdkVersion 23
targetSdkVersion 29
versionCode 16
versionName "1.0.16"
buildConfigField "String", "GITHASH", "\"${getGitHash()}\""
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
javaCompileOptions {
annotationProcessorOptions {
arguments = ["room.schemaLocation":
"$projectDir/schemas".toString()]
}
}
buildConfigField "String", "ORG", ORG
buildConfigField "int", "PROTOCOL_VERSION", PROTOCOL_VERSION
buildConfigField "int", "SERVICE_FOREGROUND_NOTIFICATION_ID", SERVICE_FOREGROUND_NOTIFICATION_ID
buildConfigField "String", "SERVICE_FOREGROUND_CHANNEL_ID", SERVICE_FOREGROUND_CHANNEL_ID
buildConfigField "String", "SERVICE_FOREGROUND_CHANNEL_NAME", SERVICE_FOREGROUND_CHANNEL_NAME
buildConfigField "int", "PUSH_NOTIFICATION_ID", PUSH_NOTIFICATION_ID
buildConfigField "String", "PUSH_NOTIFICATION_CHANNEL_NAME", PUSH_NOTIFICATION_CHANNEL_NAME
buildConfigField "long", "SCAN_DURATION", SCAN_DURATION
buildConfigField "long", "MIN_SCAN_INTERVAL", MIN_SCAN_INTERVAL
buildConfigField "long", "MAX_SCAN_INTERVAL", MAX_SCAN_INTERVAL
buildConfigField "long", "MAX_QUEUE_TIME", MAX_QUEUE_TIME
buildConfigField "long", "BM_CHECK_INTERVAL", BM_CHECK_INTERVAL
buildConfigField "long", "HEALTH_CHECK_INTERVAL", HEALTH_CHECK_INTERVAL
buildConfigField "long", "CONNECTION_TIMEOUT", CONNECTION_TIMEOUT
buildConfigField "long", "BLACKLIST_DURATION", BLACKLIST_DURATION
buildConfigField "long", "ADVERTISING_DURATION", ADVERTISING_DURATION
buildConfigField "long", "ADVERTISING_INTERVAL", ADVERTISING_INTERVAL
buildConfigField "boolean", "ENABLE_DEBUG_SCREEN", "false"
}
signingConfigs {
release {
}
staging {
}
debug {
}
}
buildTypes {
debug {
buildConfigField "String", "BLE_SSID", STAGING_SERVICE_UUID
buildConfigField "boolean", "ENABLE_DEBUG_SCREEN", "true"
buildConfigField "String", "END_POINT_PREFIX", TEST_END_POINT_PREFIX
buildConfigField "String", "BASE_URL", TEST_BASE_URL
String ssid = STAGING_SERVICE_UUID
versionNameSuffix "-debug-${getGitHash()}-${ssid.substring(ssid.length() - 5,ssid.length() - 1 )}"
resValue "string", "app_name", "COVIDSafe Debug"
applicationIdSuffix "debug"
signingConfig signingConfigs.debug
}
staging {
buildConfigField "String", "BLE_SSID", STAGING_SERVICE_UUID
buildConfigField "boolean", "ENABLE_DEBUG_SCREEN", "true"
buildConfigField "String", "END_POINT_PREFIX", STAGING_END_POINT_PREFIX
buildConfigField "String", "BASE_URL", STAGING_BASE_URL
// Retrieve bluetooth ssid from staging's strings.xml
String ssid = STAGING_SERVICE_UUID
versionNameSuffix "-beta-${getGitHash()}-${ssid.substring(ssid.length() - 5,ssid.length() - 1 )}"
debuggable false
applicationIdSuffix "beta"
resValue "string", "app_name", "COVIDSafe beta"
lintOptions {
// Ignore lint errors for now
abortOnError false
}
matchingFallbacks = ['release']
signingConfig signingConfigs.staging
}
release {
buildConfigField "String", "BLE_SSID", PRODUCTION_SERVICE_UUID
buildConfigField "String", "END_POINT_PREFIX", PRODUCTION_END_POINT_PREFIX
buildConfigField "String", "BASE_URL", PROD_BASE_URL
debuggable false
jniDebuggable false
renderscriptDebuggable false
minifyEnabled false
shrinkResources false
multiDexEnabled false
zipAlignEnabled true
resValue "string", "app_name", "COVIDSafe"
lintOptions {
// Ignore lint errors for now
abortOnError false
}
signingConfig signingConfigs.release
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
packagingOptions {
exclude 'META-INF/atomicfu.kotlin_module'
}
}
repositories {
jcenter()
}
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar'])
implementation project(":feedback-android")
// kotlin
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
def kotlin_coroutines_version = "1.3.5"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version"
//androidx
implementation 'androidx.appcompat:appcompat:1.1.0'
implementation 'androidx.recyclerview:recyclerview:1.1.0'
implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.1.0-alpha01'
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
implementation 'pub.devrel:easypermissions:3.0.0'
implementation 'com.google.code.gson:gson:2.8.6'
testImplementation 'junit:junit:4.12'
androidTestImplementation 'androidx.test.ext:junit:1.1.1'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
// room
def room_version = "2.2.5"
kapt "androidx.room:room-compiler:$room_version"
implementation "androidx.room:room-runtime:$room_version"
implementation "androidx.room:room-ktx:$room_version"
// http
implementation "com.squareup.retrofit2:retrofit:$retrofit_version"
implementation "com.squareup.retrofit2:converter-gson:$retrofit_version"
implementation "com.squareup.okhttp3:okhttp:$okhttp_version"
implementation "com.squareup.okhttp3:logging-interceptor:$okhttp_version"
// rx
implementation 'io.reactivex.rxjava2:rxandroid:2.1.1'
//bottom navigation
implementation 'androidx.navigation:navigation-fragment-ktx:2.2.2'
implementation 'androidx.navigation:navigation-ui-ktx:2.2.2'
//cardview
implementation 'androidx.cardview:cardview:1.0.0'
//Lottie
implementation 'com.airbnb.android:lottie:3.4.0'
implementation 'com.google.guava:guava:28.2-android'
implementation "androidx.security:security-crypto:1.0.0-beta01"
implementation "androidx.lifecycle:lifecycle-service:2.2.0"
implementation 'com.github.razir.progressbutton:progressbutton:2.0.1'
}

View file

@ -0,0 +1,114 @@
{
"formatVersion": 1,
"database": {
"version": 2,
"identityHash": "9a95fc8ad88c160bf76c0ba4747db316",
"entities": [
{
"tableName": "record_table",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `timestamp` INTEGER NOT NULL, `v` INTEGER NOT NULL, `msg` TEXT NOT NULL, `org` TEXT NOT NULL, `modelP` TEXT NOT NULL, `modelC` TEXT NOT NULL, `rssi` INTEGER NOT NULL, `txPower` INTEGER)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "timestamp",
"columnName": "timestamp",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "v",
"columnName": "v",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "msg",
"columnName": "msg",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "org",
"columnName": "org",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "modelP",
"columnName": "modelP",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "modelC",
"columnName": "modelC",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "rssi",
"columnName": "rssi",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "txPower",
"columnName": "txPower",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "status_table",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `timestamp` INTEGER NOT NULL, `msg` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "timestamp",
"columnName": "timestamp",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "msg",
"columnName": "msg",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '9a95fc8ad88c160bf76c0ba4747db316')"
]
}
}

View file

@ -0,0 +1,114 @@
{
"formatVersion": 1,
"database": {
"version": 2,
"identityHash": "9a95fc8ad88c160bf76c0ba4747db316",
"entities": [
{
"tableName": "record_table",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `timestamp` INTEGER NOT NULL, `v` INTEGER NOT NULL, `msg` TEXT NOT NULL, `org` TEXT NOT NULL, `modelP` TEXT NOT NULL, `modelC` TEXT NOT NULL, `rssi` INTEGER NOT NULL, `txPower` INTEGER)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "timestamp",
"columnName": "timestamp",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "v",
"columnName": "v",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "msg",
"columnName": "msg",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "org",
"columnName": "org",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "modelP",
"columnName": "modelP",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "modelC",
"columnName": "modelC",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "rssi",
"columnName": "rssi",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "txPower",
"columnName": "txPower",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "status_table",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `timestamp` INTEGER NOT NULL, `msg` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "timestamp",
"columnName": "timestamp",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "msg",
"columnName": "msg",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '9a95fc8ad88c160bf76c0ba4747db316')"
]
}
}

View file

@ -0,0 +1,114 @@
{
"formatVersion": 1,
"database": {
"version": 2,
"identityHash": "9a95fc8ad88c160bf76c0ba4747db316",
"entities": [
{
"tableName": "record_table",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `timestamp` INTEGER NOT NULL, `v` INTEGER NOT NULL, `msg` TEXT NOT NULL, `org` TEXT NOT NULL, `modelP` TEXT NOT NULL, `modelC` TEXT NOT NULL, `rssi` INTEGER NOT NULL, `txPower` INTEGER)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "timestamp",
"columnName": "timestamp",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "v",
"columnName": "v",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "msg",
"columnName": "msg",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "org",
"columnName": "org",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "modelP",
"columnName": "modelP",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "modelC",
"columnName": "modelC",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "rssi",
"columnName": "rssi",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "txPower",
"columnName": "txPower",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "status_table",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `timestamp` INTEGER NOT NULL, `msg` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "timestamp",
"columnName": "timestamp",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "msg",
"columnName": "msg",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '9a95fc8ad88c160bf76c0ba4747db316')"
]
}
}

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="au.gov.health.covidsafe">
<application
android:name=".TracerApp"
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/MyTheme.DayNight">
</application>
</manifest>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources tools:ignore="MissingTranslation" xmlns:tools="http://schemas.android.com/tools">
<string name="mp_feedback_host" />
<string name="mp_feedback_apikey" />
<string name="mp_feedback_projectkey" />
<string-array name="mp_feedback_components">
<item>Android</item>
</string-array>
</resources>

View file

@ -0,0 +1,98 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="au.gov.health.covidsafe">
<uses-feature
android:name="android.hardware.bluetooth_le"
android:required="true" />
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<application
android:name="au.gov.health.covidsafe.TracerApp"
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/MyTheme.DayNight"
android:networkSecurityConfig="@xml/network_security_config">
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/provider_paths" />
</provider>
<activity
android:name="au.gov.health.covidsafe.SplashActivity"
android:configChanges="keyboardHidden"
android:screenOrientation="portrait">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name="au.gov.health.covidsafe.ui.onboarding.OnboardingActivity"
android:windowSoftInputMode="adjustPan"
android:screenOrientation="portrait" />
<activity
android:name="au.gov.health.covidsafe.WebViewActivity"
android:screenOrientation="portrait" />
<activity
android:name="au.gov.health.covidsafe.HomeActivity"
android:windowSoftInputMode="adjustPan"
android:screenOrientation="portrait" />
<activity
android:name="au.gov.health.covidsafe.SelfIsolationDoneActivity"
android:screenOrientation="portrait" />
<receiver android:name="au.gov.health.covidsafe.boot.StartOnBootReceiver">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.QUICKBOOT_POWERON" />
</intent-filter>
</receiver>
<service
android:name="au.gov.health.covidsafe.services.BluetoothMonitoringService"
android:foregroundServiceType="location" />
<service android:name="au.gov.health.covidsafe.services.SensorMonitoringService" />
<activity
android:name="au.gov.health.covidsafe.PeekActivity"
android:screenOrientation="portrait"
android:theme="@style/MyTheme.DayNightDebug"/>
<activity
android:name="au.gov.health.covidsafe.PlotActivity"
android:screenOrientation="landscape"
android:theme="@style/MyTheme.DayNightDebug" />
<receiver android:name="au.gov.health.covidsafe.receivers.UpgradeReceiver">
<intent-filter>
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
<receiver android:name="au.gov.health.covidsafe.receivers.PrivacyCleanerReceiver" />
</application>
</manifest>

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

View file

@ -0,0 +1,5 @@
package au.gov.health.covidsafe
interface HasBlockingState {
var isUiBlocked: Boolean
}

View file

@ -0,0 +1,15 @@
package au.gov.health.covidsafe
import android.os.Bundle
import androidx.fragment.app.FragmentActivity
class HomeActivity : FragmentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_home)
Utils.startBluetoothMonitoringService(this)
}
}

View file

@ -0,0 +1,147 @@
package au.gov.health.covidsafe
import android.content.Intent
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProvider
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.floatingactionbutton.FloatingActionButton
import io.reactivex.Observable
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.schedulers.Schedulers
import au.gov.health.covidsafe.streetpass.persistence.StreetPassRecordStorage
import au.gov.health.covidsafe.streetpass.view.RecordViewModel
class PeekActivity : AppCompatActivity() {
private lateinit var viewModel: RecordViewModel
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
newPeek()
}
private fun newPeek() {
setContentView(R.layout.database_peek)
val recyclerView = findViewById<RecyclerView>(R.id.recyclerview)
val adapter = RecordListAdapter(this)
recyclerView.adapter = adapter
val layoutManager = LinearLayoutManager(this)
recyclerView.layoutManager = layoutManager
val dividerItemDecoration = DividerItemDecoration(
recyclerView.context,
layoutManager.orientation
)
recyclerView.addItemDecoration(dividerItemDecoration)
viewModel = ViewModelProvider(this).get(RecordViewModel::class.java)
viewModel.allRecords.observe(this, Observer { records ->
adapter.setSourceData(records)
})
findViewById<FloatingActionButton>(R.id.expand)
.setOnClickListener {
viewModel.allRecords.value?.let {
adapter.setMode(RecordListAdapter.MODE.ALL)
}
}
findViewById<FloatingActionButton>(R.id.collapse)
.setOnClickListener {
viewModel.allRecords.value?.let {
adapter.setMode(RecordListAdapter.MODE.COLLAPSE)
}
}
val start = findViewById<FloatingActionButton>(R.id.start)
start.setOnClickListener {
startService()
}
val stop = findViewById<FloatingActionButton>(R.id.stop)
stop.setOnClickListener {
stopService()
}
val delete = findViewById<FloatingActionButton>(R.id.delete)
delete.setOnClickListener { view ->
view.isEnabled = false
val builder = AlertDialog.Builder(this)
builder
.setTitle("Are you sure?")
.setCancelable(false)
.setMessage("Deleting the DB records is irreversible")
.setPositiveButton("DELETE") { dialog, which ->
Observable.create<Boolean> {
StreetPassRecordStorage(this).nukeDb()
it.onNext(true)
}
.observeOn(AndroidSchedulers.mainThread())
.subscribeOn(Schedulers.io())
.subscribe { result ->
Toast.makeText(this, "Database nuked: $result", Toast.LENGTH_SHORT)
.show()
view.isEnabled = true
dialog.cancel()
}
}
.setNegativeButton("DON'T DELETE") { dialog, which ->
view.isEnabled = true
dialog.cancel()
}
val dialog: AlertDialog = builder.create()
dialog.show()
}
val plot = findViewById<FloatingActionButton>(R.id.plot)
plot.setOnClickListener { view ->
val intent = Intent(this, PlotActivity::class.java)
intent.putExtra("time_period", nextTimePeriod())
startActivity(intent)
}
if(!BuildConfig.DEBUG) {
start.visibility = View.GONE
stop.visibility = View.GONE
delete.visibility = View.GONE
}
}
private var timePeriod: Int = 0
private fun nextTimePeriod(): Int {
timePeriod = when (timePeriod) {
1 -> 3
3 -> 6
6 -> 12
12 -> 24
else -> 1
}
return timePeriod
}
private fun startService() {
Utils.startBluetoothMonitoringService(this)
}
private fun stopService() {
Utils.stopBluetoothMonitoringService(this)
}
}

View file

@ -0,0 +1,231 @@
package au.gov.health.covidsafe
import android.os.Build
import android.os.Bundle
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.annotation.RequiresApi
import androidx.appcompat.app.AppCompatActivity
import io.reactivex.Observable
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.functions.BiFunction
import io.reactivex.schedulers.Schedulers
import au.gov.health.covidsafe.status.persistence.StatusRecord
import au.gov.health.covidsafe.status.persistence.StatusRecordStorage
import au.gov.health.covidsafe.streetpass.persistence.StreetPassRecord
import au.gov.health.covidsafe.streetpass.persistence.StreetPassRecordStorage
import au.gov.health.covidsafe.ui.upload.model.DebugData
import java.text.SimpleDateFormat
import java.util.*
import kotlin.Comparator
class PlotActivity : AppCompatActivity() {
private var TAG = "PlotActivity"
@RequiresApi(Build.VERSION_CODES.O)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_plot)
val webView = findViewById<WebView>(R.id.webView)
webView.webViewClient = WebViewClient()
webView.settings.javaScriptEnabled = true
val displayTimePeriod = intent.getIntExtra("time_period", 1) // in hours
val observableStreetRecords = Observable.create<List<StreetPassRecord>> {
val result = StreetPassRecordStorage(this).getAllRecords()
it.onNext(result)
}
val observableStatusRecords = Observable.create<List<StatusRecord>> {
val result = StatusRecordStorage(this).getAllRecords()
it.onNext(result)
}
val zipResult = Observable.zip(observableStreetRecords, observableStatusRecords,
BiFunction<List<StreetPassRecord>, List<StatusRecord>, DebugData> { records, _ -> DebugData(records) }
)
.observeOn(AndroidSchedulers.mainThread())
.subscribeOn(Schedulers.io())
.subscribe { exportedData ->
if(exportedData.records.isEmpty()){
return@subscribe
}
val dateFormatter = SimpleDateFormat("yyyy-MM-dd HH:mm:ss")
// Use the date of the last record as the end time (Epoch time in seconds)
val endTime =
exportedData.records.sortedByDescending { it.timestamp }[0].timestamp / 1000 + 1 * 60
val endTimeString = dateFormatter.format(Date(endTime * 1000))
val startTime =
endTime - displayTimePeriod * 3600 // ignore records older than X hour(s)
val startTimeString = dateFormatter.format(Date(startTime * 1000))
val filteredRecords = exportedData.records.filter {
it.timestamp / 1000 in startTime..endTime
}
if (filteredRecords.isNotEmpty()) {
val dataByModelC = filteredRecords.groupBy { it.modelC }
val dataByModelP = filteredRecords.groupBy { it.modelP }
// get all models
val allModelList = dataByModelC.keys union dataByModelP.keys.toList()
// sort the list by the models that appear the most frequently
val sortedModelList =
allModelList.sortedWith(Comparator { a: String, b: String ->
val aSize = (dataByModelC[a]?.size ?: 0) + (dataByModelP[a]?.size ?: 0)
val bSize = (dataByModelC[b]?.size ?: 0) + (dataByModelP[b]?.size ?: 0)
bSize - aSize
})
val individualData = sortedModelList.joinToString(separator = "\n") { model ->
val index = sortedModelList.indexOf(model) + 1
val hasC = dataByModelC.containsKey(model)
val hasP = dataByModelP.containsKey(model)
val x1 = dataByModelC[model]?.joinToString(separator = "\", \"", prefix = "[\"", postfix = "\"]") {
dateFormatter.format(Date(it.timestamp))
}
val y1 = dataByModelC[model]?.map { it.rssi }
?.joinToString(separator = ", ", prefix = "[", postfix = "]")
val x2 = dataByModelP[model]?.joinToString(separator = "\", \"", prefix = "[\"", postfix = "\"]") {
dateFormatter.format(Date(it.timestamp))
}
val y2 = dataByModelP[model]?.map { it.rssi }
?.joinToString(separator = ", ", prefix = "[", postfix = "]")
val dataHead = "var data${index} = [];"
val dataA = if (!hasC) "" else """
var data${index}a = {
name: 'central',
x: ${x1},
y: ${y1},
xaxis: 'x${index}',
yaxis: 'y${index}',
mode: 'markers',
type: 'scatter',
line: {color: 'blue'}
};
data${index} = data${index}.concat(data${index}a);
""".trimIndent()
val dataB = if (!hasP) "" else """
var data${index}b = {
name: 'peripheral',
x: ${x2},
y: ${y2},
xaxis: 'x${index}',
yaxis: 'y${index}',
mode: 'markers',
type: 'scatter',
line: {color: 'red'}
};
data${index} = data${index}.concat(data${index}b);
""".trimIndent()
val data = dataHead + dataA + dataB
data
}
val top = 20
val combinedData = sortedModelList.joinToString(separator = "\n") { model ->
val index = sortedModelList.indexOf(model) + 1
if (index < top) """
data = data.concat(data${index});
""".trimIndent() else ""
}
val xAxis = sortedModelList.joinToString(separator = ",\n") { model ->
val index = sortedModelList.indexOf(model) + 1
if (index < top) """
xaxis${index}: {
type: 'date',
tickformat: '%H:%M:%S',
range: ['${startTimeString}', '${endTimeString}'],
dtick: ${displayTimePeriod * 5} * 60 * 1000
}
""".trimIndent() else ""
}
val yAxis = sortedModelList.joinToString(separator = ",\n") { model ->
val index = sortedModelList.indexOf(model) + 1
if (index < top) """
yaxis${index}: {
range: [-100, -30],
ticks: 'outside',
dtick: 10,
title: {
text: "$model"
}
}
""".trimIndent() else ""
}
// Form the complete HTML
val customHtml = """
<head>
<script src='https://cdn.plot.ly/plotly-latest.min.js'></script>
</head>
<body>
<div id='myDiv'></div>
<script>
$individualData
var data = [];
$combinedData
var layout = {
title: 'Activities from <b>${startTimeString.substring(11..15)}</b> to <b>${endTimeString.substring(11..15)}</b> <span style="color:blue">central</span> <span style="color:red">peripheral</span>',
height: 135 * ${allModelList.size},
showlegend: false,
grid: {rows: ${allModelList.size}, columns: 1, pattern: 'independent'},
margin: {
t: 30,
r: 30,
b: 20,
l: 50,
pad: 0
},
$xAxis,
$yAxis
};
var config = {
responsive: true,
displayModeBar: false,
displaylogo: false,
modeBarButtonsToRemove: ['toImage', 'sendDataToCloud', 'editInChartStudio', 'zoom2d', 'select2d', 'pan2d', 'lasso2d', 'autoScale2d', 'resetScale2d', 'zoomIn2d', 'zoomOut2d', 'hoverClosestCartesian', 'hoverCompareCartesian', 'toggleHover', 'toggleSpikelines']
}
Plotly.newPlot('myDiv', data, layout, config);
</script>
</body>
""".trimIndent()
webView.loadData(customHtml, "text/html", "UTF-8")
} else {
webView.loadData(
"No data received in the last $displayTimePeriod hour(s) or more.",
"text/html",
"UTF-8"
)
}
}
webView.loadData("Loading...", "text/html", "UTF-8")
}
}

View file

@ -0,0 +1,162 @@
package au.gov.health.covidsafe
import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKeys
object Preference {
private const val PREF_ID = "Tracer_pref"
private const val IS_ONBOARDED = "IS_ONBOARDED"
private const val PHONE_NUMBER = "PHONE_NUMBER"
private const val HANDSHAKE_PIN = "HANDSHAKE_PIN"
private const val DEVICE_ID = "DEVICE_ID"
private const val JWT_TOKEN = "JWT_TOKEN"
private const val IS_DATA_UPLOADED = "IS_DATA_UPLOADED"
private const val DATA_UPLOADED_DATE_MS = "DATA_UPLOADED_DATE_MS"
private const val UPLOADED_MORE_THAN_24_HRS = "UPLOADED_MORE_THAN_24_HRS"
private const val NEXT_FETCH_TIME = "NEXT_FETCH_TIME"
private const val EXPIRY_TIME = "EXPIRY_TIME"
private const val NAME = "NAME"
private const val IS_MINOR = "IS_MINOR"
private const val POST_CODE = "POST_CODE"
private const val AGE = "AGE"
fun putDeviceID(context: Context, value: String) {
context.getSharedPreferences(PREF_ID, Context.MODE_PRIVATE)
.edit().putString(DEVICE_ID, value)?.apply()
}
fun getDeviceID(context: Context?): String {
return context?.getSharedPreferences(PREF_ID, Context.MODE_PRIVATE)
?.getString(DEVICE_ID, "") ?: ""
}
fun putEncrypterJWTToken(context: Context?, jwtToken: String?) {
context?.let {
val masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC)
EncryptedSharedPreferences.create(
PREF_ID,
masterKeyAlias,
context,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
).edit()?.putString(JWT_TOKEN, jwtToken)?.apply()
}
}
fun getEncrypterJWTToken(context: Context?): String? {
return context?.let {
val masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC)
EncryptedSharedPreferences.create(
PREF_ID,
masterKeyAlias,
context,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
).getString(JWT_TOKEN, null)
}
}
fun putHandShakePin(context: Context?, value: String?) {
context?.getSharedPreferences(PREF_ID, Context.MODE_PRIVATE)
?.edit()?.putString(HANDSHAKE_PIN, value)?.apply()
}
fun putIsOnBoarded(context: Context, value: Boolean) {
context.getSharedPreferences(PREF_ID, Context.MODE_PRIVATE)
.edit().putBoolean(IS_ONBOARDED, value).apply()
}
fun isOnBoarded(context: Context): Boolean {
return context.getSharedPreferences(PREF_ID, Context.MODE_PRIVATE)
.getBoolean(IS_ONBOARDED, false)
}
fun putPhoneNumber(context: Context, value: String) {
context.getSharedPreferences(PREF_ID, Context.MODE_PRIVATE)
.edit().putString(PHONE_NUMBER, value).apply()
}
fun putNextFetchTimeInMillis(context: Context, time: Long) {
context.getSharedPreferences(PREF_ID, Context.MODE_PRIVATE)
.edit().putLong(NEXT_FETCH_TIME, time).apply()
}
fun getNextFetchTimeInMillis(context: Context): Long {
return context.getSharedPreferences(PREF_ID, Context.MODE_PRIVATE)
.getLong(
NEXT_FETCH_TIME, 0
)
}
fun putExpiryTimeInMillis(context: Context, time: Long) {
context.getSharedPreferences(PREF_ID, Context.MODE_PRIVATE)
.edit().putLong(EXPIRY_TIME, time).apply()
}
fun getExpiryTimeInMillis(context: Context): Long {
return context.getSharedPreferences(PREF_ID, Context.MODE_PRIVATE)
.getLong(
EXPIRY_TIME, 0
)
}
fun isDataUploaded(context: Context): Boolean {
return context.getSharedPreferences(PREF_ID, Context.MODE_PRIVATE).getBoolean(IS_DATA_UPLOADED, false)
}
fun setDataIsUploaded(context: Context, value: Boolean) {
context.getSharedPreferences(PREF_ID, Context.MODE_PRIVATE).edit().also { editor ->
editor.putBoolean(IS_DATA_UPLOADED, value)
if (value) {
editor.putLong(DATA_UPLOADED_DATE_MS, System.currentTimeMillis())
} else {
editor.remove(DATA_UPLOADED_DATE_MS)
}
}.apply()
}
fun getDataUploadedDateMs(context: Context): Long {
return context.getSharedPreferences(PREF_ID, Context.MODE_PRIVATE).getLong(DATA_UPLOADED_DATE_MS, System.currentTimeMillis())
}
fun putName(context: Context, name: String): Boolean {
return context.getSharedPreferences(PREF_ID, Context.MODE_PRIVATE)
.edit().putString(NAME, name).commit()
}
fun getName(context: Context): String? {
return context.getSharedPreferences(PREF_ID, Context.MODE_PRIVATE).getString(NAME, null)
}
fun putIsMinor(context: Context, minor: Boolean): Boolean {
return context.getSharedPreferences(PREF_ID, Context.MODE_PRIVATE)
.edit().putBoolean(IS_MINOR, minor).commit()
}
fun isMinor(context: Context): Boolean {
return context.getSharedPreferences(PREF_ID, Context.MODE_PRIVATE).getBoolean(IS_MINOR, false)
}
fun putPostCode(context: Context, state: String): Boolean {
return context.getSharedPreferences(PREF_ID, Context.MODE_PRIVATE)
.edit().putString(POST_CODE, state).commit()
}
fun getPostCode(context: Context): String? {
return context.getSharedPreferences(PREF_ID, Context.MODE_PRIVATE)
.getString(POST_CODE, null)
}
fun putAge(context: Context, age: String): Boolean {
return context.getSharedPreferences(PREF_ID, Context.MODE_PRIVATE)
.edit().putString(AGE, age).commit()
}
fun getAge(context: Context): String? {
return context.getSharedPreferences(PREF_ID, Context.MODE_PRIVATE)
.getString(AGE, null)
}
}

View file

@ -0,0 +1,170 @@
package au.gov.health.covidsafe
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import au.gov.health.covidsafe.streetpass.persistence.StreetPassRecord
import au.gov.health.covidsafe.streetpass.view.StreetPassRecordViewModel
class RecordListAdapter internal constructor(context: Context) :
RecyclerView.Adapter<RecordListAdapter.RecordViewHolder>() {
private val inflater: LayoutInflater = LayoutInflater.from(context)
private var records = emptyList<StreetPassRecordViewModel>() // Cached copy of records
private var sourceData = emptyList<StreetPassRecord>()
enum class MODE {
ALL, COLLAPSE, MODEL_P, MODEL_C
}
private var mode = MODE.ALL
inner class RecordViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
val modelCView: TextView = itemView.findViewById(R.id.modelc)
val modelPView: TextView = itemView.findViewById(R.id.modelp)
val timestampView: TextView = itemView.findViewById(R.id.timestamp)
val findsView: TextView = itemView.findViewById(R.id.finds)
val txpowerView: TextView = itemView.findViewById(R.id.txpower)
val signalStrengthView: TextView = itemView.findViewById(R.id.signal_strength)
val filterModelP: View = itemView.findViewById(R.id.filter_by_modelp)
val filterModelC: View = itemView.findViewById(R.id.filter_by_modelc)
val msgView: TextView = itemView.findViewById(R.id.msg)
val version: TextView = itemView.findViewById(R.id.version)
val org: TextView = itemView.findViewById(R.id.org)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecordViewHolder {
val itemView = inflater.inflate(R.layout.recycler_view_item, parent, false)
return RecordViewHolder(itemView)
}
override fun onBindViewHolder(holder: RecordViewHolder, position: Int) {
val current = records[position]
holder.msgView.text = current.msg
holder.modelCView.text = current.modelC
holder.modelPView.text = current.modelP
holder.findsView.text = "Detections: ${current.number}"
val readableDate = Utils.getDate(current.timeStamp)
holder.timestampView.text = readableDate
holder.version.text = "v: ${current.version}"
holder.org.text = "ORG: ${current.org}"
holder.filterModelP.tag = current
holder.filterModelC.tag = current
holder.signalStrengthView.text = "Signal Strength: ${current.rssi}"
holder.txpowerView.text = "Tx Power: ${current.transmissionPower}"
holder.filterModelP.setOnClickListener {
val model = it.tag as StreetPassRecordViewModel
setMode(MODE.MODEL_P, model)
}
holder.filterModelC.setOnClickListener {
val model = it.tag as StreetPassRecordViewModel
setMode(MODE.MODEL_C, model)
}
}
private fun filter(sample: StreetPassRecordViewModel?): List<StreetPassRecordViewModel> {
return when (mode) {
MODE.COLLAPSE -> prepareCollapsedData(sourceData)
MODE.ALL -> prepareViewData(sourceData)
MODE.MODEL_P -> filterByModelP(sample, sourceData)
MODE.MODEL_C -> filterByModelC(sample, sourceData)
else -> {
prepareViewData(sourceData)
}
}
}
private fun filterByModelC(
model: StreetPassRecordViewModel?,
words: List<StreetPassRecord>
): List<StreetPassRecordViewModel> {
if (model != null) {
return prepareViewData(words.filter { it.modelC == model.modelC })
}
return prepareViewData(words)
}
private fun filterByModelP(
model: StreetPassRecordViewModel?,
words: List<StreetPassRecord>
): List<StreetPassRecordViewModel> {
if (model != null) {
return prepareViewData(words.filter { it.modelP == model.modelP })
}
return prepareViewData(words)
}
private fun prepareCollapsedData(words: List<StreetPassRecord>): List<StreetPassRecordViewModel> {
//we'll need to count the number of unique device IDs
val countMap = words.groupBy {
it.modelC
}
val distinctAddresses = words.distinctBy { it.modelC }
return distinctAddresses.map { record ->
val count = countMap[record.modelC]?.size
count?.let { count ->
val mostRecentRecord = countMap[record.modelC]?.maxBy { it.timestamp }
if (mostRecentRecord != null) {
return@map StreetPassRecordViewModel(mostRecentRecord, count)
}
return@map StreetPassRecordViewModel(record, count)
}
//fallback - unintended
return@map StreetPassRecordViewModel(record)
}
}
private fun prepareViewData(words: List<StreetPassRecord>): List<StreetPassRecordViewModel> {
words.let {
val reversed = it.reversed()
return reversed.map { streetPassRecord ->
return@map StreetPassRecordViewModel(streetPassRecord)
}
}
}
fun setMode(mode: MODE) {
setMode(mode, null)
}
private fun setMode(mode: MODE, model: StreetPassRecordViewModel?) {
this.mode = mode
val list = filter(model)
setRecords(list)
}
private fun setRecords(records: List<StreetPassRecordViewModel>) {
this.records = records
notifyDataSetChanged()
}
internal fun setSourceData(records: List<StreetPassRecord>) {
this.sourceData = records
setMode(mode)
}
override fun getItemCount() = records.size
}

View file

@ -0,0 +1,35 @@
package au.gov.health.covidsafe
import android.content.Intent
import android.os.Bundle
import androidx.fragment.app.FragmentActivity
import kotlinx.android.synthetic.main.activity_self_isolation.*
class SelfIsolationDoneActivity : FragmentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_self_isolation)
}
override fun onResume() {
super.onResume()
activity_self_isolation_next.setOnClickListener {
Preference.setDataIsUploaded(this, false)
val intent = Intent(this, HomeActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
startActivity(intent)
finish()
}
}
override fun onPause() {
super.onPause()
activity_self_isolation_next.setOnClickListener(null)
}
override fun onDestroy() {
super.onDestroy()
root.removeAllViews()
}
}

View file

@ -0,0 +1,82 @@
package au.gov.health.covidsafe
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.os.Handler
import android.provider.Settings
import android.view.View
import androidx.appcompat.app.AppCompatActivity
import au.gov.health.covidsafe.ui.onboarding.OnboardingActivity
import java.util.*
class SplashActivity : AppCompatActivity() {
private val SPLASH_TIME: Long = 2000
private var retryProviderInstall: Boolean = false
private val ERROR_DIALOG_REQUEST_CODE = 1
private var updateFlag = false
private lateinit var mHandler: Handler
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_splash)
hideSystemUI()
mHandler = Handler()
Preference.putDeviceID(this, Settings.Secure.getString(this.contentResolver,
Settings.Secure.ANDROID_ID))
}
override fun onPause() {
super.onPause()
mHandler.removeCallbacksAndMessages(null)
}
override fun onResume() {
super.onResume()
if (!updateFlag) {
mHandler.postDelayed({
goToNextScreen()
finish()
}, SPLASH_TIME)
}
}
private fun goToNextScreen() {
val dateUploaded = Calendar.getInstance().also {
it.timeInMillis = Preference.getDataUploadedDateMs(this)
}
val fourteenDaysAgo = Calendar.getInstance().also {
it.add(Calendar.DATE, -14)
}
startActivity(Intent(this, if (!Preference.isOnBoarded(this)) {
OnboardingActivity::class.java
} else if (dateUploaded.before(fourteenDaysAgo)) {
SelfIsolationDoneActivity::class.java
} else {
HomeActivity::class.java
}))
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == ERROR_DIALOG_REQUEST_CODE) {
retryProviderInstall = true
}
}
// This snippet hides the system bars.
private fun hideSystemUI() {
window.decorView.systemUiVisibility = (
View.SYSTEM_UI_FLAG_LAYOUT_STABLE
or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_FULLSCREEN)
}
}

View file

@ -0,0 +1,48 @@
package au.gov.health.covidsafe
import android.app.Application
import android.content.Context
import android.os.Build
import com.atlassian.mobilekit.module.feedback.FeedbackModule
import au.gov.health.covidsafe.logging.CentralLog
import au.gov.health.covidsafe.services.BluetoothMonitoringService
import au.gov.health.covidsafe.streetpass.CentralDevice
import au.gov.health.covidsafe.streetpass.PeripheralDevice
class TracerApp : Application() {
override fun onCreate() {
super.onCreate()
AppContext = applicationContext
FeedbackModule.init(this)
}
companion object {
private const val TAG = "TracerApp"
const val ORG = BuildConfig.ORG
const val protocolVersion = BuildConfig.PROTOCOL_VERSION
lateinit var AppContext: Context
fun thisDeviceMsg(): String {
BluetoothMonitoringService.broadcastMessage?.let {
CentralLog.i(TAG, "Retrieved BM for storage: $it")
return it
}
CentralLog.e(TAG, "No local Broadcast Message")
return BluetoothMonitoringService.broadcastMessage!!
}
fun asPeripheralDevice(): PeripheralDevice {
return PeripheralDevice(Build.MODEL, "SELF")
}
fun asCentralDevice(): CentralDevice {
return CentralDevice(Build.MODEL, "SELF")
}
}
}

View file

@ -0,0 +1,247 @@
package au.gov.health.covidsafe
import android.Manifest
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.provider.Settings
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import au.gov.health.covidsafe.bluetooth.gatt.*
import au.gov.health.covidsafe.logging.CentralLog
import au.gov.health.covidsafe.scheduler.Scheduler
import au.gov.health.covidsafe.services.BluetoothMonitoringService
import au.gov.health.covidsafe.services.BluetoothMonitoringService.Companion.PENDING_ADVERTISE_REQ_CODE
import au.gov.health.covidsafe.services.BluetoothMonitoringService.Companion.PENDING_BM_UPDATE
import au.gov.health.covidsafe.services.BluetoothMonitoringService.Companion.PENDING_HEALTH_CHECK_CODE
import au.gov.health.covidsafe.services.BluetoothMonitoringService.Companion.PENDING_SCAN_REQ_CODE
import au.gov.health.covidsafe.services.BluetoothMonitoringService.Companion.PENDING_START
import au.gov.health.covidsafe.status.Status
import au.gov.health.covidsafe.streetpass.ACTION_DEVICE_SCANNED
import au.gov.health.covidsafe.streetpass.ConnectablePeripheral
import au.gov.health.covidsafe.streetpass.ConnectionRecord
import java.io.File
import java.text.SimpleDateFormat
import java.util.*
object Utils {
private const val TAG = "Utils"
fun getRequiredPermissions(): Array<String> {
return arrayOf(Manifest.permission.ACCESS_FINE_LOCATION)
}
fun getBatteryOptimizerExemptionIntent(packageName: String): Intent {
val intent = Intent()
intent.action = Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS
intent.data = Uri.parse("package:$packageName")
return intent
}
fun canHandleIntent(batteryExemptionIntent: Intent, packageManager: PackageManager?): Boolean {
packageManager?.let {
return batteryExemptionIntent.resolveActivity(packageManager) != null
}
return false
}
fun getDate(milliSeconds: Long): String {
val dateFormat = "dd/MM/yyyy HH:mm:ss.SSS"
// Create a DateFormatter object for displaying date in specified format.
val formatter = SimpleDateFormat(dateFormat)
// Create a calendar object that will convert the date and time value in milliseconds to date.
val calendar = Calendar.getInstance()
calendar.timeInMillis = milliSeconds
return formatter.format(calendar.time)
}
fun startBluetoothMonitoringService(context: Context) {
val intent = Intent(context, BluetoothMonitoringService::class.java)
intent.putExtra(
BluetoothMonitoringService.COMMAND_KEY,
BluetoothMonitoringService.Command.ACTION_START.index
)
context.startService(intent)
}
fun scheduleStartMonitoringService(context: Context, timeInMillis: Long) {
val intent = Intent(context, BluetoothMonitoringService::class.java)
intent.putExtra(
BluetoothMonitoringService.COMMAND_KEY,
BluetoothMonitoringService.Command.ACTION_START.index
)
Scheduler.scheduleServiceIntent(
PENDING_START,
context,
intent,
timeInMillis
)
}
fun scheduleBMUpdateCheck(context: Context, bmCheckInterval: Long) {
cancelBMUpdateCheck(context)
val intent = Intent(context, BluetoothMonitoringService::class.java)
intent.putExtra(
BluetoothMonitoringService.COMMAND_KEY,
BluetoothMonitoringService.Command.ACTION_UPDATE_BM.index
)
Scheduler.scheduleServiceIntent(
PENDING_BM_UPDATE,
context,
intent,
bmCheckInterval
)
}
fun cancelBMUpdateCheck(context: Context) {
val intent = Intent(context, BluetoothMonitoringService::class.java)
intent.putExtra(
BluetoothMonitoringService.COMMAND_KEY,
BluetoothMonitoringService.Command.ACTION_UPDATE_BM.index
)
Scheduler.cancelServiceIntent(PENDING_BM_UPDATE, context, intent)
}
fun stopBluetoothMonitoringService(context: Context) {
val intent = Intent(context, BluetoothMonitoringService::class.java)
intent.putExtra(
BluetoothMonitoringService.COMMAND_KEY,
BluetoothMonitoringService.Command.ACTION_STOP.index
)
cancelNextScan(context)
cancelNextHealthCheck(context)
context.stopService(intent)
}
fun cancelNextScan(context: Context) {
val nextIntent = Intent(context, BluetoothMonitoringService::class.java)
nextIntent.putExtra(
BluetoothMonitoringService.COMMAND_KEY,
BluetoothMonitoringService.Command.ACTION_SCAN.index
)
Scheduler.cancelServiceIntent(PENDING_SCAN_REQ_CODE, context, nextIntent)
}
fun cancelNextAdvertise(context: Context) {
val nextIntent = Intent(context, BluetoothMonitoringService::class.java)
nextIntent.putExtra(
BluetoothMonitoringService.COMMAND_KEY,
BluetoothMonitoringService.Command.ACTION_ADVERTISE.index
)
Scheduler.cancelServiceIntent(PENDING_ADVERTISE_REQ_CODE, context, nextIntent)
}
fun scheduleNextHealthCheck(context: Context, timeInMillis: Long) {
//cancels any outstanding check schedules.
cancelNextHealthCheck(context)
val nextIntent = Intent(context, BluetoothMonitoringService::class.java)
nextIntent.putExtra(
BluetoothMonitoringService.COMMAND_KEY,
BluetoothMonitoringService.Command.ACTION_SELF_CHECK.index
)
Scheduler.scheduleServiceIntent(
PENDING_HEALTH_CHECK_CODE,
context,
nextIntent,
timeInMillis
)
}
private fun cancelNextHealthCheck(context: Context) {
val nextIntent = Intent(context, BluetoothMonitoringService::class.java)
nextIntent.putExtra(
BluetoothMonitoringService.COMMAND_KEY,
BluetoothMonitoringService.Command.ACTION_SELF_CHECK.index
)
Scheduler.cancelServiceIntent(PENDING_HEALTH_CHECK_CODE, context, nextIntent)
}
fun broadcastDeviceScanned(
context: Context,
device: BluetoothDevice,
connectableBleDevice: ConnectablePeripheral
) {
val intent = Intent(ACTION_DEVICE_SCANNED)
intent.putExtra(BluetoothDevice.EXTRA_DEVICE, device)
intent.putExtra(CONNECTION_DATA, connectableBleDevice)
LocalBroadcastManager.getInstance(context).sendBroadcast(intent)
}
fun broadcastDeviceProcessed(context: Context, deviceAddress: String) {
val intent = Intent(ACTION_DEVICE_PROCESSED)
intent.putExtra(DEVICE_ADDRESS, deviceAddress)
LocalBroadcastManager.getInstance(context).sendBroadcast(intent)
}
fun broadcastStreetPassReceived(context: Context, streetpass: ConnectionRecord) {
val intent = Intent(ACTION_RECEIVED_STREETPASS)
intent.putExtra(STREET_PASS, streetpass)
LocalBroadcastManager.getInstance(context).sendBroadcast(intent)
}
fun broadcastStatusReceived(context: Context, statusRecord: Status) {
val intent = Intent(ACTION_RECEIVED_STATUS)
intent.putExtra(STATUS, statusRecord)
LocalBroadcastManager.getInstance(context).sendBroadcast(intent)
}
fun broadcastDeviceDisconnected(context: Context, device: BluetoothDevice) {
val intent = Intent(ACTION_GATT_DISCONNECTED)
intent.putExtra(BluetoothDevice.EXTRA_DEVICE, device)
LocalBroadcastManager.getInstance(context).sendBroadcast(intent)
}
fun isBluetoothAvailable(): Boolean {
val bluetoothAdapter = BluetoothAdapter.getDefaultAdapter()
return bluetoothAdapter != null &&
bluetoothAdapter.isEnabled && bluetoothAdapter.state == BluetoothAdapter.STATE_ON
}
fun storeBroadcastMessage(context: Context?, packet: String) {
CentralLog.d(TAG, "Storing packet into internal storage...")
val file = File(context?.filesDir, "packet")
file.writeText(packet)
}
fun retrieveBroadcastMessage(context: Context): String? {
val file = File(context.filesDir, "packet")
if (file.exists()) {
val readback = file.readText()
CentralLog.d(TAG, "fetched broadcastmessage from file: $readback")
return readback
}
return null
}
fun needToUpdate(context: Context): Boolean {
val nextFetchTime = Preference.getNextFetchTimeInMillis(context)
val currentTime = System.currentTimeMillis()
val update = currentTime >= nextFetchTime
CentralLog.i(TAG, "Need to update BM? $nextFetchTime vs $currentTime: $update")
return update
}
fun bmValid(context: Context): Boolean {
val expiryTime = Preference.getExpiryTimeInMillis(context)
val currentTime = System.currentTimeMillis()
val update = currentTime < expiryTime
CentralLog.i(TAG, "Is BM Valid? $expiryTime vs $currentTime: $update")
return true
}
}

View file

@ -0,0 +1,35 @@
package au.gov.health.covidsafe
import android.os.Bundle
import android.webkit.WebChromeClient
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.fragment.app.FragmentActivity
import au.gov.health.covidsafe.logging.CentralLog
class WebViewActivity : FragmentActivity() {
companion object {
val URL_ARG = "URL_ARG"
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.webview)
val webView = findViewById<WebView>(R.id.webview)
webView.webViewClient = WebViewClient()
if (intent.getStringExtra(URL_ARG).isNullOrBlank()) {
webView.loadUrl("https://www.australia.gov.au")
} else {
webView.loadUrl(intent.getStringExtra(URL_ARG))
}
val wbc: WebChromeClient = object : WebChromeClient() {
override fun onCloseWindow(w: WebView) {
CentralLog.d("WebViewActivity", "Window trying to close")
}
}
webView.webChromeClient = wbc
}
}

View file

@ -0,0 +1,128 @@
package au.gov.health.covidsafe.bluetooth
import android.bluetooth.BluetoothAdapter
import android.bluetooth.le.AdvertiseCallback
import android.bluetooth.le.AdvertiseData
import android.bluetooth.le.AdvertiseSettings
import android.bluetooth.le.BluetoothLeAdvertiser
import android.os.Handler
import android.os.ParcelUuid
import au.gov.health.covidsafe.logging.CentralLog
import java.util.*
class BLEAdvertiser constructor(serviceUUID: String) {
private var advertiser: BluetoothLeAdvertiser? =
BluetoothAdapter.getDefaultAdapter().bluetoothLeAdvertiser
private val TAG = "BLEAdvertiser"
private var charLength = 3
private var callback: AdvertiseCallback = object : AdvertiseCallback() {
override fun onStartSuccess(settingsInEffect: AdvertiseSettings) {
super.onStartSuccess(settingsInEffect)
CentralLog.i(TAG, "Advertising onStartSuccess")
CentralLog.i(TAG, settingsInEffect.toString())
isAdvertising = true
}
override fun onStartFailure(errorCode: Int) {
super.onStartFailure(errorCode)
val reason: String
when (errorCode) {
ADVERTISE_FAILED_ALREADY_STARTED -> {
reason = "ADVERTISE_FAILED_ALREADY_STARTED"
isAdvertising = true
}
ADVERTISE_FAILED_FEATURE_UNSUPPORTED -> {
reason = "ADVERTISE_FAILED_FEATURE_UNSUPPORTED"
isAdvertising = false
}
ADVERTISE_FAILED_INTERNAL_ERROR -> {
reason = "ADVERTISE_FAILED_INTERNAL_ERROR"
isAdvertising = false
}
ADVERTISE_FAILED_TOO_MANY_ADVERTISERS -> {
reason = "ADVERTISE_FAILED_TOO_MANY_ADVERTISERS"
isAdvertising = false
}
ADVERTISE_FAILED_DATA_TOO_LARGE -> {
reason = "ADVERTISE_FAILED_DATA_TOO_LARGE"
isAdvertising = false
charLength--
}
else -> {
reason = "UNDOCUMENTED"
}
}
CentralLog.d(TAG, "Advertising onStartFailure: $errorCode - $reason")
}
}
private val pUuid = ParcelUuid(UUID.fromString(serviceUUID))
private val settings: AdvertiseSettings? = AdvertiseSettings.Builder()
.setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_HIGH)
.setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY)
.setConnectable(true)
.setTimeout(0)
.build()
var data: AdvertiseData? = null
private var handler = Handler()
private var stopRunnable: Runnable = Runnable {
CentralLog.i(TAG, "Advertising stopping as scheduled.")
stopAdvertising()
}
var isAdvertising = false
var shouldBeAdvertising = false
private fun startAdvertisingLegacy(timeoutInMillis: Long) {
val randomUUID = UUID.randomUUID().toString()
val finalString = randomUUID.substring(randomUUID.length - charLength, randomUUID.length)
CentralLog.d(TAG, "Unique string: $finalString")
val serviceDataByteArray = finalString.toByteArray()
if (data == null) {
data = AdvertiseData.Builder()
.setIncludeDeviceName(false)
.setIncludeTxPowerLevel(true)
.addServiceUuid(pUuid)
.addManufacturerData(1023, serviceDataByteArray)
.build()
}
try {
CentralLog.d(TAG, "Start advertising")
advertiser = advertiser ?: BluetoothAdapter.getDefaultAdapter().bluetoothLeAdvertiser
advertiser?.startAdvertising(settings, data, callback)
} catch (e: Throwable) {
CentralLog.e(TAG, "Failed to start advertising legacy: ${e.message}")
}
handler.removeCallbacksAndMessages(stopRunnable)
handler.postDelayed(stopRunnable, timeoutInMillis)
}
fun startAdvertising(timeoutInMillis: Long) {
startAdvertisingLegacy(timeoutInMillis)
shouldBeAdvertising = true
}
private fun stopAdvertising() {
try {
CentralLog.d(TAG, "stop advertising")
advertiser?.stopAdvertising(callback)
} catch (e: Throwable) {
CentralLog.e(TAG, "Failed to stop advertising: ${e.message}")
}
shouldBeAdvertising = false
handler.removeCallbacksAndMessages(null)
}
}

View file

@ -0,0 +1,67 @@
package au.gov.health.covidsafe.bluetooth
import android.bluetooth.BluetoothAdapter
import android.bluetooth.le.BluetoothLeScanner
import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanFilter
import android.bluetooth.le.ScanSettings
import android.content.Context
import android.os.ParcelUuid
import au.gov.health.covidsafe.Utils
import au.gov.health.covidsafe.logging.CentralLog
import java.util.*
import kotlin.collections.ArrayList
import kotlin.properties.Delegates
class BLEScanner constructor(context: Context, uuid: String, reportDelay: Long) {
private var serviceUUID: String by Delegates.notNull()
private var context: Context by Delegates.notNull()
private var scanCallback: ScanCallback? = null
private var reportDelay: Long by Delegates.notNull()
private var scanner: BluetoothLeScanner? =
BluetoothAdapter.getDefaultAdapter().bluetoothLeScanner
private val TAG = "BLEScanner"
init {
this.serviceUUID = uuid
this.context = context
this.reportDelay = reportDelay
}
fun startScan(scanCallback: ScanCallback) {
val filter = ScanFilter.Builder()
.setServiceUuid(ParcelUuid(UUID.fromString(serviceUUID)))
.build()
val filters: ArrayList<ScanFilter> = ArrayList()
filters.add(filter)
val settings = ScanSettings.Builder()
.setReportDelay(reportDelay)
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
.build()
this.scanCallback = scanCallback
scanner = scanner ?: BluetoothAdapter.getDefaultAdapter().bluetoothLeScanner
scanner?.startScan(filters, settings, scanCallback)
}
fun stopScan() {
try {
if (scanCallback != null && Utils.isBluetoothAvailable()) { //fixed crash if BT if turned off, stop scan will crash.
scanner?.stopScan(scanCallback)
CentralLog.d(TAG, "scanning stopped")
}
} catch (e: Throwable) {
CentralLog.e(
TAG,
"unable to stop scanning - callback null or bluetooth off? : ${e.localizedMessage}"
)
}
}
}

View file

@ -0,0 +1,65 @@
package au.gov.health.covidsafe.bluetooth.gatt
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import au.gov.health.covidsafe.BuildConfig
import au.gov.health.covidsafe.streetpass.PeripheralDevice
const val ACTION_RECEIVED_STREETPASS =
"${BuildConfig.APPLICATION_ID}.ACTION_RECEIVED_STREETPASS"
const val ACTION_RECEIVED_STATUS =
"${BuildConfig.APPLICATION_ID}.ACTION_RECEIVED_STATUS"
const val DEVICE_ADDRESS = "${BuildConfig.APPLICATION_ID}.DEVICE_ADDRESS"
const val CONNECTION_DATA = "${BuildConfig.APPLICATION_ID}.CONNECTION_DATA"
const val STREET_PASS = "${BuildConfig.APPLICATION_ID}.STREET_PASS"
const val STATUS = "${BuildConfig.APPLICATION_ID}.STATUS"
const val ACTION_DEVICE_PROCESSED = "${BuildConfig.APPLICATION_ID}.ACTION_DEVICE_PROCESSED"
const val ACTION_GATT_DISCONNECTED = "${BuildConfig.APPLICATION_ID}.ACTION_GATT_DISCONNECTED"
class ReadRequestPayload(
val v: Int,
val msg: String,
val org: String,
peripheral: PeripheralDevice
) {
val modelP = peripheral.modelP
fun getPayload(): ByteArray {
return gson.toJson(this).toByteArray(Charsets.UTF_8)
}
companion object {
val gson: Gson = GsonBuilder().disableHtmlEscaping().create()
fun createReadRequestPayload(dataBytes: ByteArray) : ReadRequestPayload {
val dataString = String(dataBytes, Charsets.UTF_8)
return gson.fromJson(dataString, ReadRequestPayload::class.java)
}
}
}
class WriteRequestPayload(
val v: Int,
val msg: String,
val org: String,
val modelC: String,
val rssi: Int,
val txPower: Int?
) {
fun getPayload(): ByteArray {
return gson.toJson(this).toByteArray(Charsets.UTF_8)
}
companion object {
val gson: Gson = GsonBuilder().disableHtmlEscaping().create()
fun createReadRequestPayload(dataBytes: ByteArray) : WriteRequestPayload {
val dataString = String(dataBytes, Charsets.UTF_8)
return gson.fromJson(dataString, WriteRequestPayload::class.java)
}
}
}

View file

@ -0,0 +1,286 @@
package au.gov.health.covidsafe.bluetooth.gatt
import android.bluetooth.*
import android.bluetooth.BluetoothGatt.GATT_FAILURE
import android.bluetooth.BluetoothGatt.GATT_SUCCESS
import android.content.Context
import au.gov.health.covidsafe.TracerApp
import au.gov.health.covidsafe.Utils
import au.gov.health.covidsafe.logging.CentralLog
import au.gov.health.covidsafe.streetpass.CentralDevice
import au.gov.health.covidsafe.streetpass.ConnectionRecord
import java.util.*
import kotlin.properties.Delegates
class GattServer constructor(val context: Context, serviceUUIDString: String) {
private val TAG = "GattServer"
private var bluetoothManager: BluetoothManager by Delegates.notNull()
private var serviceUUID: UUID by Delegates.notNull()
var bluetoothGattServer: BluetoothGattServer? = null
init {
bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
this.serviceUUID = UUID.fromString(serviceUUIDString)
}
private val gattServerCallback = object : BluetoothGattServerCallback() {
val writeDataPayload: MutableMap<String, ByteArray> = HashMap()
val readPayloadMap: MutableMap<String, ByteArray> = HashMap()
override fun onConnectionStateChange(device: BluetoothDevice?, status: Int, newState: Int) {
when (newState) {
BluetoothProfile.STATE_CONNECTED -> {
CentralLog.i(TAG, "${device?.address} Connected to local GATT server")
device?.let {
val b = bluetoothManager.getConnectedDevices(BluetoothProfile.GATT)
.contains(device)
}
}
BluetoothProfile.STATE_DISCONNECTED -> {
CentralLog.i(TAG, "${device?.address} Disconnected from local GATT server.")
device?.let {
Utils.broadcastDeviceDisconnected(context, device)
}
}
else -> {
CentralLog.i(TAG, "Connection status: $newState - ${device?.address}")
}
}
}
override fun onCharacteristicReadRequest(
device: BluetoothDevice?,
requestId: Int,
offset: Int,
characteristic: BluetoothGattCharacteristic?
) {
device?.let {
CentralLog.i(TAG, "onCharacteristicReadRequest from ${device.address}")
if (serviceUUID == characteristic?.uuid) {
if (Utils.bmValid(context)) {
val base = readPayloadMap.getOrPut(device.address, {
ReadRequestPayload(
v = TracerApp.protocolVersion,
msg = TracerApp.thisDeviceMsg(),
org = TracerApp.ORG,
peripheral = TracerApp.asPeripheralDevice()
).getPayload()
})
val value = base.copyOfRange(offset, base.size)
CentralLog.i(
TAG,
"onCharacteristicReadRequest from ${device.address} - $requestId- $offset - ${String(
value,
Charsets.UTF_8
)}"
)
bluetoothGattServer?.sendResponse(device, requestId, GATT_SUCCESS, 0, value)
} else {
CentralLog.i(
TAG,
"onCharacteristicReadRequest from ${device.address} - $requestId- $offset - BM Expired"
)
bluetoothGattServer?.sendResponse(
device,
requestId,
GATT_FAILURE,
0,
ByteArray(0)
)
}
} else {
CentralLog.i(TAG, "incorrect serviceUUID from ${device.address}")
bluetoothGattServer?.sendResponse(device, requestId, GATT_SUCCESS, 0, null)
}
}
if (device == null) {
CentralLog.i(TAG, "No device")
}
}
override fun onCharacteristicWriteRequest(
device: BluetoothDevice?,
requestId: Int,
characteristic: BluetoothGattCharacteristic,
preparedWrite: Boolean,
responseNeeded: Boolean,
offset: Int,
value: ByteArray?
) {
device?.let {
CentralLog.i(
TAG,
"onCharacteristicWriteRequest - ${device.address} - preparedWrite: $preparedWrite"
)
CentralLog.i(
TAG,
"onCharacteristicWriteRequest from ${device.address} - $requestId - $offset"
)
if (serviceUUID == characteristic.uuid) {
var valuePassed = ""
value?.let {
valuePassed = String(value, Charsets.UTF_8)
}
CentralLog.i(
TAG,
"onCharacteristicWriteRequest from ${device.address} - $valuePassed"
)
if (value != null) {
var dataBuffer = writeDataPayload[device.address]
if (dataBuffer == null) {
dataBuffer = ByteArray(0)
}
dataBuffer = dataBuffer.plus(value)
writeDataPayload[device.address] = dataBuffer
CentralLog.i(
TAG,
"Accumulated characteristic: ${String(
dataBuffer,
Charsets.UTF_8
)}"
)
if (responseNeeded) {
CentralLog.i(TAG, "Sending response offset: ${dataBuffer.size}")
bluetoothGattServer?.sendResponse(
device,
requestId,
GATT_SUCCESS,
dataBuffer.size,
value
)
}
}
} else {
CentralLog.i(TAG, "no data from ${device.address}")
bluetoothGattServer?.sendResponse(device, requestId, GATT_SUCCESS, 0, null)
}
if (!preparedWrite) {
CentralLog.i(
TAG,
"onCharacteristicWriteRequest - ${device.address} - preparedWrite: $preparedWrite"
)
saveDataSaved(device)
bluetoothGattServer?.sendResponse(device, requestId, GATT_SUCCESS, 0, null)
}
}
if (device == null) {
CentralLog.e(TAG, "Write stopped - no device")
}
}
override fun onExecuteWrite(device: BluetoothDevice, requestId: Int, execute: Boolean) {
super.onExecuteWrite(device, requestId, execute)
val data = writeDataPayload[device.address]
data.let { dataBuffer ->
if (dataBuffer != null) {
CentralLog.i(
TAG,
"onExecuteWrite - $requestId- ${device.address} - ${String(
dataBuffer,
Charsets.UTF_8
)}"
)
saveDataSaved(device)
bluetoothGattServer?.sendResponse(device, requestId, GATT_SUCCESS, 0, null)
} else {
bluetoothGattServer?.sendResponse(device, requestId, GATT_FAILURE, 0, null)
}
}
}
fun saveDataSaved(device: BluetoothDevice) {
val data = writeDataPayload[device.address]
data?.let {
try {
val dataWritten = WriteRequestPayload.createReadRequestPayload(data)
device.let {
val centralDevice: CentralDevice?
try {
centralDevice = CentralDevice(dataWritten.modelC, device.address)
val connectionRecord = ConnectionRecord(
version = dataWritten.v,
msg = dataWritten.msg,
org = dataWritten.org,
peripheral = TracerApp.asPeripheralDevice(),
central = centralDevice,
rssi = dataWritten.rssi,
txPower = dataWritten.txPower
)
Utils.broadcastStreetPassReceived(
context,
connectionRecord
)
} catch (e: Throwable) {
CentralLog.e(TAG, "caught error here ${e.message}")
}
}
} catch (e: Throwable) {
CentralLog.e(TAG, "Failed to save write payload - ${e.message}")
}
Utils.broadcastDeviceProcessed(context, device.address)
writeDataPayload.remove(device.address)
readPayloadMap.remove(device.address)
}
}
}
fun startServer(): Boolean {
bluetoothGattServer = bluetoothManager.openGattServer(context, gattServerCallback)
bluetoothGattServer?.let {
it.clearServices()
return true
}
return false
}
fun addService(service: GattService) {
bluetoothGattServer?.addService(service.gattService)
}
fun stop() {
try {
bluetoothGattServer?.clearServices()
bluetoothGattServer?.close()
} catch (e: Throwable) {
CentralLog.e(TAG, "GATT server can't be closed elegantly ${e.localizedMessage}")
}
}
}

View file

@ -0,0 +1,34 @@
package au.gov.health.covidsafe.bluetooth.gatt
import android.bluetooth.BluetoothGattCharacteristic
import android.bluetooth.BluetoothGattService
import android.content.Context
import java.util.*
import kotlin.properties.Delegates
class GattService constructor(val context: Context, serviceUUIDString: String) {
private var serviceUUID = UUID.fromString(serviceUUIDString)
var gattService: BluetoothGattService by Delegates.notNull()
private var devicePropertyCharacteristic: BluetoothGattCharacteristic by Delegates.notNull()
init {
gattService = BluetoothGattService(serviceUUID, BluetoothGattService.SERVICE_TYPE_PRIMARY)
devicePropertyCharacteristic = BluetoothGattCharacteristic(
serviceUUID,
BluetoothGattCharacteristic.PROPERTY_READ or BluetoothGattCharacteristic.PROPERTY_WRITE,
BluetoothGattCharacteristic.PERMISSION_READ or BluetoothGattCharacteristic.PERMISSION_WRITE
)
gattService.addCharacteristic(devicePropertyCharacteristic)
}
fun setValue(value: String) {
setValue(value.toByteArray(Charsets.UTF_8))
}
fun setValue(value: ByteArray) {
devicePropertyCharacteristic.value = value
}
}

View file

@ -0,0 +1,25 @@
package au.gov.health.covidsafe.boot
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import au.gov.health.covidsafe.Utils
import au.gov.health.covidsafe.logging.CentralLog
class StartOnBootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (Intent.ACTION_BOOT_COMPLETED == intent.action) {
CentralLog.d("StartOnBootReceiver", "boot completed received")
try {
CentralLog.d("StartOnBootReceiver", "Attempting to start service")
Utils.scheduleStartMonitoringService(context, 500)
} catch (e: Throwable) {
CentralLog.e("StartOnBootReceiver", e.localizedMessage)
e.printStackTrace()
}
}
}
}

View file

@ -0,0 +1,8 @@
package au.gov.health.covidsafe.extensions
import android.os.Bundle
import androidx.fragment.app.Fragment
import androidx.navigation.Navigator
import androidx.navigation.fragment.NavHostFragment
fun Fragment.navigateTo(actionId: Int, bundle: Bundle? = null, navigatorExtras: Navigator.Extras? = null) = NavHostFragment.findNavController(this).navigate(actionId, bundle, null, navigatorExtras)

View file

@ -0,0 +1,13 @@
package au.gov.health.covidsafe.extensions
import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
fun Context.isInternetAvailable(): Boolean {
val connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager?
val capabilities = connectivityManager?.getNetworkCapabilities(connectivityManager.activeNetwork)
return capabilities != null && (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) ||
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) ||
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET))
}

View file

@ -0,0 +1,137 @@
package au.gov.health.covidsafe.extensions
import android.Manifest.permission.ACCESS_FINE_LOCATION
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothManager
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.PowerManager
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.NotificationManagerCompat
import androidx.fragment.app.Fragment
import pub.devrel.easypermissions.AppSettingsDialog
import pub.devrel.easypermissions.EasyPermissions
import pub.devrel.easypermissions.PermissionRequest
import au.gov.health.covidsafe.R
import au.gov.health.covidsafe.Utils
const val REQUEST_ENABLE_BT = 123
const val LOCATION = 345
const val BATTERY_OPTIMISER = 789
fun Fragment.requestAllPermissions(onEndCallback: () -> Unit) {
if (isBlueToothEnabled() ?: true) {
requestFineLocationAndCheckBleSupportThenNextPermission(onEndCallback)
} else {
requestBlueToothPermissionThenNextPermission()
}
}
fun Fragment.requestBlueToothPermissionThenNextPermission() {
val enableBtIntent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)
startActivityForResult(enableBtIntent, REQUEST_ENABLE_BT)
}
fun Fragment.checkBLESupport() {
if (BluetoothAdapter.getDefaultAdapter()?.isMultipleAdvertisementSupported?.not() ?: false) {
activity?.let {
Utils.stopBluetoothMonitoringService(it)
}
}
}
private fun Fragment.requestFineLocationAndCheckBleSupportThenNextPermission(onEndCallback: () -> Unit) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
activity?.let {
when {
EasyPermissions.hasPermissions(it, ACCESS_FINE_LOCATION) -> {
checkBLESupport()
excludeFromBatteryOptimization(onEndCallback)
}
else -> {
EasyPermissions.requestPermissions(
PermissionRequest.Builder(this, LOCATION, ACCESS_FINE_LOCATION)
.setRationale(R.string.permission_location_rationale)
.build())
}
}
}
} else {
checkBLESupport()
}
}
fun Fragment.excludeFromBatteryOptimization(onEndCallback: (() -> Unit)? = null) {
activity?.let {
val powerManager =
it.getSystemService(AppCompatActivity.POWER_SERVICE) as PowerManager
val packageName = it.packageName
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val intent = Utils.getBatteryOptimizerExemptionIntent(packageName)
if (!powerManager.isIgnoringBatteryOptimizations(packageName)) {
//check if there's any activity that can handle this
if (Utils.canHandleIntent(intent, it.packageManager)) {
this.startActivityForResult(intent, BATTERY_OPTIMISER)
} else {
//no way of handling battery optimizer
onEndCallback?.invoke()
}
} else {
onEndCallback?.invoke()
}
}
}
}
fun Fragment.isBlueToothEnabled(): Boolean? {
val bluetoothManager = activity?.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager?
return bluetoothManager?.adapter?.isEnabled
}
fun Fragment.isPushNotificationEnabled(): Boolean? {
return activity?.let { activity ->
NotificationManagerCompat.from(activity).areNotificationsEnabled()
}
}
fun Fragment.isFineLocationEnabled(): Boolean? {
return activity?.let { activity ->
EasyPermissions.hasPermissions(activity, ACCESS_FINE_LOCATION)
}
}
fun Fragment.isNonBatteryOptimizationAllowed(): Boolean? {
return activity?.let { activity ->
val powerManager = activity.getSystemService(AppCompatActivity.POWER_SERVICE) as PowerManager?
val packageName = activity.packageName
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
powerManager?.isIgnoringBatteryOptimizations(packageName) ?: true
} else {
null
}
} ?: run {
null
}
}
fun Fragment.askForLocationPermission() {
activity?.let {
when {
EasyPermissions.hasPermissions(it, ACCESS_FINE_LOCATION) -> {
}
EasyPermissions.somePermissionPermanentlyDenied(this, listOf(ACCESS_FINE_LOCATION)) -> {
AppSettingsDialog.Builder(this).build().show()
}
else -> {
EasyPermissions.requestPermissions(
PermissionRequest.Builder(this, LOCATION, ACCESS_FINE_LOCATION)
.setRationale(R.string.permission_location_rationale)
.build())
}
}
}
}

View file

@ -0,0 +1,30 @@
package au.gov.health.covidsafe.extensions
import android.text.SpannableString
import android.text.Spanned
import android.text.style.URLSpan
import android.widget.TextView
import androidx.core.content.ContextCompat
import au.gov.health.covidsafe.R
fun TextView.toHyperlink(textToHyperLink: String? = null, onClick: () -> Unit) {
val text = this.text
val spannableString = SpannableString(text)
val startIndex = if (textToHyperLink.isNullOrEmpty()) {
0
} else {
text.indexOf(textToHyperLink)
}
val endIndex = if (textToHyperLink.isNullOrEmpty()) {
spannableString.length
} else {
text.indexOf(textToHyperLink) + textToHyperLink.length
}
spannableString.setSpan(URLSpan(""), startIndex, endIndex, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
this.setText(spannableString, TextView.BufferType.SPANNABLE)
this.setLinkTextColor(ContextCompat.getColor(context, R.color.dark_green))
this.setOnClickListener {
onClick.invoke()
}
}

View file

@ -0,0 +1,43 @@
package au.gov.health.covidsafe.factory
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import au.gov.health.covidsafe.BuildConfig
import au.gov.health.covidsafe.networking.service.AwsClient
interface NetworkFactory {
companion object {
private val logging = HttpLoggingInterceptor()
.setLevel(HttpLoggingInterceptor.Level.BODY)
val awsClient: AwsClient by lazy {
RetrofitServiceGenerator.createService(AwsClient::class.java)
}
val okHttpClient: OkHttpClient by lazy {
val builder = OkHttpClient.Builder()
if (!builder.interceptors().contains(logging) && BuildConfig.DEBUG) {
builder.addInterceptor(logging)
}
builder.build()
}
}
}
object RetrofitServiceGenerator {
private val builder = Retrofit.Builder()
.baseUrl(BuildConfig.BASE_URL)
.addConverterFactory(GsonConverterFactory.create())
private var retrofit = builder.build()
fun <S> createService(
serviceClass: Class<S>): S {
builder.client(NetworkFactory.okHttpClient)
retrofit = builder.build()
return retrofit.create(serviceClass)
}
}

View file

@ -0,0 +1,14 @@
package au.gov.health.covidsafe.interactor
sealed class Either<out F, out S> {
inline fun <T> fold(failed: (F) -> T, succeeded: (S) -> T): T =
when (this) {
is Failure -> failed(failure)
is Success -> succeeded(success)
}
}
data class Failure<out F>(val failure: F) : Either<F, Nothing>()
data class Success<out S>(val success: S) : Either<Nothing, S>()

View file

@ -0,0 +1,76 @@
package au.gov.health.covidsafe.interactor
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleObserver
import androidx.lifecycle.OnLifecycleEvent
import kotlinx.coroutines.*
import retrofit2.Response
import kotlin.math.pow
private val RETRIES_LIMIT = 3
abstract class UseCase<out Type, in Params>(lifecycle: Lifecycle) : CoroutineScope by MainScope(), LifecycleObserver where Type : Any? {
private var job: Job = Job()
init {
lifecycle.addObserver(this)
}
abstract suspend fun run(params: Params): Either<Exception, Type>
operator fun invoke(params: Params, onSuccess: (Type) -> Unit, onFailure: (Exception) -> Unit) {
job.cancel()
job = launch(context = coroutineContext) {
val result = async(context = Dispatchers.IO) {
run(params)
}
result.await().fold(
failed = { onFailure(it) },
succeeded = { onSuccess(it) }
)
}
}
@OnLifecycleEvent(Lifecycle.Event.ON_STOP)
fun onStop() {
job.cancel()
}
@OnLifecycleEvent(Lifecycle.Event.ON_DESTROY)
fun onDestroy() {
cancel()
}
protected suspend fun <S> retryRetrofitCall(call: () -> Response<S>?): Response<S>? {
var response = call.invoke()
var retryCount = 0
while ((response == null || (!response.isSuccessful && response.code() != 403) || response.body() == null) && retryCount < RETRIES_LIMIT) {
val interval = 2.toDouble().pow(retryCount.toDouble()).toLong() * 1000
delay(interval)
response = call.invoke()
retryCount++
}
return response
}
protected suspend fun retryOkhttpCall(call: () -> okhttp3.Response?): okhttp3.Response? {
var response = call.invoke()
var retryCount = 0
while ((response == null || !response.isSuccessful || response.body == null) && retryCount < RETRIES_LIMIT) {
val interval = 2.toDouble().pow(retryCount.toDouble()).toLong() * 1000
delay(interval)
response = call.invoke()
retryCount++
}
return if (response != null && response.isSuccessful) {
response
} else {
null
}
}
object None
}

View file

@ -0,0 +1,60 @@
package au.gov.health.covidsafe.interactor.usecase
import androidx.lifecycle.Lifecycle
import au.gov.health.covidsafe.interactor.Either
import au.gov.health.covidsafe.interactor.Failure
import au.gov.health.covidsafe.interactor.Success
import au.gov.health.covidsafe.interactor.UseCase
import au.gov.health.covidsafe.logging.CentralLog
import au.gov.health.covidsafe.networking.request.OTPChallengeRequest
import au.gov.health.covidsafe.networking.response.OTPChallengeResponse
import au.gov.health.covidsafe.networking.service.AwsClient
class GetOnboardingOtp(private val awsClient: AwsClient, lifecycle: Lifecycle) : UseCase<OTPChallengeResponse, GetOtpParams>(lifecycle) {
private val TAG = this.javaClass.simpleName
override suspend fun run(params: GetOtpParams): Either<Exception, OTPChallengeResponse> {
return try {
val response = awsClient.initiateAuth(
OTPChallengeRequest(params.phoneNumber,
params.deviceId,
params.postCode,
params.age,
params.name)).execute()
when {
response.code() == 200 -> {
response.body()?.let { body ->
CentralLog.d(TAG, "onCodeSent: ${response.body()?.challengeName}")
Success(body)
} ?: run {
CentralLog.d(TAG, "AWSAuthInvalidBody")
Failure(GetOnboardingOtpException.GetOtpServiceException(response.code()))
}
}
response.code() == 400 -> {
CentralLog.d(TAG, "AWSAuthInvalidNumber")
Failure(GetOnboardingOtpException.GetOtpInvalidNumberException)
}
else -> {
CentralLog.d(TAG, "AWSAuthServiceError")
Failure(GetOnboardingOtpException.GetOtpServiceException(response.code()))
}
}
} catch (e: Exception) {
CentralLog.d(TAG, "AWSAuthInvalidChallengeRequest", e)
Failure(GetOnboardingOtpException.GetOtpServiceException())
}
}
}
data class GetOtpParams(internal val phoneNumber: String,
internal val deviceId: String,
internal val postCode: String?,
internal val age: String?,
internal val name: String?)
sealed class GetOnboardingOtpException : Exception() {
class GetOtpServiceException(val code: Int? = null) : GetOnboardingOtpException()
object GetOtpInvalidNumberException : GetOnboardingOtpException()
}

View file

@ -0,0 +1,34 @@
package au.gov.health.covidsafe.interactor.usecase
import androidx.lifecycle.Lifecycle
import au.gov.health.covidsafe.interactor.Either
import au.gov.health.covidsafe.interactor.Failure
import au.gov.health.covidsafe.interactor.Success
import au.gov.health.covidsafe.interactor.UseCase
import au.gov.health.covidsafe.logging.CentralLog
import au.gov.health.covidsafe.networking.response.UploadOTPResponse
import au.gov.health.covidsafe.networking.service.AwsClient
class GetUploadOtp(private val awsClient: AwsClient, lifecycle: Lifecycle)
: UseCase<UploadOTPResponse?, String>(lifecycle) {
private val TAG = this.javaClass.simpleName
override suspend fun run(params: String): Either<Exception, UploadOTPResponse?> {
return try {
val response = awsClient.requestUploadOtp("Bearer $params").execute()
return if (response.code() == 200) {
CentralLog.d(TAG, "onCodeUpload")
Success(response.body())
} else {
Failure(GetUploadOtpException.GetUploadOtpServiceException(response.code()))
}
} catch (e: Exception) {
Failure(e)
}
}
}
sealed class GetUploadOtpException : Exception() {
class GetUploadOtpServiceException(val code: Int?) : GetUploadOtpException()
}

View file

@ -0,0 +1,69 @@
package au.gov.health.covidsafe.interactor.usecase
import android.content.Context
import androidx.lifecycle.Lifecycle
import kotlinx.coroutines.delay
import retrofit2.Response
import au.gov.health.covidsafe.Preference
import au.gov.health.covidsafe.Utils
import au.gov.health.covidsafe.interactor.Either
import au.gov.health.covidsafe.interactor.Failure
import au.gov.health.covidsafe.interactor.Success
import au.gov.health.covidsafe.interactor.UseCase
import au.gov.health.covidsafe.networking.response.BroadcastMessageResponse
import au.gov.health.covidsafe.networking.service.AwsClient
import kotlin.math.pow
class UpdateBroadcastMessageAndPerformScanWithExponentialBackOff(private val awsClient: AwsClient,
private val context: Context,
lifecycle: Lifecycle) : UseCase<BroadcastMessageResponse, Void?>(lifecycle) {
private val TAG = this.javaClass.simpleName
private val RETRIES_LIMIT = 3
override suspend fun run(params: Void?): Either<Exception, BroadcastMessageResponse> {
val jwtToken = Preference.getEncrypterJWTToken(context)
return jwtToken?.let { jwtToken ->
var response = call(jwtToken)
var retryCount = 0
while ((response == null || !response.isSuccessful || response.body() == null) && retryCount < RETRIES_LIMIT) {
val interval = 2.toDouble().pow(retryCount.toDouble()).toLong() * 1000
delay(interval)
response = call(jwtToken)
retryCount++
}
if (response != null && response.isSuccessful) {
response.body()?.let { broadcastMessageResponse ->
if (broadcastMessageResponse.tempId.isNullOrEmpty()) {
Failure(Exception())
} else {
val expiryTime = broadcastMessageResponse.expiryTime
val expiry = expiryTime?.toLongOrNull() ?: 0
Preference.putExpiryTimeInMillis(context, expiry * 1000)
val refreshTime = broadcastMessageResponse.refreshTime
val refresh = refreshTime?.toLongOrNull() ?: 0
Preference.putNextFetchTimeInMillis(context, refresh * 1000)
Utils.storeBroadcastMessage(context, broadcastMessageResponse.tempId)
Success(broadcastMessageResponse)
}
} ?: run {
Failure(Exception())
}
} else {
Failure(Exception())
}
} ?: run {
return Failure(Exception())
}
}
private fun call(jwtToken: String): Response<BroadcastMessageResponse>? {
return try {
awsClient.getTempId("Bearer $jwtToken").execute()
} catch (e: Exception) {
null
}
}
}

View file

@ -0,0 +1,86 @@
package au.gov.health.covidsafe.interactor.usecase
import android.content.Context
import androidx.lifecycle.Lifecycle
import au.gov.health.covidsafe.Preference
import au.gov.health.covidsafe.TracerApp
import au.gov.health.covidsafe.interactor.Either
import au.gov.health.covidsafe.interactor.Failure
import au.gov.health.covidsafe.interactor.Success
import au.gov.health.covidsafe.interactor.UseCase
import au.gov.health.covidsafe.logging.CentralLog
import au.gov.health.covidsafe.networking.service.AwsClient
import au.gov.health.covidsafe.streetpass.persistence.StreetPassRecordStorage
import au.gov.health.covidsafe.ui.upload.model.ExportData
import com.google.gson.Gson
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
class UploadData(private val awsClient: AwsClient,
private val okHttpClient: OkHttpClient,
private val context: Context?,
lifecycle: Lifecycle)
: UseCase<UseCase.None, String>(lifecycle) {
private val TAG = this.javaClass.simpleName
override suspend fun run(params: String): Either<Exception, None> {
val jwtToken = Preference.getEncrypterJWTToken(context)
return jwtToken?.let { jwtToken ->
try {
val initialUploadResponse = retryRetrofitCall {
awsClient.initiateUpload("Bearer $jwtToken", params).execute()
}
if (initialUploadResponse == null) {
Failure(UploadDataException.UploadDataIncorrectPinException)
} else if (initialUploadResponse.isSuccessful) {
val uploadLink = initialUploadResponse.body()?.uploadLink
if (uploadLink.isNullOrEmpty()) {
Failure(Exception())
} else {
zipAndUploadData(uploadLink)
}
} else if (initialUploadResponse.code() == 400) {
Failure(UploadDataException.UploadDataIncorrectPinException)
} else if (initialUploadResponse.code() == 403) {
Failure(UploadDataException.UploadDataJwtExpiredException)
} else {
Failure(Exception())
}
} catch (e: Exception) {
Failure(e)
}
} ?: run {
return Failure(Exception())
}
}
private suspend fun zipAndUploadData(uploadLink: String): Either<Exception, None> {
val exportedData = ExportData(StreetPassRecordStorage(TracerApp.AppContext).getAllRecords())
CentralLog.d(TAG, "records: ${exportedData.records}")
val jsonData = Gson().toJson(exportedData)
val request = Request.Builder()
.url(uploadLink)
.put(jsonData.toRequestBody(null))
.build()
return try {
val response = retryOkhttpCall { okHttpClient.newCall(request).execute() }
return if (response == null) {
Failure(Exception())
} else {
Success(None)
}
} catch (e: Exception) {
Failure(Exception())
}
}
}
sealed class UploadDataException : Exception() {
object UploadDataIncorrectPinException : UploadDataException()
object UploadDataJwtExpiredException : UploadDataException()
}

View file

@ -0,0 +1,85 @@
package au.gov.health.covidsafe.logging
import android.os.Build
import android.os.PowerManager
import android.util.Log
import au.gov.health.covidsafe.BuildConfig
class CentralLog {
companion object {
private var pm: PowerManager? = null
fun setPowerManager(powerManager: PowerManager) {
pm = powerManager
}
private fun shouldLog(): Boolean {
return BuildConfig.DEBUG
}
private fun getIdleStatus(): String {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
return if (true == pm?.isDeviceIdleMode) {
" IDLE "
} else {
" NOT-IDLE "
}
}
return " NO-DOZE-FEATURE "
}
fun d(tag: String, message: String) {
if (!shouldLog()) {
return
}
Log.d(tag, getIdleStatus() + message)
}
fun d(tag: String, message: String, e: Throwable?) {
if (!shouldLog()) {
return
}
Log.d(tag, getIdleStatus() + message, e)
}
fun w(tag: String, message: String) {
if (!shouldLog()) {
return
}
Log.w(tag, getIdleStatus() + message)
}
fun i(tag: String, message: String) {
if (!shouldLog()) {
return
}
Log.i(tag, getIdleStatus() + message)
}
fun e(tag: String, message: String) {
if (!shouldLog()) {
return
}
Log.e(tag, getIdleStatus() + message)
}
fun e(tag: String, message: String, exception: Exception) {
if (!shouldLog()) {
return
}
Log.e(tag, getIdleStatus() + message, exception)
}
}
}

View file

@ -0,0 +1,6 @@
package au.gov.health.covidsafe.networking.request
import androidx.annotation.Keep
@Keep
data class AuthChallengeRequest(val session: String?, val code: String?)

View file

@ -0,0 +1,10 @@
package au.gov.health.covidsafe.networking.request
import androidx.annotation.Keep
@Keep
data class OTPChallengeRequest(val phone_number: String,
val device_id: String,
val postcode: String?,
val age: String?,
val name: String?)

View file

@ -0,0 +1,6 @@
package au.gov.health.covidsafe.networking.response
import androidx.annotation.Keep
@Keep
data class AuthChallengeResponse(val token: String, val uuid: String, val token_expiry: String, val pin: String)

View file

@ -0,0 +1,6 @@
package au.gov.health.covidsafe.networking.response
import androidx.annotation.Keep
@Keep
data class BroadcastMessageResponse(val tempId: String?, val expiryTime: String?, val refreshTime: String?)

View file

@ -0,0 +1,9 @@
package au.gov.health.covidsafe.networking.response
import androidx.annotation.Keep
import com.google.gson.annotations.SerializedName
@Keep
data class InitiateUploadResponse(@SerializedName("UploadLink") val uploadLink: String,
@SerializedName("ExpiresIn") val expiresIn: String,
@SerializedName("UploadPrefix") val uploadPrefix: String)

View file

@ -0,0 +1,6 @@
package au.gov.health.covidsafe.networking.response
import androidx.annotation.Keep
@Keep
data class OTPChallengeResponse(val session: String, val challengeName: String)

View file

@ -0,0 +1,6 @@
package au.gov.health.covidsafe.networking.response
import androidx.annotation.Keep
@Keep
class UploadOTPResponse

View file

@ -0,0 +1,33 @@
package au.gov.health.covidsafe.networking.service
import au.gov.health.covidsafe.BuildConfig
import au.gov.health.covidsafe.networking.request.AuthChallengeRequest
import au.gov.health.covidsafe.networking.request.OTPChallengeRequest
import au.gov.health.covidsafe.networking.response.*
import retrofit2.Call
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.POST
interface AwsClient {
@POST(BuildConfig.END_POINT_PREFIX + "/initiateAuth")
fun initiateAuth(@Body body : OTPChallengeRequest) : Call<OTPChallengeResponse>
@POST(BuildConfig.END_POINT_PREFIX + "/respondToAuthChallenge")
fun respondToAuthChallenge(@Body body : AuthChallengeRequest) : Call<AuthChallengeResponse>
@GET(BuildConfig.END_POINT_PREFIX + "/getTempId")
fun getTempId(@Header("Authorization") jwtToken: String?) : Call<BroadcastMessageResponse>
@GET(BuildConfig.END_POINT_PREFIX + "/initiateDataUpload")
fun initiateUpload(@Header("Authorization") jwtToken: String?,@Header("pin") pin : String) : Call<InitiateUploadResponse>
@GET(BuildConfig.END_POINT_PREFIX + "/initiateDataUpload")
fun initiateReUpload(@Header("Authorization") jwtToken: String?): Call<InitiateUploadResponse>
@GET(BuildConfig.END_POINT_PREFIX + "/requestUploadOtp")
fun requestUploadOtp(@Header("Authorization") jwtToken : String?) : Call<UploadOTPResponse>
}

View file

@ -0,0 +1,114 @@
package au.gov.health.covidsafe.notifications
import android.app.Notification
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import au.gov.health.covidsafe.HomeActivity
import au.gov.health.covidsafe.R
import au.gov.health.covidsafe.services.BluetoothMonitoringService.Companion.DAILY_UPLOAD_NOTIFICATION_CODE
import au.gov.health.covidsafe.services.BluetoothMonitoringService.Companion.PENDING_ACTIVITY
import au.gov.health.covidsafe.services.BluetoothMonitoringService.Companion.PENDING_WIZARD_REQ_CODE
class NotificationTemplates {
companion object {
fun getRunningNotification(context: Context, channel: String): Notification {
val intent = Intent(context, HomeActivity::class.java)
val activityPendingIntent = PendingIntent.getActivity(
context, PENDING_ACTIVITY,
intent, 0
)
val builder = NotificationCompat.Builder(context, channel)
.setContentTitle(context.getText(R.string.service_ok_title))
.setContentText(context.getText(R.string.service_ok_body))
.setOngoing(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setSmallIcon(R.drawable.ic_notification_icon)
.setContentIntent(activityPendingIntent)
.setTicker(context.getText(R.string.service_ok_body))
.setStyle(NotificationCompat.BigTextStyle().bigText(context.getText(R.string.service_ok_body)))
.setWhen(System.currentTimeMillis())
.setSound(null)
.setVibrate(null)
.setColor(ContextCompat.getColor(context, R.color.notification_tint))
return builder.build()
}
fun lackingThingsNotification(context: Context, channel: String): Notification {
val intent = Intent(context, HomeActivity::class.java)
intent.putExtra("page", 3)
val activityPendingIntent = PendingIntent.getActivity(
context, PENDING_WIZARD_REQ_CODE,
intent, 0
)
val builder = NotificationCompat.Builder(context, channel)
.setContentTitle(context.getText(R.string.service_not_ok_title))
.setContentText(context.getText(R.string.service_not_ok_body))
.setStyle(NotificationCompat.BigTextStyle().bigText(context.getText(R.string.service_not_ok_body)))
.setOngoing(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setSmallIcon(R.drawable.ic_notification_warning)
.setTicker(context.getText(R.string.service_not_ok_body))
.addAction(
R.drawable.ic_notification_setting,
context.getText(R.string.service_not_ok_action),
activityPendingIntent
)
.setContentIntent(activityPendingIntent)
.setWhen(System.currentTimeMillis())
.setSound(null)
.setVibrate(null)
.setColor(ContextCompat.getColor(context, R.color.notification_tint))
return builder.build()
}
fun getUploadReminder(context: Context, channel: String): Notification {
val intent = Intent(context, HomeActivity::class.java)
intent.putExtra("uploadNotification", true)
val activityPendingIntent = PendingIntent.getActivity(
context, DAILY_UPLOAD_NOTIFICATION_CODE,
intent, PendingIntent.FLAG_UPDATE_CURRENT
)
val builder = NotificationCompat.Builder(context, channel)
.setContentTitle(context.getText(R.string.upload_your_data_title))
.setContentText(context.getText(R.string.upload_your_data_description))
.setOngoing(false)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setSmallIcon(R.drawable.ic_notification_icon)
.setTicker(context.getText(R.string.upload_your_data_description))
.setStyle(NotificationCompat.BigTextStyle().bigText(context.getText(R.string.upload_your_data_description)))
.setAutoCancel(true)
.addAction(
R.drawable.ic_notification_setting,
context.getText(R.string.upload_data_action),
activityPendingIntent
)
.setContentIntent(activityPendingIntent)
.setWhen(System.currentTimeMillis())
.setSound(null)
.setVibrate(null)
.setColor(ContextCompat.getColor(context, R.color.notification_tint))
return builder.build()
}
}
}

View file

@ -0,0 +1,64 @@
package au.gov.health.covidsafe.receivers
import android.app.AlarmManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import au.gov.health.covidsafe.logging.CentralLog
import au.gov.health.covidsafe.services.BluetoothMonitoringService.Companion.PENDING_PRIVACY_CLEANER_CODE
import au.gov.health.covidsafe.services.SensorMonitoringService.Companion.TAG
import au.gov.health.covidsafe.status.persistence.StatusRecordStorage
import au.gov.health.covidsafe.streetpass.persistence.StreetPassRecordStorage
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import java.util.*
import kotlin.coroutines.CoroutineContext
class PrivacyCleanerReceiver : BroadcastReceiver(), CoroutineScope {
private val TAG = this.javaClass.simpleName
private var job: Job = Job()
override val coroutineContext: CoroutineContext
get() = Dispatchers.Default + job
companion object {
private fun getIntent(context: Context, requestCode: Int): PendingIntent? {
val intent = Intent(context, PrivacyCleanerReceiver::class.java)
return PendingIntent.getBroadcast(context, requestCode, intent, PendingIntent.FLAG_CANCEL_CURRENT)
}
fun startAlarm(context: Context) {
val pendingIntent = getIntent(context, PENDING_PRIVACY_CLEANER_CODE)
val alarm = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
alarm.setRepeating(AlarmManager.RTC, System.currentTimeMillis(), AlarmManager.INTERVAL_DAY, pendingIntent)
}
suspend fun cleanDb(context: Context) {
val twentyOneDaysAgo = Calendar.getInstance()
twentyOneDaysAgo.set(Calendar.HOUR_OF_DAY, 23)
twentyOneDaysAgo.set(Calendar.MINUTE, 59)
twentyOneDaysAgo.set(Calendar.SECOND, 59)
twentyOneDaysAgo.add(Calendar.DATE, -21)
val countStreetDeleted = StreetPassRecordStorage(context).deleteDataOlderThan(twentyOneDaysAgo.timeInMillis)
val countStatusDeleted = StatusRecordStorage(context).deleteDataOlderThan(twentyOneDaysAgo.timeInMillis)
CentralLog.i(TAG, "Street info deleted count : $countStreetDeleted")
CentralLog.i(TAG, "Status info deleted count : $countStatusDeleted")
}
}
override fun onReceive(context: Context, intent: Intent) {
launch {
cleanDb(context)
}
}
}

View file

@ -0,0 +1,23 @@
package au.gov.health.covidsafe.receivers
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import au.gov.health.covidsafe.Utils
import au.gov.health.covidsafe.logging.CentralLog
class UpgradeReceiver : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
try {
if (Intent.ACTION_MY_PACKAGE_REPLACED != intent!!.action) return
context?.let {
CentralLog.i("UpgradeReceiver", "Starting service from upgrade receiver")
Utils.startBluetoothMonitoringService(context)
}
} catch (e: Exception) {
CentralLog.e("UpgradeReceiver", "Unable to handle upgrade: ${e.localizedMessage}")
}
}
}

View file

@ -0,0 +1,53 @@
package au.gov.health.covidsafe.scheduler
import android.app.AlarmManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.SystemClock
object Scheduler {
fun scheduleServiceIntent(
requestCode: Int,
context: Context,
intent: Intent,
timeFromNowInMillis: Long
) {
val alarmMgr = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
val alarmIntent = PendingIntent.getService(
context,
requestCode,
intent,
PendingIntent.FLAG_UPDATE_CURRENT
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
alarmMgr.setExactAndAllowWhileIdle(
AlarmManager.ELAPSED_REALTIME_WAKEUP,
SystemClock.elapsedRealtime() + timeFromNowInMillis, alarmIntent
)
} else {
alarmMgr.set(
AlarmManager.ELAPSED_REALTIME_WAKEUP,
SystemClock.elapsedRealtime() + timeFromNowInMillis, alarmIntent
)
}
}
fun cancelServiceIntent(requestCode: Int, context: Context, intent: Intent) {
val alarmIntent =
PendingIntent.getService(
context,
requestCode,
intent,
PendingIntent.FLAG_UPDATE_CURRENT
)
alarmIntent.cancel()
}
}

View file

@ -0,0 +1,668 @@
package au.gov.health.covidsafe.services
import android.app.NotificationChannel
import android.app.NotificationManager
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothManager
import android.content.*
import android.os.Build
import android.os.IBinder
import android.os.PowerManager
import androidx.annotation.Keep
import androidx.lifecycle.LifecycleService
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import au.gov.health.covidsafe.BuildConfig
import au.gov.health.covidsafe.Preference
import au.gov.health.covidsafe.Utils
import au.gov.health.covidsafe.bluetooth.BLEAdvertiser
import au.gov.health.covidsafe.bluetooth.gatt.ACTION_RECEIVED_STATUS
import au.gov.health.covidsafe.bluetooth.gatt.ACTION_RECEIVED_STREETPASS
import au.gov.health.covidsafe.bluetooth.gatt.STATUS
import au.gov.health.covidsafe.bluetooth.gatt.STREET_PASS
import au.gov.health.covidsafe.factory.NetworkFactory
import au.gov.health.covidsafe.interactor.usecase.UpdateBroadcastMessageAndPerformScanWithExponentialBackOff
import au.gov.health.covidsafe.logging.CentralLog
import au.gov.health.covidsafe.notifications.NotificationTemplates
import au.gov.health.covidsafe.receivers.PrivacyCleanerReceiver
import au.gov.health.covidsafe.status.Status
import au.gov.health.covidsafe.status.persistence.StatusRecord
import au.gov.health.covidsafe.status.persistence.StatusRecordStorage
import au.gov.health.covidsafe.streetpass.ConnectionRecord
import au.gov.health.covidsafe.streetpass.StreetPassScanner
import au.gov.health.covidsafe.streetpass.StreetPassServer
import au.gov.health.covidsafe.streetpass.StreetPassWorker
import au.gov.health.covidsafe.streetpass.persistence.StreetPassRecord
import au.gov.health.covidsafe.streetpass.persistence.StreetPassRecordStorage
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import pub.devrel.easypermissions.EasyPermissions
import java.lang.ref.WeakReference
import kotlin.coroutines.CoroutineContext
@Keep
class BluetoothMonitoringService : LifecycleService(), CoroutineScope {
private var mNotificationManager: NotificationManager? = null
@Keep
private lateinit var serviceUUID: String
private var streetPassServer: StreetPassServer? = null
private var streetPassScanner: StreetPassScanner? = null
private var advertiser: BLEAdvertiser? = null
private var worker: StreetPassWorker? = null
private val streetPassReceiver = StreetPassReceiver()
private val statusReceiver = StatusReceiver()
private val bluetoothStatusReceiver = BluetoothStatusReceiver()
private lateinit var streetPassRecordStorage: StreetPassRecordStorage
private lateinit var statusRecordStorage: StatusRecordStorage
private var job: Job = Job()
override val coroutineContext: CoroutineContext
get() = Dispatchers.Main + job
private lateinit var commandHandler: CommandHandler
private lateinit var mService: SensorMonitoringService
private var mBound: Boolean = false
private lateinit var localBroadcastManager: LocalBroadcastManager
private val awsClient = NetworkFactory.awsClient
/** Defines callbacks for service binding, passed to bindService() */
private val connection = object : ServiceConnection {
override fun onServiceConnected(className: ComponentName, service: IBinder) {
// We've bound to LocalService, cast the IBinder and get LocalService instance
val binder = service as SensorMonitoringService.LocalBinder
mService = binder.getService()
mBound = true
}
override fun onServiceDisconnected(arg0: ComponentName) {
mBound = false
}
}
override fun onCreate() {
super.onCreate()
localBroadcastManager = LocalBroadcastManager.getInstance(this)
setup()
}
private fun setup() {
val pm = getSystemService(Context.POWER_SERVICE) as PowerManager
CentralLog.setPowerManager(pm)
commandHandler = CommandHandler(WeakReference(this))
CentralLog.d(TAG, "Creating service - BluetoothMonitoringService")
serviceUUID = BuildConfig.BLE_SSID
worker = StreetPassWorker(this.applicationContext)
unregisterReceivers()
registerReceivers()
streetPassRecordStorage = StreetPassRecordStorage(this.applicationContext)
statusRecordStorage = StatusRecordStorage(this.applicationContext)
PrivacyCleanerReceiver.startAlarm(this.applicationContext)
setupNotifications()
broadcastMessage = Utils.retrieveBroadcastMessage(this.applicationContext)
}
fun teardown() {
streetPassServer?.tearDown()
streetPassServer = null
streetPassScanner?.stopScan()
streetPassScanner = null
commandHandler.removeCallbacksAndMessages(null)
Utils.cancelBMUpdateCheck(this.applicationContext)
Utils.cancelNextScan(this.applicationContext)
Utils.cancelNextAdvertise(this.applicationContext)
}
private fun setupNotifications() {
val mNotificationManager: NotificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
// Android O requires a Notification Channel.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val name = CHANNEL_SERVICE
// Create the channel for the notification
val mChannel =
NotificationChannel(CHANNEL_ID, name, NotificationManager.IMPORTANCE_LOW)
mChannel.enableLights(false)
mChannel.enableVibration(true)
mChannel.vibrationPattern = longArrayOf(0L)
mChannel.setSound(null, null)
mChannel.setShowBadge(false)
// Set the Notification Channel for the Notification Manager.
mNotificationManager.createNotificationChannel(mChannel)
}
}
private fun hasLocationPermissions(): Boolean {
val perms = Utils.getRequiredPermissions()
return EasyPermissions.hasPermissions(this.applicationContext, *perms)
}
private fun isBluetoothEnabled(): Boolean {
var btOn = false
val bluetoothAdapter: BluetoothAdapter? by lazy(LazyThreadSafetyMode.NONE) {
val bluetoothManager = getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
bluetoothManager.adapter
}
bluetoothAdapter?.let {
btOn = it.isEnabled
}
return btOn
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId)
CentralLog.i(TAG, "Service onStartCommand")
// Bind to LocalService
Intent(this.applicationContext, SensorMonitoringService::class.java).also { intent ->
bindService(intent, connection, Context.BIND_AUTO_CREATE)
}
//check for permissions
if (!hasLocationPermissions() || !isBluetoothEnabled()) {
CentralLog.i(
TAG,
"location permission: ${hasLocationPermissions()} bluetooth: ${isBluetoothEnabled()}"
)
val notif =
NotificationTemplates.lackingThingsNotification(this.applicationContext, CHANNEL_ID)
startForeground(NOTIFICATION_ID, notif)
return START_STICKY
}
intent?.let {
val cmd = intent.getIntExtra(COMMAND_KEY, Command.INVALID.index)
runService(Command.findByValue(cmd))
return START_STICKY
}
if (intent == null) {
CentralLog.e(TAG, "Nothing in intent @ onStartCommand")
commandHandler.startBluetoothMonitoringService()
}
// Tells the system to not try to recreate the service after it has been killed.
return START_STICKY
}
fun runService(cmd: Command?) {
CentralLog.i(TAG, "Command is:${cmd?.string}")
//check for permissions
if (!hasLocationPermissions() || !isBluetoothEnabled()) {
CentralLog.i(
TAG,
"location permission: ${hasLocationPermissions()} bluetooth: ${isBluetoothEnabled()}"
)
val notif =
NotificationTemplates.lackingThingsNotification(this.applicationContext, CHANNEL_ID)
startForeground(NOTIFICATION_ID, notif)
return
}
when (cmd) {
Command.ACTION_START -> {
setupService()
actionStart()
Utils.scheduleNextHealthCheck(this.applicationContext, healthCheckInterval)
Utils.scheduleBMUpdateCheck(this.applicationContext, bmCheckInterval)
}
Command.ACTION_SCAN -> {
actionScan()
}
Command.ACTION_ADVERTISE -> {
actionAdvertise()
}
Command.ACTION_UPDATE_BM -> {
actionUpdateBm()
}
Command.ACTION_STOP -> {
actionStop()
}
Command.ACTION_SELF_CHECK -> {
actionHealthCheck()
}
else -> CentralLog.i(TAG, "Invalid command: $cmd. Nothing to do")
}
}
private fun actionStop() {
stopForeground(true)
stopSelf()
CentralLog.w(TAG, "Service Stopping")
}
private fun actionHealthCheck() {
Utils.scheduleNextHealthCheck(this.applicationContext, healthCheckInterval)
performHealthCheck()
}
private fun actionStart() {
if (Preference.isOnBoarded(this)) {
CentralLog.d(TAG, "Service Starting ")
startForeground(
NOTIFICATION_ID,
NotificationTemplates.getRunningNotification(
this.applicationContext,
CHANNEL_ID
)
)
//ensure BM is ready here
if (Preference.isOnBoarded(this) && Utils.needToUpdate(this.applicationContext) || broadcastMessage == null) {
//need to pull new BM
UpdateBroadcastMessageAndPerformScanWithExponentialBackOff(awsClient, applicationContext, lifecycle).invoke(
params = null,
onSuccess = {
broadcastMessage = it.tempId
setupCycles()
},
onFailure = {
}
)
} else if (Preference.isOnBoarded(this)) {
setupCycles()
}
}
}
private fun actionUpdateBm() {
Utils.scheduleBMUpdateCheck(this.applicationContext, bmCheckInterval)
CentralLog.i(TAG, "checking need to update BM")
if (Preference.isOnBoarded(this) && Utils.needToUpdate(this.applicationContext) || broadcastMessage == null) {
//need to pull new BM
UpdateBroadcastMessageAndPerformScanWithExponentialBackOff(awsClient, applicationContext, lifecycle).invoke(
params = null,
onSuccess = {
broadcastMessage = it.tempId
},
onFailure = {
}
)
} else {
CentralLog.i(TAG, "Don't need to update bm")
}
}
private fun calcPhaseShift(min: Long, max: Long): Long {
return (min + (Math.random() * (max - min))).toLong()
}
private fun actionScan() {
if (Preference.isOnBoarded(this) && Utils.needToUpdate(this.applicationContext) || broadcastMessage == null) {
//need to pull new BM
UpdateBroadcastMessageAndPerformScanWithExponentialBackOff(awsClient, applicationContext, lifecycle).invoke(
params = null,
onSuccess = {
broadcastMessage = it.tempId
performScanAndScheduleNextScan()
},
onFailure = {
}
)
} else if (Preference.isOnBoarded(this)) {
performScanAndScheduleNextScan()
}
}
private fun actionAdvertise() {
setupAdvertiser()
if (isBluetoothEnabled()) {
advertiser?.startAdvertising(advertisingDuration)
} else {
CentralLog.w(TAG, "Unable to start advertising, bluetooth is off")
}
commandHandler.scheduleNextAdvertise(advertisingDuration + advertisingGap)
}
private fun setupService() {
streetPassServer =
streetPassServer ?: StreetPassServer(this.applicationContext, serviceUUID)
setupScanner()
setupAdvertiser()
}
private fun setupScanner() {
streetPassScanner = streetPassScanner ?: StreetPassScanner(
this,
serviceUUID,
scanDuration
)
}
private fun setupAdvertiser() {
advertiser = advertiser ?: BLEAdvertiser(serviceUUID)
}
private fun setupCycles() {
setupScanCycles()
setupAdvertisingCycles()
}
private fun setupScanCycles() {
actionScan()
}
private fun setupAdvertisingCycles() {
actionAdvertise()
}
private fun performScanAndScheduleNextScan() {
setupScanner()
commandHandler.scheduleNextScan(
scanDuration + calcPhaseShift(
minScanInterval,
maxScanInterval
)
)
startScan()
}
private fun startScan() {
if (isBluetoothEnabled()) {
streetPassScanner?.let { scanner ->
if (!scanner.isScanning()) {
scanner.startScan()
} else {
CentralLog.e(TAG, "Already scanning!")
}
}
} else {
CentralLog.w(TAG, "Unable to start scan - bluetooth is off")
}
}
private fun performHealthCheck() {
CentralLog.i(TAG, "Performing self diagnosis")
if (!hasLocationPermissions() || !isBluetoothEnabled()) {
CentralLog.i(TAG, "no location permission")
val notif =
NotificationTemplates.lackingThingsNotification(this.applicationContext, CHANNEL_ID)
startForeground(NOTIFICATION_ID, notif)
return
}
startForeground(
NOTIFICATION_ID,
NotificationTemplates.getRunningNotification(
this.applicationContext,
CHANNEL_ID
)
)
//ensure our service is there
setupService()
if (!commandHandler.hasScanScheduled()) {
CentralLog.w(TAG, "Missing Scan Schedule - rectifying")
setupScanCycles()
} else {
CentralLog.w(TAG, "Scan Schedule present")
}
if (!commandHandler.hasAdvertiseScheduled()) {
CentralLog.w(TAG, "Missing Advertise Schedule - rectifying")
setupAdvertisingCycles()
} else {
CentralLog.w(
TAG,
"Advertise Schedule present. Should be advertising?: ${advertiser?.shouldBeAdvertising
?: false}. Is Advertising?: ${advertiser?.isAdvertising ?: false}"
)
}
}
override fun onDestroy() {
super.onDestroy()
CentralLog.i(TAG, "BluetoothMonitoringService destroyed - tearing down")
teardown()
unregisterReceivers()
worker?.terminateConnections()
worker?.unregisterReceivers()
job.cancel()
if (mBound) {
unbindService(connection)
mBound = false
}
CentralLog.i(TAG, "BluetoothMonitoringService destroyed")
}
private fun registerReceivers() {
val recordAvailableFilter = IntentFilter(ACTION_RECEIVED_STREETPASS)
localBroadcastManager.registerReceiver(streetPassReceiver, recordAvailableFilter)
val statusReceivedFilter = IntentFilter(ACTION_RECEIVED_STATUS)
localBroadcastManager.registerReceiver(statusReceiver, statusReceivedFilter)
val bluetoothStatusReceivedFilter = IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED)
registerReceiver(bluetoothStatusReceiver, bluetoothStatusReceivedFilter)
CentralLog.i(TAG, "Receivers registered")
}
private fun unregisterReceivers() {
try {
localBroadcastManager.unregisterReceiver(streetPassReceiver)
} catch (e: Throwable) {
CentralLog.w(TAG, "streetPassReceiver is not registered?")
}
try {
localBroadcastManager.unregisterReceiver(statusReceiver)
} catch (e: Throwable) {
CentralLog.w(TAG, "statusReceiver is not registered?")
}
try {
unregisterReceiver(bluetoothStatusReceiver)
} catch (e: Throwable) {
CentralLog.w(TAG, "bluetoothStatusReceiver is not registered?")
}
}
inner class BluetoothStatusReceiver : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
intent?.let {
val action = intent.action
if (action == BluetoothAdapter.ACTION_STATE_CHANGED) {
when (intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, -1)) {
BluetoothAdapter.STATE_TURNING_OFF -> {
CentralLog.d(TAG, "BluetoothAdapter.STATE_TURNING_OFF")
val notif = NotificationTemplates.lackingThingsNotification(
this@BluetoothMonitoringService.applicationContext,
CHANNEL_ID
)
startForeground(NOTIFICATION_ID, notif)
teardown()
}
BluetoothAdapter.STATE_OFF -> {
CentralLog.d(TAG, "BluetoothAdapter.STATE_OFF")
}
BluetoothAdapter.STATE_TURNING_ON -> {
CentralLog.d(TAG, "BluetoothAdapter.STATE_TURNING_ON")
}
BluetoothAdapter.STATE_ON -> {
CentralLog.d(TAG, "BluetoothAdapter.STATE_ON")
Utils.startBluetoothMonitoringService(this@BluetoothMonitoringService.applicationContext)
}
}
}
}
}
}
inner class StreetPassReceiver : BroadcastReceiver() {
private val TAG = "StreetPassReceiver"
override fun onReceive(context: Context, intent: Intent) {
if (ACTION_RECEIVED_STREETPASS == intent.action) {
val connRecord: ConnectionRecord = intent.getParcelableExtra(STREET_PASS)
CentralLog.d(
TAG,
"StreetPass received: $connRecord"
)
if (connRecord.msg.isNotEmpty()) {
if (mBound) {
val proximity = mService.proximity
val light = mService.light
CentralLog.d(
TAG,
"Sensor values just before saving StreetPassRecord: proximity=$proximity light=$light"
)
}
val record = StreetPassRecord(
v = connRecord.version,
msg = connRecord.msg,
org = connRecord.org,
modelP = connRecord.peripheral.modelP,
modelC = connRecord.central.modelC,
rssi = connRecord.rssi,
txPower = connRecord.txPower
)
launch{
CentralLog.d(
TAG,
"Coroutine - Saving StreetPassRecord: ${Utils.getDate(record.timestamp)} $record")
streetPassRecordStorage.saveRecord(record)
}
}
}
}
}
inner class StatusReceiver : BroadcastReceiver() {
private val TAG = "StatusReceiver"
override fun onReceive(context: Context, intent: Intent) {
if (ACTION_RECEIVED_STATUS == intent.action) {
val statusRecord: Status = intent.getParcelableExtra(STATUS)
CentralLog.d(TAG, "Status received: ${statusRecord.msg}")
if (statusRecord.msg.isNotEmpty()) {
val statusRecord = StatusRecord(statusRecord.msg)
launch {
statusRecordStorage.saveRecord(statusRecord)
}
}
}
}
}
enum class Command(val index: Int, val string: String) {
INVALID(-1, "INVALID"),
ACTION_START(0, "START"),
ACTION_SCAN(1, "SCAN"),
ACTION_STOP(2, "STOP"),
ACTION_ADVERTISE(3, "ADVERTISE"),
ACTION_SELF_CHECK(4, "SELF_CHECK"),
ACTION_UPDATE_BM(5, "UPDATE_BM");
companion object {
private val types = values().associate { it.index to it }
fun findByValue(value: Int) = types[value]
}
}
companion object {
private const val TAG = "BTMService"
private const val NOTIFICATION_ID = BuildConfig.SERVICE_FOREGROUND_NOTIFICATION_ID
private const val CHANNEL_ID = BuildConfig.SERVICE_FOREGROUND_CHANNEL_ID
const val CHANNEL_SERVICE = BuildConfig.SERVICE_FOREGROUND_CHANNEL_NAME
const val COMMAND_KEY = "${BuildConfig.APPLICATION_ID}_CMD"
const val PENDING_ACTIVITY = 5
const val PENDING_START = 6
const val PENDING_SCAN_REQ_CODE = 7
const val PENDING_ADVERTISE_REQ_CODE = 8
const val PENDING_HEALTH_CHECK_CODE = 9
const val PENDING_WIZARD_REQ_CODE = 10
const val PENDING_BM_UPDATE = 11
const val PENDING_PRIVACY_CLEANER_CODE = 12
const val DAILY_UPLOAD_NOTIFICATION_CODE = 13
var broadcastMessage: String? = null
const val scanDuration: Long = BuildConfig.SCAN_DURATION
const val minScanInterval: Long = BuildConfig.MIN_SCAN_INTERVAL
const val maxScanInterval: Long = BuildConfig.MAX_SCAN_INTERVAL
const val advertisingDuration: Long = BuildConfig.ADVERTISING_DURATION
const val advertisingGap: Long = BuildConfig.ADVERTISING_INTERVAL
const val maxQueueTime: Long = BuildConfig.MAX_QUEUE_TIME
const val bmCheckInterval: Long = BuildConfig.BM_CHECK_INTERVAL
const val healthCheckInterval: Long = BuildConfig.HEALTH_CHECK_INTERVAL
const val connectionTimeout: Long = BuildConfig.CONNECTION_TIMEOUT
const val blacklistDuration: Long = BuildConfig.BLACKLIST_DURATION
}
}

View file

@ -0,0 +1,55 @@
package au.gov.health.covidsafe.services
import android.os.Handler
import android.os.Message
import java.lang.ref.WeakReference
class CommandHandler(val service: WeakReference<BluetoothMonitoringService>) : Handler() {
override fun handleMessage(msg: Message?) {
msg?.let {
val cmd = msg.what
service.get()?.runService(BluetoothMonitoringService.Command.findByValue(cmd))
}
}
private fun sendCommandMsg(cmd: BluetoothMonitoringService.Command, delay: Long) {
val msg = Message.obtain(this, cmd.index)
sendMessageDelayed(msg, delay)
}
private fun sendCommandMsg(cmd: BluetoothMonitoringService.Command) {
val msg = obtainMessage(cmd.index)
msg.arg1 = cmd.index
sendMessage(msg)
}
fun startBluetoothMonitoringService() {
sendCommandMsg(BluetoothMonitoringService.Command.ACTION_START)
}
fun scheduleNextScan(timeInMillis: Long) {
cancelNextScan()
sendCommandMsg(BluetoothMonitoringService.Command.ACTION_SCAN, timeInMillis)
}
private fun cancelNextScan() {
removeMessages(BluetoothMonitoringService.Command.ACTION_SCAN.index)
}
fun hasScanScheduled(): Boolean {
return hasMessages(BluetoothMonitoringService.Command.ACTION_SCAN.index)
}
fun scheduleNextAdvertise(timeInMillis: Long) {
cancelNextAdvertise()
sendCommandMsg(BluetoothMonitoringService.Command.ACTION_ADVERTISE, timeInMillis)
}
private fun cancelNextAdvertise() {
removeMessages(BluetoothMonitoringService.Command.ACTION_ADVERTISE.index)
}
fun hasAdvertiseScheduled(): Boolean {
return hasMessages(BluetoothMonitoringService.Command.ACTION_ADVERTISE.index)
}
}

View file

@ -0,0 +1,96 @@
package au.gov.health.covidsafe.services
import android.app.Service
import android.content.Context
import android.content.Intent
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import android.os.Binder
import android.os.IBinder
import au.gov.health.covidsafe.logging.CentralLog
import kotlin.math.sqrt
class SensorMonitoringService : Service(), SensorEventListener {
private lateinit var sensorManager: SensorManager
private var _light: FloatArray? = null
private var _proximity: FloatArray? = null
private val binder = LocalBinder()
override fun onCreate() {
super.onCreate()
sensorManager = getSystemService(Context.SENSOR_SERVICE) as SensorManager
val proximitySensor = sensorManager.getDefaultSensor(Sensor.TYPE_PROXIMITY)
val lightSensor = sensorManager.getDefaultSensor(Sensor.TYPE_LIGHT)
if (proximitySensor != null) {
CentralLog.d(TAG, "Proximity sensor: $proximitySensor")
sensorManager.registerListener(this, proximitySensor, SENSOR_DELAY_SUPER_SLOW)
} else {
CentralLog.d(TAG, "Proximity sensor not available")
}
if (lightSensor != null) {
CentralLog.d(TAG, "Light sensor: $lightSensor")
sensorManager.registerListener(this, lightSensor, SENSOR_DELAY_SUPER_SLOW)
} else {
CentralLog.d(TAG, "Light sensor not available")
}
CentralLog.d(TAG, "SensorMonitoringService started")
}
override fun onDestroy() {
sensorManager.unregisterListener(this)
CentralLog.d(TAG, "SensorMonitoringService destroyed")
super.onDestroy()
}
inner class LocalBinder : Binder() {
fun getService(): SensorMonitoringService = this@SensorMonitoringService
}
override fun onBind(intent: Intent): IBinder {
return binder
}
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {
CentralLog.d(TAG, "Sensor accuracy changed! $sensor")
}
override fun onSensorChanged(event: SensorEvent) {
when (event.sensor.type) {
Sensor.TYPE_PROXIMITY -> {
_proximity = event.values
}
Sensor.TYPE_LIGHT -> {
_light = event.values
}
else -> {
CentralLog.w(TAG, "Unexpected sensor type changed: ${event.sensor.type}")
}
}
}
val proximity
get() = if (_proximity != null) {
sqrt((_proximity as FloatArray).reduce { acc: Float, n: Float -> acc + n * n })
} else {
-1.0f
}
val light
get() = if (_light != null) {
sqrt((_light as FloatArray).reduce { acc: Float, n: Float -> acc + n * n })
} else {
-1.0f
}
companion object {
const val TAG = "SensorMonitoringService"
const val SENSOR_DELAY_SUPER_SLOW = 3_000_000
}
}

View file

@ -0,0 +1,9 @@
package au.gov.health.covidsafe.status
import android.os.Parcelable
import kotlinx.android.parcel.Parcelize
@Parcelize
data class Status(
val msg: String
) : Parcelable

View file

@ -0,0 +1,20 @@
package au.gov.health.covidsafe.status.persistence
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "status_table")
class StatusRecord constructor(
@ColumnInfo(name = "msg")
var msg: String
) {
@PrimaryKey(autoGenerate = true)
@ColumnInfo(name = "id")
var id: Int = 0
@ColumnInfo(name = "timestamp")
var timestamp: Long = System.currentTimeMillis()
}

View file

@ -0,0 +1,32 @@
package au.gov.health.covidsafe.status.persistence
import androidx.lifecycle.LiveData
import androidx.room.*
import androidx.sqlite.db.SupportSQLiteQuery
@Dao
interface StatusRecordDao {
@Query("SELECT * from status_table ORDER BY timestamp ASC")
fun getRecords(): LiveData<List<StatusRecord>>
@Query("SELECT * from status_table ORDER BY timestamp ASC")
fun getCurrentRecords(): List<StatusRecord>
@Query("SELECT * from status_table where msg = :msg ORDER BY timestamp DESC LIMIT 1")
fun getMostRecentRecord(msg: String): LiveData<StatusRecord?>
@Query("DELETE FROM status_table WHERE timestamp <= :timeInMs")
fun deleteDataOlder(timeInMs: Long): Int
@Query("DELETE FROM status_table")
fun nukeDb()
@RawQuery
fun getRecordsViaQuery(query: SupportSQLiteQuery): List<StatusRecord>
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insert(record: StatusRecord)
}

View file

@ -0,0 +1,22 @@
package au.gov.health.covidsafe.status.persistence
import android.content.Context
import au.gov.health.covidsafe.streetpass.persistence.StreetPassRecordDatabase
class StatusRecordStorage(val context: Context) {
private val statusDao = StreetPassRecordDatabase.getDatabase(context).statusDao()
suspend fun saveRecord(record: StatusRecord) {
statusDao.insert(record)
}
fun getAllRecords(): List<StatusRecord> {
return statusDao.getCurrentRecords()
}
fun deleteDataOlderThan(timeInMs: Long): Int {
return statusDao.deleteDataOlder(timeInMs)
}
}

View file

@ -0,0 +1,3 @@
package au.gov.health.covidsafe.streetpass
class BlacklistEntry(val uniqueIdentifier: String?)

View file

@ -0,0 +1,40 @@
package au.gov.health.covidsafe.streetpass
import android.os.Parcelable
import kotlinx.android.parcel.Parcelize
@Parcelize
class ConnectablePeripheral(
var transmissionPower: Int?,
var rssi: Int
) : Parcelable
@Parcelize
data class PeripheralDevice(
val modelP: String,
val address: String?
) : Parcelable
@Parcelize
data class CentralDevice(
val modelC: String,
val address: String?
) : Parcelable
@Parcelize
data class ConnectionRecord(
val version: Int,
val msg: String,
val org: String,
val peripheral: PeripheralDevice,
val central: CentralDevice,
var rssi: Int,
var txPower: Int?
) : Parcelable {
override fun toString(): String {
return "Central ${central.modelC} - ${central.address} ---> Peripheral ${peripheral.modelP} - ${peripheral.address}"
}
}

View file

@ -0,0 +1,5 @@
package au.gov.health.covidsafe.streetpass
import au.gov.health.covidsafe.BuildConfig
const val ACTION_DEVICE_SCANNED = "${BuildConfig.APPLICATION_ID}.ACTION_DEVICE_SCANNED"

View file

@ -0,0 +1,122 @@
package au.gov.health.covidsafe.streetpass
import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanResult
import android.content.Context
import android.os.Build
import android.os.Handler
import au.gov.health.covidsafe.Utils
import au.gov.health.covidsafe.bluetooth.BLEScanner
import au.gov.health.covidsafe.logging.CentralLog
import au.gov.health.covidsafe.status.Status
import kotlin.properties.Delegates
class StreetPassScanner constructor(
context: Context,
serviceUUIDString: String,
private val scanDurationInMillis: Long
) {
private var scanner: BLEScanner by Delegates.notNull()
private var context: Context by Delegates.notNull()
private val TAG = "StreetPassScanner"
private var handler: Handler = Handler()
var scannerCount = 0
private val scanCallback = BleScanCallback()
init {
scanner = BLEScanner(context, serviceUUIDString, 0)
this.context = context
}
fun startScan() {
val statusRecord = Status("Scanning Started")
Utils.broadcastStatusReceived(context, statusRecord)
scanner.startScan(scanCallback)
scannerCount++
handler.postDelayed(
{ stopScan() }
, scanDurationInMillis)
CentralLog.d(TAG, "scanning started")
}
fun stopScan() {
if (scannerCount > 0) {
val statusRecord = Status("Scanning Stopped")
Utils.broadcastStatusReceived(context, statusRecord)
scannerCount--
scanner.stopScan()
}
}
fun isScanning(): Boolean {
return scannerCount > 0
}
inner class BleScanCallback : ScanCallback() {
private val TAG = "BleScanCallback"
private fun processScanResult(scanResult: ScanResult?) {
scanResult?.let { result ->
val device = result.device
val rssi = result.rssi // get RSSI value
var txPower: Int? = null
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
txPower = result.txPower
if (txPower == 127) {
txPower = null
}
}
val manuData: ByteArray = scanResult.scanRecord?.getManufacturerSpecificData(1023)
?: "N.A".toByteArray()
val manuString = String(manuData, Charsets.UTF_8)
val connectable = ConnectablePeripheral(txPower, rssi)
CentralLog.i(TAG, "Scanned: $manuString - ${device.address}")
Utils.broadcastDeviceScanned(context, device, connectable)
}
}
override fun onScanResult(callbackType: Int, result: ScanResult?) {
super.onScanResult(callbackType, result)
processScanResult(result)
}
override fun onScanFailed(errorCode: Int) {
super.onScanFailed(errorCode)
val reason = when (errorCode) {
SCAN_FAILED_ALREADY_STARTED -> "$errorCode - SCAN_FAILED_ALREADY_STARTED"
SCAN_FAILED_APPLICATION_REGISTRATION_FAILED -> "$errorCode - SCAN_FAILED_APPLICATION_REGISTRATION_FAILED"
SCAN_FAILED_FEATURE_UNSUPPORTED -> "$errorCode - SCAN_FAILED_FEATURE_UNSUPPORTED"
SCAN_FAILED_INTERNAL_ERROR -> "$errorCode - SCAN_FAILED_INTERNAL_ERROR"
else -> {
"$errorCode - UNDOCUMENTED"
}
}
CentralLog.e(TAG, "BT Scan failed: $reason")
if (scannerCount > 0) {
scannerCount--
}
}
}
}

View file

@ -0,0 +1,32 @@
package au.gov.health.covidsafe.streetpass
import android.content.Context
import au.gov.health.covidsafe.bluetooth.gatt.GattServer
import au.gov.health.covidsafe.bluetooth.gatt.GattService
class StreetPassServer constructor(val context: Context, serviceUUIDString: String) {
private val TAG = "StreetPassServer"
private var gattServer: GattServer? = null
init {
gattServer = setupGattServer(context, serviceUUIDString)
}
private fun setupGattServer(context: Context, serviceUUIDString: String): GattServer? {
val gattServer = GattServer(context, serviceUUIDString)
val started = gattServer.startServer()
if (started) {
val readService = GattService(context, serviceUUIDString)
gattServer.addService(readService)
return gattServer
}
return null
}
fun tearDown() {
gattServer?.stop()
}
}

View file

@ -0,0 +1,721 @@
package au.gov.health.covidsafe.streetpass
import android.bluetooth.*
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Handler
import androidx.annotation.Keep
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import au.gov.health.covidsafe.BuildConfig
import au.gov.health.covidsafe.TracerApp
import au.gov.health.covidsafe.Utils
import au.gov.health.covidsafe.bluetooth.gatt.*
import au.gov.health.covidsafe.logging.CentralLog
import au.gov.health.covidsafe.services.BluetoothMonitoringService
import au.gov.health.covidsafe.services.BluetoothMonitoringService.Companion.blacklistDuration
import au.gov.health.covidsafe.services.BluetoothMonitoringService.Companion.maxQueueTime
import java.util.*
import java.util.concurrent.PriorityBlockingQueue
@Keep
class StreetPassWorker(val context: Context) {
private val workQueue: PriorityBlockingQueue<Work> = PriorityBlockingQueue()
private val blacklist: MutableList<BlacklistEntry> = Collections.synchronizedList(ArrayList())
private val workReceiver = StreetPassWorkReceiver()
private val deviceProcessedReceiver = DeviceProcessedReceiver()
private val serviceUUID: UUID = UUID.fromString(BuildConfig.BLE_SSID)
private val TAG = "StreetPassWorker"
private val bluetoothManager =
context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
private lateinit var timeoutHandler: Handler
private lateinit var queueHandler: Handler
private lateinit var blacklistHandler: Handler
private var currentPendingConnection: Work? = null
private var localBroadcastManager: LocalBroadcastManager = LocalBroadcastManager.getInstance(context)
val onWorkTimeoutListener = object : Work.OnWorkTimeoutListener {
override fun onWorkTimeout(work: Work) {
if (!isCurrentlyWorkedOn(work.device.address)) {
CentralLog.i(TAG, "Work already removed. Timeout ineffective??.")
}
CentralLog.e(
TAG,
"Work timed out for ${work.device.address} @ ${work.connectable.rssi} queued for ${work.checklist.started.timePerformed - work.timeStamp}ms"
)
CentralLog.e(
TAG,
"${work.device.address} work status: ${work.checklist}."
)
//connection never formed - don't need to disconnect
if (!work.checklist.connected.status) {
CentralLog.e(TAG, "No connection formed for ${work.device.address}")
if (work.device.address == currentPendingConnection?.device?.address) {
currentPendingConnection = null
}
try {
work.gatt?.close()
} catch (e: Exception) {
CentralLog.e(
TAG,
"Unexpected error while attempting to close clientIf to ${work.device.address}: ${e.localizedMessage}"
)
}
finishWork(work)
}
//the connection is still there - might be stuck / work in progress
else if (work.checklist.connected.status && !work.checklist.disconnected.status) {
if (work.checklist.readCharacteristic.status || work.checklist.writeCharacteristic.status || work.checklist.skipped.status) {
CentralLog.e(
TAG,
"Connected but did not disconnect in time for ${work.device.address}"
)
try {
work.gatt?.disconnect()
//disconnect callback won't get invoked
if (work.gatt == null) {
currentPendingConnection = null
finishWork(work)
}
} catch (e: Throwable) {
CentralLog.e(
TAG,
"Failed to clean up work, bluetooth state likely changed or other device's advertiser stopped: ${e.localizedMessage}"
)
}
} else {
CentralLog.e(
TAG,
"Connected but did nothing for ${work.device.address}"
)
try {
work.gatt?.disconnect()
//disconnect callback won't get invoked
if (work.gatt == null) {
currentPendingConnection = null
finishWork(work)
}
} catch (e: Throwable) {
CentralLog.e(
TAG,
"Failed to clean up work, bluetooth state likely changed or other device's advertiser stopped: ${e.localizedMessage}"
)
}
}
}
//all other edge cases? - disconnected
else {
CentralLog.e(
TAG,
"Disconnected but callback not invoked in time. Waiting.: ${work.device.address}: ${work.checklist}"
)
}
}
}
init {
prepare()
}
private fun prepare() {
val deviceAvailableFilter = IntentFilter(ACTION_DEVICE_SCANNED)
localBroadcastManager.registerReceiver(workReceiver, deviceAvailableFilter)
val deviceProcessedFilter = IntentFilter(ACTION_DEVICE_PROCESSED)
localBroadcastManager.registerReceiver(deviceProcessedReceiver, deviceProcessedFilter)
timeoutHandler = Handler()
queueHandler = Handler()
blacklistHandler = Handler()
}
fun isCurrentlyWorkedOn(address: String?): Boolean {
return currentPendingConnection?.let {
it.device.address == address
} ?: false
}
fun addWork(work: Work): Boolean {
//if it's our current work. ignore
if (isCurrentlyWorkedOn(work.device.address)) {
CentralLog.i(TAG, "${work.device.address} is being worked on, not adding to queue")
return false
}
//if its in blacklist - check for both mac address and manu data
if (blacklist.any { it.uniqueIdentifier == work.device.address }) {
CentralLog.i(TAG, "${work.device.address} is in blacklist, not adding to queue")
return false
}
//if we haven't seen this device yet
if (workQueue.none { it.device.address == work.device.address }) {
workQueue.offer(work)
queueHandler.postDelayed({
if (workQueue.contains(work))
CentralLog.i(
TAG,
"Work for ${work.device.address} removed from queue? : ${workQueue.remove(
work
)}"
)
}, maxQueueTime)
CentralLog.i(TAG, "Added to work queue: ${work.device.address}")
return true
}
//this gadget is already in the queue, we can use the latest rssi and txpower? replace the entry
else {
//ignore it
CentralLog.i(TAG, "${work.device.address} is already in work queue")
val prevWork = workQueue.find { it.device.address == work.device.address }
val removed = workQueue.remove(prevWork)
val added = workQueue.offer(work)
CentralLog.i(TAG, "Queue entry updated - removed: ${removed}, added: $added")
return false
}
}
fun doWork() {
if (currentPendingConnection != null) {
CentralLog.i(
TAG,
"Already trying to connect to: ${currentPendingConnection?.device?.address}"
)
//devices may reset their bluetooth before the disconnection happens properly and disconnect is never called.
//handle that situation here
//if the job was finished but not removed
//or if the job was timed out but not removed
val timedout = System.currentTimeMillis() > currentPendingConnection?.timeout ?: 0
if (currentPendingConnection?.finished ?: false || timedout) {
CentralLog.w(
TAG,
"Handling erroneous current work for ${currentPendingConnection?.device?.address} : - finished: ${currentPendingConnection?.finished
?: false}, timedout: $timedout"
)
//check if there is, for some reason, an existing connection
if (currentPendingConnection != null) {
if (bluetoothManager.getConnectedDevices(BluetoothProfile.GATT).contains(
currentPendingConnection?.device
)
) {
CentralLog.w(
TAG,
"Disconnecting dangling connection to ${currentPendingConnection?.device?.address}"
)
currentPendingConnection?.gatt?.disconnect()
}
} else {
doWork()
}
}
return
}
if (workQueue.isEmpty()) {
CentralLog.i(TAG, "Queue empty. Nothing to do.")
return
}
CentralLog.i(TAG, "Queue size: ${workQueue.size}")
var workToDo: Work? = null
val now = System.currentTimeMillis()
while (workToDo == null && workQueue.isNotEmpty()) {
workToDo = workQueue.poll()
workToDo?.let { work ->
if (now - work.timeStamp > maxQueueTime) {
CentralLog.w(
TAG,
"Work request for ${work.device.address} too old. Not doing"
)
workToDo = null
}
}
}
workToDo?.let {
val device = it.device
if (blacklist.filter { it.uniqueIdentifier == device.address }.isNotEmpty()) {
CentralLog.w(TAG, "Already worked on ${device.address}. Skip.")
doWork()
return
}
var currentWorkOrder = it
val alreadyConnected = getConnectionStatus(device)
CentralLog.i(TAG, "Already connected to ${device.address} : $alreadyConnected")
if (alreadyConnected) {
//this might mean that the other device is currently connected to this device's local gatt server
//skip. we'll rely on the other party to do a write
currentWorkOrder.checklist.skipped.status = true
currentWorkOrder.checklist.skipped.timePerformed = System.currentTimeMillis()
currentWorkOrder.let {
finishWork(it)
}
} else {
currentWorkOrder.let {
if (it != null) {
val gattCallback = StreetPassGattCallback(it)
CentralLog.i(
TAG,
"Starting work - connecting to device: ${device.address} @ ${it.connectable.rssi} ${System.currentTimeMillis() - it.timeStamp}ms ago"
)
currentPendingConnection = it
try {
it.checklist.started.status = true
it.checklist.started.timePerformed = System.currentTimeMillis()
it.startWork(context, gattCallback)
var connecting = it.gatt?.connect() ?: false
if (!connecting) {
CentralLog.e(
TAG,
"not connecting to ${it.device.address}??"
)
//bail and do the next job
CentralLog.e(TAG, "Moving on to next task")
currentPendingConnection = null
doWork()
return
} else {
CentralLog.i(
TAG,
"Connection to ${it.device.address} attempt in progress"
)
}
timeoutHandler.postDelayed(
it.timeoutRunnable,
BluetoothMonitoringService.connectionTimeout
)
it.timeout =
System.currentTimeMillis() + BluetoothMonitoringService.connectionTimeout
CentralLog.i(TAG, "Timeout scheduled for ${it.device.address}")
} catch (e: Throwable) {
CentralLog.e(
TAG,
"Unexpected error while attempting to connect to ${device.address}: ${e.localizedMessage}"
)
CentralLog.e(TAG, "Moving on to next task")
currentPendingConnection = null
doWork()
return
}
} else {
CentralLog.e(TAG, "Work not started - missing Work Object")
}
}
}
}
if (workToDo == null) {
CentralLog.i(TAG, "No outstanding work")
}
}
private fun getConnectionStatus(device: BluetoothDevice): Boolean {
val connectedDevices = bluetoothManager.getDevicesMatchingConnectionStates(
BluetoothProfile.GATT,
intArrayOf(BluetoothProfile.STATE_CONNECTED)
)
return connectedDevices.contains(device)
}
fun finishWork(work: Work) {
if (work.finished) {
CentralLog.i(
TAG,
"Work on ${work.device.address} already finished and closed"
)
return
}
if (work.isCriticalsCompleted()) {
Utils.broadcastDeviceProcessed(context, work.device.address)
}
CentralLog.i(
TAG,
"Work on ${work.device.address} stopped in: ${work.checklist.disconnected.timePerformed - work.checklist.started.timePerformed}"
)
CentralLog.i(
TAG,
"Work on ${work.device.address} completed?: ${work.isCriticalsCompleted()}. Connected in: ${work.checklist.connected.timePerformed - work.checklist.started.timePerformed}. connection lasted for: ${work.checklist.disconnected.timePerformed - work.checklist.connected.timePerformed}. Status: ${work.checklist}"
)
timeoutHandler.removeCallbacks(work.timeoutRunnable)
CentralLog.i(TAG, "Timeout removed for ${work.device.address}")
work.finished = true
doWork()
}
inner class StreetPassGattCallback(private val work: Work) : BluetoothGattCallback() {
private fun endWorkConnection(gatt: BluetoothGatt) {
CentralLog.i(TAG, "Ending connection with: ${gatt.device.address}")
gatt.disconnect()
}
override fun onConnectionStateChange(gatt: BluetoothGatt?, status: Int, newState: Int) {
gatt?.let {
when (newState) {
BluetoothProfile.STATE_CONNECTED -> {
CentralLog.i(TAG, "Connected to other GATT server - ${gatt.device.address}")
gatt.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_BALANCED)
gatt.requestMtu(512)
work.checklist.connected.status = true
work.checklist.connected.timePerformed = System.currentTimeMillis()
}
BluetoothProfile.STATE_DISCONNECTED -> {
CentralLog.i(
TAG,
"Disconnected from other GATT server - ${gatt.device.address}"
)
work.checklist.disconnected.status = true
work.checklist.disconnected.timePerformed = System.currentTimeMillis()
//remove timeout runnable if its still there
timeoutHandler.removeCallbacks(work.timeoutRunnable)
CentralLog.i(TAG, "Timeout removed for ${work.device.address}")
//remove job from list of current work - if it is the current work
if (work.device.address == currentPendingConnection?.device?.address) {
currentPendingConnection = null
}
gatt.close()
finishWork(work)
}
else -> {
CentralLog.i(TAG, "Connection status for ${gatt.device.address}: $newState")
endWorkConnection(gatt)
}
}
}
}
override fun onMtuChanged(gatt: BluetoothGatt?, mtu: Int, status: Int) {
if (!work.checklist.mtuChanged.status) {
work.checklist.mtuChanged.status = true
work.checklist.mtuChanged.timePerformed = System.currentTimeMillis()
CentralLog.i(
TAG,
"${gatt?.device?.address} MTU is $mtu. Was change successful? : ${status == BluetoothGatt.GATT_SUCCESS}"
)
gatt?.let {
val discoveryOn = gatt.discoverServices()
CentralLog.i(
TAG,
"Attempting to start service discovery on ${gatt.device.address}: $discoveryOn"
)
}
}
}
// New services discovered
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
when (status) {
BluetoothGatt.GATT_SUCCESS -> {
CentralLog.i(
TAG,
"onServicesDiscovered received: BluetoothGatt.GATT_SUCCESS - $status"
)
CentralLog.i(
TAG,
"Discovered ${gatt.services.size} services on ${gatt.device.address}"
)
val service = gatt.getService(serviceUUID)
service?.let {
val characteristic = service.getCharacteristic(serviceUUID)
if (characteristic != null) {
val readSuccess = gatt.readCharacteristic(characteristic)
CentralLog.i(
TAG,
"Attempt to read characteristic of our service on ${gatt.device.address}: $readSuccess"
)
} else {
CentralLog.e(
TAG,
"${gatt.device.address} does not have our characteristic"
)
endWorkConnection(gatt)
}
}
if (service == null) {
CentralLog.e(
TAG,
"${gatt.device.address} does not have our service"
)
endWorkConnection(gatt)
}
}
else -> {
CentralLog.w(TAG, "No services discovered on ${gatt.device.address}")
endWorkConnection(gatt)
}
}
}
// data read from a perhipheral
//I am a central
override fun onCharacteristicRead(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
status: Int
) {
CentralLog.i(TAG, "Read Status: $status")
when (status) {
BluetoothGatt.GATT_SUCCESS -> {
CentralLog.i(
TAG,
"Characteristic read from ${gatt.device.address}: ${characteristic.getStringValue(
0
)}"
)
when (characteristic.uuid) {
serviceUUID -> {
//need to populate in the rssi here?
CentralLog.i(
TAG,
"onCharacteristicRead: ${work.device.address} - [${work.connectable.rssi}]"
)
val dataBytes = characteristic.value
try {
val readData = ReadRequestPayload.createReadRequestPayload(dataBytes)
val peripheral =
PeripheralDevice(readData.modelP, work.device.address)
val connectionRecord = ConnectionRecord(
version = readData.v,
msg = readData.msg,
org = readData.org,
peripheral = peripheral,
central = TracerApp.asCentralDevice(),
rssi = work.connectable.rssi,
txPower = work.connectable.transmissionPower
)
Utils.broadcastStreetPassReceived(
context,
connectionRecord
)
} catch (e: Throwable) {
CentralLog.e(
TAG,
"Failed to de-serialize request payload object - ${e.message}"
)
}
}
}
work.checklist.readCharacteristic.status = true
work.checklist.readCharacteristic.timePerformed = System.currentTimeMillis()
}
else -> {
CentralLog.w(
TAG,
"Failed to read characteristics from ${gatt.device.address}: $status"
)
}
}
// Only attempt to write BM back to peripheral if it is still valid
if (Utils.bmValid(context)) {
//may have failed to read, can try to write
//we are writing as the central device
val thisCentralDevice = TracerApp.asCentralDevice()
val writedata = WriteRequestPayload(
v = TracerApp.protocolVersion,
msg = TracerApp.thisDeviceMsg(),
org = TracerApp.ORG,
modelC = thisCentralDevice.modelC,
rssi = work.connectable.rssi,
txPower = work.connectable.transmissionPower
)
characteristic.value = writedata.getPayload()
val writeSuccess = gatt.writeCharacteristic(characteristic)
CentralLog.i(
TAG,
"Attempt to write characteristic to our service on ${gatt.device.address}: $writeSuccess"
)
} else {
CentralLog.i(
TAG,
"Expired BM. Skipping attempt to write characteristic to our service on ${gatt.device.address}"
)
endWorkConnection(gatt)
}
}
override fun onCharacteristicWrite(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
status: Int
) {
when (status) {
BluetoothGatt.GATT_SUCCESS -> {
CentralLog.i(TAG, "Characteristic wrote successfully")
work.checklist.writeCharacteristic.status = true
work.checklist.writeCharacteristic.timePerformed = System.currentTimeMillis()
}
else -> {
CentralLog.i(TAG, "Failed to write characteristics: $status")
}
}
endWorkConnection(gatt)
}
}
fun terminateConnections() {
CentralLog.d(TAG, "Cleaning up worker.")
currentPendingConnection?.gatt?.disconnect()
currentPendingConnection = null
timeoutHandler.removeCallbacksAndMessages(null)
queueHandler.removeCallbacksAndMessages(null)
blacklistHandler.removeCallbacksAndMessages(null)
//concurrent modifications?
workQueue.clear()
blacklist.clear()
}
fun unregisterReceivers() {
try {
localBroadcastManager.unregisterReceiver(deviceProcessedReceiver)
} catch (e: Throwable) {
CentralLog.e(TAG, "Unable to close receivers: ${e.localizedMessage}")
}
try {
localBroadcastManager.unregisterReceiver(workReceiver)
} catch (e: Throwable) {
CentralLog.e(TAG, "Unable to close receivers: ${e.localizedMessage}")
}
}
inner class DeviceProcessedReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (ACTION_DEVICE_PROCESSED == intent.action) {
val deviceAddress = intent.getStringExtra(DEVICE_ADDRESS)
CentralLog.d(TAG, "Adding to blacklist: $deviceAddress")
val entry = BlacklistEntry(deviceAddress)
blacklist.add(entry)
blacklistHandler.postDelayed({
CentralLog.i(
TAG,
"blacklist for ${entry.uniqueIdentifier} removed? : ${blacklist.remove(entry)}"
)
}, blacklistDuration)
}
}
}
inner class StreetPassWorkReceiver : BroadcastReceiver() {
private val TAG = "StreetPassWorkReceiver"
override fun onReceive(context: Context?, intent: Intent?) {
intent?.let {
if (ACTION_DEVICE_SCANNED == intent.action) {
//get data from extras
val device: BluetoothDevice? =
intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE)
val connectable: ConnectablePeripheral? =
intent.getParcelableExtra(CONNECTION_DATA)
val devicePresent = device != null
val connectablePresent = connectable != null
CentralLog.i(
TAG,
"Device received: ${device?.address}. Device present: $devicePresent, Connectable Present: $connectablePresent"
)
device?.let {
connectable?.let {
val work = Work(device, connectable, onWorkTimeoutListener)
if (addWork(work)) {
doWork()
}
}
}
}
}
}
}
}

View file

@ -0,0 +1,71 @@
package au.gov.health.covidsafe.streetpass
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothGatt
import android.content.Context
import com.google.gson.Gson
import au.gov.health.covidsafe.logging.CentralLog
import kotlin.properties.Delegates
class Work constructor(
var device: BluetoothDevice,
var connectable: ConnectablePeripheral,
private val onWorkTimeoutListener: OnWorkTimeoutListener
) : Comparable<Work> {
var timeStamp: Long by Delegates.notNull()
var checklist = WorkCheckList()
var gatt: BluetoothGatt? = null
var finished = false
var timeout : Long = 0
private val TAG = "Work"
val timeoutRunnable: Runnable = Runnable {
onWorkTimeoutListener.onWorkTimeout(this)
}
init {
timeStamp = System.currentTimeMillis()
}
fun isCriticalsCompleted(): Boolean {
return (checklist.connected.status && checklist.readCharacteristic.status && checklist.writeCharacteristic.status) || checklist.skipped.status
}
fun startWork(
context: Context,
gattCallback: StreetPassWorker.StreetPassGattCallback
) {
gatt = device.connectGatt(context, false, gattCallback)
if (gatt == null) {
CentralLog.e(TAG, "Unable to connect to ${device.address}")
}
}
override fun compareTo(other: Work): Int {
return -(timeStamp - other.timeStamp).toInt()
}
inner class WorkCheckList {
var started = Check()
var connected = Check()
var mtuChanged = Check()
var readCharacteristic = Check()
var writeCharacteristic = Check()
var disconnected = Check()
var skipped = Check()
override fun toString(): String {
return Gson().toJson(this)
}
}
inner class Check {
var status = false
var timePerformed: Long = 0
}
interface OnWorkTimeoutListener {
fun onWorkTimeout(work: Work)
}
}

View file

@ -0,0 +1,45 @@
package au.gov.health.covidsafe.streetpass.persistence
import androidx.annotation.Keep
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@Keep
@Entity(tableName = "record_table")
class StreetPassRecord(
@ColumnInfo(name = "v")
var v: Int,
@ColumnInfo(name = "msg")
var msg: String,
@ColumnInfo(name = "org")
var org: String,
@ColumnInfo(name = "modelP")
val modelP: String,
@ColumnInfo(name = "modelC")
val modelC: String,
@ColumnInfo(name = "rssi")
val rssi: Int,
@ColumnInfo(name = "txPower")
val txPower: Int?
) {
@PrimaryKey(autoGenerate = true)
@ColumnInfo(name = "id")
var id: Int = 0
@ColumnInfo(name = "timestamp")
var timestamp: Long = System.currentTimeMillis()
override fun toString(): String {
return "StreetPassRecord(v=$v, msg='$msg', org='$org', modelP='$modelP', modelC='$modelC', rssi=$rssi, txPower=$txPower, id=$id, timestamp=$timestamp)"
}
}

View file

@ -0,0 +1,31 @@
package au.gov.health.covidsafe.streetpass.persistence
import androidx.lifecycle.LiveData
import androidx.room.*
import androidx.sqlite.db.SupportSQLiteQuery
@Dao
interface StreetPassRecordDao {
@Query("SELECT * from record_table ORDER BY timestamp ASC")
fun getRecords(): LiveData<List<StreetPassRecord>>
@Query("SELECT * from record_table ORDER BY timestamp DESC LIMIT 1")
fun getMostRecentRecord(): LiveData<StreetPassRecord?>
@Query("SELECT * from record_table ORDER BY timestamp ASC")
fun getCurrentRecords(): List<StreetPassRecord>
@Query("DELETE FROM record_table WHERE timestamp <= :timeInMs")
fun deleteDataOlder(timeInMs: Long): Int
@Query("DELETE FROM record_table")
fun nukeDb()
@RawQuery
fun getRecordsViaQuery(query: SupportSQLiteQuery): List<StreetPassRecord>
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insert(record: StreetPassRecord)
}

View file

@ -0,0 +1,76 @@
package au.gov.health.covidsafe.streetpass.persistence
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import au.gov.health.covidsafe.status.persistence.StatusRecord
import au.gov.health.covidsafe.status.persistence.StatusRecordDao
@Database(
entities = [StreetPassRecord::class, StatusRecord::class],
version = 2,
exportSchema = true
)
abstract class StreetPassRecordDatabase : RoomDatabase() {
abstract fun recordDao(): StreetPassRecordDao
abstract fun statusDao(): StatusRecordDao
companion object {
@Volatile
private var INSTANCE: StreetPassRecordDatabase? = null
fun getDatabase(context: Context): StreetPassRecordDatabase {
val tempInstance = INSTANCE
if (tempInstance != null) {
return tempInstance
}
synchronized(this) {
val instance = Room.databaseBuilder(
context,
StreetPassRecordDatabase::class.java,
"record_database"
)
.addMigrations(MIGRATION_1_2)
.build()
INSTANCE = instance
return instance
}
}
private val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(database: SupportSQLiteDatabase) {
//adding of status table
database.execSQL("CREATE TABLE IF NOT EXISTS `status_table` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `timestamp` INTEGER NOT NULL, `msg` TEXT NOT NULL)")
if (!isFieldExist(database, "record_table", "v")) {
database.execSQL("ALTER TABLE `record_table` ADD COLUMN `v` INTEGER NOT NULL DEFAULT 1")
}
if (!isFieldExist(database, "record_table", "org")) {
database.execSQL("ALTER TABLE `record_table` ADD COLUMN `org` TEXT NOT NULL DEFAULT 'AU_DTA'")
}
}
}
fun isFieldExist(db: SupportSQLiteDatabase, tableName: String, fieldName: String): Boolean {
var isExist = false
val res =
db.query("PRAGMA table_info($tableName)", null)
res.moveToFirst()
do {
val currentColumn = res.getString(1)
if (currentColumn == fieldName) {
isExist = true
}
} while (res.moveToNext())
return isExist
}
}
}

View file

@ -0,0 +1,8 @@
package au.gov.health.covidsafe.streetpass.persistence
import androidx.lifecycle.LiveData
class StreetPassRecordRepository(recordDao: StreetPassRecordDao) {
val allRecords: LiveData<List<StreetPassRecord>> = recordDao.getRecords()
}

View file

@ -0,0 +1,29 @@
package au.gov.health.covidsafe.streetpass.persistence
import android.content.Context
class StreetPassRecordStorage(val context: Context) {
private val recordDao = StreetPassRecordDatabase.getDatabase(context).recordDao()
suspend fun saveRecord(record: StreetPassRecord) {
recordDao.insert(record)
}
fun deleteDataOlderThan(timeInMs: Long): Int {
return recordDao.deleteDataOlder(timeInMs)
}
fun nukeDb() {
recordDao.nukeDb()
}
suspend fun nukeDbAsync() {
recordDao.nukeDb()
}
fun getAllRecords(): List<StreetPassRecord> {
return recordDao.getCurrentRecords()
}
}

View file

@ -0,0 +1,23 @@
package au.gov.health.covidsafe.streetpass.view
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import au.gov.health.covidsafe.streetpass.persistence.StreetPassRecord
import au.gov.health.covidsafe.streetpass.persistence.StreetPassRecordDatabase
import au.gov.health.covidsafe.streetpass.persistence.StreetPassRecordRepository
class RecordViewModel(app: Application) : AndroidViewModel(app) {
private var repo: StreetPassRecordRepository
var allRecords: LiveData<List<StreetPassRecord>>
init {
val recordDao = StreetPassRecordDatabase.getDatabase(app).recordDao()
repo = StreetPassRecordRepository(recordDao)
allRecords = repo.allRecords
}
}

View file

@ -0,0 +1,16 @@
package au.gov.health.covidsafe.streetpass.view
import au.gov.health.covidsafe.streetpass.persistence.StreetPassRecord
class StreetPassRecordViewModel(record: StreetPassRecord, val number: Int) {
val version = record.v
val modelC = record.modelC
val modelP = record.modelP
val msg = record.msg
val timeStamp = record.timestamp
val rssi = record.rssi
val transmissionPower = record.txPower
val org = record.org
constructor(record: StreetPassRecord) : this(record, 1)
}

View file

@ -0,0 +1,34 @@
package au.gov.health.covidsafe.ui
import android.os.Bundle
import androidx.fragment.app.Fragment
import androidx.navigation.Navigator
import androidx.navigation.fragment.NavHostFragment
import au.gov.health.covidsafe.HasBlockingState
open class BaseFragment : Fragment() {
override fun onResume() {
super.onResume()
val activity = this.activity
if (activity is HasBlockingState) {
activity.isUiBlocked = false
}
}
protected fun navigateTo(actionId: Int, bundle: Bundle? = null, navigatorExtras: Navigator.Extras? = null) {
val activity = this.activity
if (activity is HasBlockingState) {
activity.isUiBlocked = true
}
NavHostFragment.findNavController(this).navigate(actionId, bundle, null, navigatorExtras)
}
protected fun popBackStack() {
val activity = this.activity
if (activity is HasBlockingState) {
activity.isUiBlocked = true
}
NavHostFragment.findNavController(this).popBackStack()
}
}

View file

@ -0,0 +1,63 @@
package au.gov.health.covidsafe.ui
import androidx.annotation.StringRes
abstract class PagerChildFragment : BaseFragment() {
override fun onResume() {
super.onResume()
updateToolBar()
updateButton()
updateProgressBar()
updateButtonState()
}
private fun updateProgressBar() {
(parentFragment?.parentFragment as? PagerContainer)?.updateProgressBar(stepProgress)
(activity as? PagerContainer)?.updateProgressBar(stepProgress)
}
private fun updateToolBar() {
(parentFragment?.parentFragment as? PagerContainer)?.setNavigationIcon(navigationIcon)
(activity as? PagerContainer)?.setNavigationIcon(navigationIcon)
}
private fun updateButton() {
val updateButtonLayout = getUploadButtonLayout()
if (updateButtonLayout is UploadButtonLayout.ContinueLayout) {
updateButtonState()
}
(parentFragment?.parentFragment as? PagerContainer)?.refreshButton(updateButtonLayout)
(activity as? PagerContainer)?.refreshButton(updateButtonLayout)
}
fun enableContinueButton() {
(parentFragment?.parentFragment as? PagerContainer)?.enableNextButton()
(activity as? PagerContainer)?.enableNextButton()
}
fun disableContinueButton() {
(parentFragment?.parentFragment as? PagerContainer)?.disableNextButton()
(activity as? PagerContainer)?.disableNextButton()
}
fun showLoading() {
(parentFragment?.parentFragment as? PagerContainer)?.showLoading()
(activity as? PagerContainer)?.showLoading()
}
fun hideLoading() {
(parentFragment?.parentFragment as? PagerContainer)?.hideLoading((getUploadButtonLayout() as? UploadButtonLayout.ContinueLayout)?.buttonText)
(activity as? PagerContainer)?.hideLoading((getUploadButtonLayout() as? UploadButtonLayout.ContinueLayout)?.buttonText)
}
abstract val navigationIcon: Int?
abstract var stepProgress: Int?
abstract fun getUploadButtonLayout(): UploadButtonLayout
abstract fun updateButtonState()
}
sealed class UploadButtonLayout {
class ContinueLayout(@StringRes val buttonText: Int, val buttonListener: (() -> Unit)?) : UploadButtonLayout()
class QuestionLayout(val buttonYesListener: () -> Unit, val buttonNoListener: () -> Unit) : UploadButtonLayout()
}

View file

@ -0,0 +1,13 @@
package au.gov.health.covidsafe.ui
import androidx.annotation.StringRes
interface PagerContainer {
fun enableNextButton()
fun disableNextButton()
fun showLoading()
fun hideLoading(@StringRes stringRes: Int?)
fun updateProgressBar(stepProgress: Int?)
fun setNavigationIcon(navigationIcon: Int?)
fun refreshButton(updateButtonLayout: UploadButtonLayout)
}

View file

@ -0,0 +1,79 @@
package au.gov.health.covidsafe.ui.home
import android.content.Intent
import android.graphics.Bitmap
import android.net.Uri
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.core.view.isVisible
import androidx.navigation.fragment.findNavController
import com.atlassian.mobilekit.module.feedback.FeedbackModule
import kotlinx.android.synthetic.main.fragment_help.*
import kotlinx.android.synthetic.main.fragment_help.view.*
import au.gov.health.covidsafe.R
import au.gov.health.covidsafe.ui.BaseFragment
class HelpFragment : BaseFragment() {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? = inflater.inflate(R.layout.fragment_help, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val webView = view.helpWebView
webView.settings.javaScriptEnabled = true
webView.webViewClient = createWebVieClient(view)
webView.loadUrl(HELP_URL)
reportAnIssue.setOnClickListener {
FeedbackModule.showFeedbackScreen()
}
toolbar.setNavigationOnClickListener { findNavController().popBackStack() }
}
private fun createWebVieClient(view: View): WebViewClient =
object : WebViewClient() {
private var isRedirecting = false
private var loadFinished = false
override fun shouldOverrideUrlLoading(webView: WebView, request: WebResourceRequest): Boolean {
if (!loadFinished) isRedirecting = true
loadFinished = false
val urlString = request.url.toString()
if (urlString == HELP_URL) {
webView.loadUrl(request.url.toString())
} else {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(urlString))
webView.context.startActivity(intent)
}
return true
}
override fun onPageStarted(webView: WebView, url: String?, favicon: Bitmap?) {
super.onPageStarted(webView, url, favicon)
loadFinished = false
view.progress.isVisible = true
}
override fun onPageFinished(webView: WebView, url: String?) {
super.onPageFinished(webView, url)
if (!isRedirecting) loadFinished = true
if (loadFinished && !isRedirecting) {
view.progress.isVisible = false
} else {
isRedirecting = false
}
}
}
}
private const val HELP_URL = "https://www.covidsafe.gov.au/help-topics.html"

View file

@ -0,0 +1,332 @@
package au.gov.health.covidsafe.ui.home
import android.Manifest
import android.bluetooth.BluetoothAdapter
import android.content.*
import android.net.Uri
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.View.GONE
import android.view.View.VISIBLE
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.navigation.fragment.findNavController
import au.gov.health.covidsafe.BuildConfig
import au.gov.health.covidsafe.Preference
import au.gov.health.covidsafe.R
import au.gov.health.covidsafe.WebViewActivity
import au.gov.health.covidsafe.extensions.*
import au.gov.health.covidsafe.ui.BaseFragment
import kotlinx.android.synthetic.main.fragment_home.*
import kotlinx.android.synthetic.main.fragment_home.view.*
import kotlinx.android.synthetic.main.fragment_home_external_links.*
import kotlinx.android.synthetic.main.fragment_home_setup_complete_header.*
import kotlinx.android.synthetic.main.fragment_home_setup_incomplete_content.*
import pub.devrel.easypermissions.AppSettingsDialog
import pub.devrel.easypermissions.EasyPermissions
class HomeFragment : BaseFragment(), EasyPermissions.PermissionCallbacks {
private lateinit var presenter: HomePresenter
private var mIsBroadcastListenerRegistered = false
private var counter: Int = 0
private val mBroadcastListener: BroadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val action = intent.action
if (action == BluetoothAdapter.ACTION_STATE_CHANGED) {
when (intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, -1)) {
BluetoothAdapter.STATE_OFF -> {
bluetooth_card_view.render(formatBlueToothTitle(false), false)
refreshSetupCompleteOrIncompleteUi()
}
BluetoothAdapter.STATE_TURNING_OFF -> {
bluetooth_card_view.render(formatBlueToothTitle(false), false)
refreshSetupCompleteOrIncompleteUi()
}
BluetoothAdapter.STATE_ON -> {
bluetooth_card_view.render(formatBlueToothTitle(true), true)
refreshSetupCompleteOrIncompleteUi()
}
}
}
}
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
presenter = HomePresenter(this)
return inflater.inflate(R.layout.fragment_home, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
view.home_header_help.setOnClickListener {
findNavController().navigate(HomeFragmentDirections.actionHomeFragmentToHelpFragment())
}
if (BuildConfig.ENABLE_DEBUG_SCREEN) {
view.header_background.setOnClickListener {
counter++
if (counter >= 2) {
counter = 0
findNavController().navigate(HomeFragmentDirections.actionHomeFragmentToPeekActivity())
}
}
}
home_version_number.text = getString(R.string.home_version_number, BuildConfig.VERSION_NAME)
}
override fun onResume() {
super.onResume()
bluetooth_card_view.setOnClickListener { requestBlueToothPermissionThenNextPermission() }
location_card_view.setOnClickListener { askForLocationPermission() }
battery_card_view.setOnClickListener { excludeFromBatteryOptimization() }
home_been_tested_button.setOnClickListener {
navigateTo(R.id.action_home_to_selfIsolate)
}
home_setup_complete_share.setOnClickListener {
shareThisApp()
}
home_setup_complete_news.setOnClickListener {
goToNewsWebsite()
}
home_setup_complete_app.setOnClickListener {
goToCovidApp()
}
if (!mIsBroadcastListenerRegistered) {
registerBroadcast()
}
refreshSetupCompleteOrIncompleteUi()
}
override fun onPause() {
super.onPause()
bluetooth_card_view.setOnClickListener(null)
location_card_view.setOnClickListener(null)
battery_card_view.setOnClickListener(null)
home_been_tested_button.setOnClickListener(null)
home_setup_complete_share.setOnClickListener(null)
home_setup_complete_news.setOnClickListener(null)
home_setup_complete_app.setOnClickListener(null)
activity?.let { activity ->
if (mIsBroadcastListenerRegistered) {
activity.unregisterReceiver(mBroadcastListener)
mIsBroadcastListenerRegistered = false
}
}
}
override fun onDestroyView() {
super.onDestroyView()
home_root.removeAllViews()
}
private fun refreshSetupCompleteOrIncompleteUi() {
val isUploaded = context?.let {
Preference.isDataUploaded(it)
} ?: run {
false
}
home_been_tested_button.visibility = if (isUploaded) GONE else VISIBLE
when {
!allPermissionsEnabled() -> {
home_header_setup_complete_header_uploaded.visibility = GONE
home_header_setup_complete_header_divider.visibility = GONE
home_header_setup_complete_header.setText(R.string.home_header_inactive_title)
home_header_picture_setup_complete.setImageResource(R.drawable.ic_logo_home_inactive)
home_header_help.setImageResource(R.drawable.ic_help_outline_black)
context?.let { context ->
val backGroundColor = ContextCompat.getColor(context, R.color.grey)
header_background.setBackgroundColor(backGroundColor)
header_background_overlap.setBackgroundColor(backGroundColor)
val textColor = ContextCompat.getColor(context, R.color.slack_black)
home_header_setup_complete_header_uploaded.setTextColor(textColor)
home_header_setup_complete_header.setTextColor(textColor)
}
content_setup_incomplete_group.visibility = VISIBLE
updateBlueToothStatus()
updatePushNotificationStatus()
updateBatteryOptimizationStatus()
updateLocationStatus()
}
isUploaded -> {
home_header_setup_complete_header_uploaded.visibility = VISIBLE
home_header_setup_complete_header_divider.visibility = VISIBLE
home_header_setup_complete_header.setText(R.string.home_header_active_title)
home_header_picture_setup_complete.setImageResource(R.drawable.ic_logo_home_uploaded)
home_header_picture_setup_complete.setAnimation("spinner_home_upload_complete.json")
home_header_help.setImageResource(R.drawable.ic_help_outline_white)
content_setup_incomplete_group.visibility = GONE
context?.let { context ->
val backGroundColor = ContextCompat.getColor(context, R.color.dark_green)
header_background.setBackgroundColor(backGroundColor)
header_background_overlap.setBackgroundColor(backGroundColor)
val textColor = ContextCompat.getColor(context, R.color.white)
home_header_setup_complete_header_uploaded.setTextColor(textColor)
home_header_setup_complete_header.setTextColor(textColor)
}
}
else -> {
home_header_setup_complete_header_uploaded.visibility = GONE
home_header_setup_complete_header_divider.visibility = GONE
home_header_setup_complete_header.setText(R.string.home_header_active_title)
home_header_help.setImageResource(R.drawable.ic_help_outline_black)
home_header_picture_setup_complete.setAnimation("spinner_home.json")
content_setup_incomplete_group.visibility = GONE
context?.let { context ->
val backGroundColor = ContextCompat.getColor(context, R.color.lighter_green)
header_background.setBackgroundColor(backGroundColor)
header_background_overlap.setBackgroundColor(backGroundColor)
val textColor = ContextCompat.getColor(context, R.color.slack_black)
home_header_setup_complete_header_uploaded.setTextColor(textColor)
home_header_setup_complete_header.setTextColor(textColor)
}
}
}
}
private fun allPermissionsEnabled(): Boolean {
val bluetoothEnabled = isBlueToothEnabled() ?: true
val pushNotificationEnabled = isPushNotificationEnabled() ?: true
val nonBatteryOptimizationAllowed = isNonBatteryOptimizationAllowed() ?: true
val locationStatusAllowed = isFineLocationEnabled() ?: true
return bluetoothEnabled &&
pushNotificationEnabled &&
nonBatteryOptimizationAllowed &&
locationStatusAllowed
}
private fun registerBroadcast() {
activity?.let { activity ->
var f = IntentFilter()
activity.registerReceiver(mBroadcastListener, f)
// bluetooth on/off
f = IntentFilter()
f.addAction(BluetoothAdapter.ACTION_STATE_CHANGED)
activity.registerReceiver(mBroadcastListener, f)
mIsBroadcastListenerRegistered = true
}
}
private fun shareThisApp() {
val newIntent = Intent(Intent.ACTION_SEND)
newIntent.type = "text/plain"
newIntent.putExtra(Intent.EXTRA_TEXT, getString(R.string.share_this_app_content))
newIntent.putExtra(Intent.EXTRA_HTML_TEXT, getString(R.string.share_this_app_content_html))
startActivity(Intent.createChooser(newIntent, null))
}
private fun updateBlueToothStatus() {
isBlueToothEnabled()?.let {
bluetooth_card_view.visibility = VISIBLE
bluetooth_card_view.render(formatBlueToothTitle(it), it)
} ?: run {
bluetooth_card_view.visibility = GONE
}
}
private fun updatePushNotificationStatus() {
isPushNotificationEnabled()?.let {
push_card_view.visibility = VISIBLE
push_card_view.render(formatPushNotificationTitle(it), it)
} ?: run {
push_card_view.visibility = GONE
}
}
private fun updateBatteryOptimizationStatus() {
isNonBatteryOptimizationAllowed()?.let {
battery_card_view.visibility = VISIBLE
battery_card_view.render(formatNonBatteryOptimizationTitle(!it), it)
} ?: run {
battery_card_view.visibility = GONE
}
}
private fun updateLocationStatus() {
isFineLocationEnabled()?.let {
location_card_view.visibility = VISIBLE
location_card_view.render(formatLocationTitle(it), it)
} ?: run {
location_card_view.visibility = VISIBLE
}
}
private fun formatBlueToothTitle(on: Boolean): String {
return resources.getString(R.string.home_bluetooth_permission, getPermissionEnabledTitle(on))
}
private fun formatLocationTitle(on: Boolean): String {
return resources.getString(R.string.home_location_permission, getPermissionEnabledTitle(on))
}
private fun formatNonBatteryOptimizationTitle(on: Boolean): String {
return resources.getString(R.string.home_non_battery_optimization_permission, getPermissionEnabledTitle(on))
}
private fun formatPushNotificationTitle(on: Boolean): String {
return resources.getString(R.string.home_push_notification_permission, getPermissionEnabledTitle(on))
}
private fun getPermissionEnabledTitle(on: Boolean): String {
return resources.getString(if (on) R.string.home_permission_on else R.string.home_permission_off)
}
private fun goToNewsWebsite() {
val url = getString(R.string.home_set_complete_external_link_news_url)
try {
Intent(Intent.ACTION_VIEW).run {
data = Uri.parse(url)
startActivity(this)
}
} catch (e: ActivityNotFoundException) {
val intent = Intent(activity, WebViewActivity::class.java)
intent.putExtra(WebViewActivity.URL_ARG, url)
startActivity(intent)
}
}
private fun goToCovidApp() {
val url = getString(R.string.home_set_complete_external_link_app_url)
try {
Intent(Intent.ACTION_VIEW).run {
data = Uri.parse(url)
startActivity(this)
}
} catch (e: ActivityNotFoundException) {
val intent = Intent(activity, WebViewActivity::class.java)
intent.putExtra(WebViewActivity.URL_ARG, url)
startActivity(intent)
}
}
override fun onPermissionsDenied(requestCode: Int, perms: MutableList<String>) {
if (requestCode == LOCATION && EasyPermissions.somePermissionPermanentlyDenied(this, listOf(Manifest.permission.ACCESS_FINE_LOCATION))) {
AppSettingsDialog.Builder(this).build().show()
}
}
override fun onPermissionsGranted(requestCode: Int, perms: MutableList<String>) {
if (requestCode == LOCATION) {
checkBLESupport()
}
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
EasyPermissions.onRequestPermissionsResult(requestCode, permissions, grantResults, this)
}
}

View file

@ -0,0 +1,10 @@
package au.gov.health.covidsafe.ui.home
import androidx.lifecycle.LifecycleObserver
class HomePresenter(fragment: HomeFragment) : LifecycleObserver {
init {
fragment.lifecycle.addObserver(this)
}
}

View file

@ -0,0 +1,34 @@
package au.gov.health.covidsafe.ui.home.view
import android.content.Context
import android.content.res.TypedArray
import android.util.AttributeSet
import android.view.LayoutInflater
import androidx.constraintlayout.widget.ConstraintLayout
import au.gov.health.covidsafe.R
import kotlinx.android.synthetic.main.view_card_external_link_card.view.*
class ExternalLinkCard @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : ConstraintLayout(context, attrs, defStyleAttr) {
init {
LayoutInflater.from(context).inflate(R.layout.view_card_external_link_card, this, true)
val a: TypedArray = context.obtainStyledAttributes(attrs, R.styleable.ExternalLinkCard)
val icon = a.getDrawable(R.styleable.ExternalLinkCard_external_linkCard_icon)
val title = a.getString(R.styleable.ExternalLinkCard_external_linkCard_title)
val content = a.getString(R.styleable.ExternalLinkCard_external_linkCard_content)
val padding = a.getDimension(R.styleable.ExternalLinkCard_external_linkCard_icon_padding, 0f).toInt()
val iconBackground = a.getResourceId(R.styleable.ExternalLinkCard_external_linkCard_icon_background, R.color.transparent)
external_link_round_image.setImageDrawable(icon)
external_link_round_image.setBackgroundResource(iconBackground)
external_link_round_image.setPadding(padding, padding, padding, padding)
external_link_headline.text = title
external_link_content.text = content
a.recycle()
}
}

View file

@ -0,0 +1,37 @@
package au.gov.health.covidsafe.ui.home.view
import android.content.Context
import android.content.res.TypedArray
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.FrameLayout
import au.gov.health.covidsafe.R
import kotlinx.android.synthetic.main.view_card_permission_card.view.*
class PermissionStatusCard @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {
init {
LayoutInflater.from(context).inflate(R.layout.view_card_permission_card, this, true)
val a: TypedArray = context.obtainStyledAttributes(attrs, R.styleable.PermissionStatusCard)
val title = a.getString(R.styleable.PermissionStatusCard_permissionStatusCard_title)
a.recycle()
permission_title.text = title
val height = context.resources.getDimensionPixelSize(R.dimen.permission_height)
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, height)
}
fun render(text: String, correct: Boolean) {
permission_icon.isSelected = correct
permission_title.text = text
}
}

View file

@ -0,0 +1,104 @@
package au.gov.health.covidsafe.ui.onboarding
import android.os.Bundle
import android.view.View.GONE
import android.view.View.VISIBLE
import androidx.annotation.StringRes
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import au.gov.health.covidsafe.HasBlockingState
import au.gov.health.covidsafe.R
import au.gov.health.covidsafe.ui.PagerContainer
import au.gov.health.covidsafe.ui.UploadButtonLayout
import com.github.razir.progressbutton.bindProgressButton
import com.github.razir.progressbutton.hideProgress
import com.github.razir.progressbutton.showProgress
import kotlinx.android.synthetic.main.activity_onboarding.*
class OnboardingActivity : FragmentActivity(), HasBlockingState, PagerContainer {
override var isUiBlocked: Boolean = false
set(value) {
loadingProgressBarFrame?.isVisible = value
field = value
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_onboarding)
bindProgressButton(onboarding_next)
if (isUiBlocked) {
loadingProgressBarFrame?.isVisible = true
}
}
override fun onAttachFragment(fragment: Fragment) {
super.onAttachFragment(fragment)
isUiBlocked = false
}
override fun onResume() {
super.onResume()
toolbar.setNavigationOnClickListener {
super.onBackPressed()
}
}
override fun updateProgressBar(stepProgress: Int?) {
stepProgress?.let { progress ->
onboarding_progress_bar.visibility = VISIBLE
onboarding_progress_bar.progress = progress
} ?: run {
onboarding_progress_bar.visibility = GONE
}
}
override fun setNavigationIcon(navigationIcon: Int?) {
if (navigationIcon == null) {
toolbar.navigationIcon = null
} else {
toolbar.navigationIcon = ContextCompat.getDrawable(this, navigationIcon)
}
}
override fun refreshButton(updateButtonLayout: UploadButtonLayout) {
if (updateButtonLayout is UploadButtonLayout.ContinueLayout) {
onboarding_next.setText(updateButtonLayout.buttonText)
onboarding_next.setOnClickListener {
updateButtonLayout.buttonListener?.invoke()
}
}
}
override fun onPause() {
super.onPause()
onboarding_next.setOnClickListener(null)
toolbar.setNavigationOnClickListener(null)
}
override fun enableNextButton() {
onboarding_next.isEnabled = true
}
override fun disableNextButton() {
onboarding_next.isEnabled = false
}
override fun showLoading() {
onboarding_next.showProgress {
progressColorRes = R.color.slack_black_2
}
}
override fun hideLoading(@StringRes stringRes: Int?) {
if (stringRes == null) {
onboarding_next.hideProgress()
} else {
onboarding_next.hideProgress(newTextRes = stringRes)
}
}
}

View file

@ -0,0 +1,39 @@
package au.gov.health.covidsafe.ui.onboarding.fragment.dataprivacy
import android.os.Bundle
import android.text.method.LinkMovementMethod
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import au.gov.health.covidsafe.R
import au.gov.health.covidsafe.ui.PagerChildFragment
import au.gov.health.covidsafe.ui.UploadButtonLayout
import kotlinx.android.synthetic.main.fragment_data_privacy.*
import kotlinx.android.synthetic.main.fragment_data_privacy.view.*
class DataPrivacyFragment : PagerChildFragment() {
override val navigationIcon: Int? = R.drawable.ic_up
override var stepProgress: Int? = null
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?)
: View? = inflater.inflate(R.layout.fragment_data_privacy, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
view.data_privacy_content.movementMethod = LinkMovementMethod.getInstance()
}
override fun getUploadButtonLayout() = UploadButtonLayout.ContinueLayout(R.string.data_privacy_button) {
navigateTo(DataPrivacyFragmentDirections.actionDataPrivacyToRegistrationConsentFragment().actionId)
}
override fun updateButtonState() {
enableContinueButton()
}
override fun onDestroyView() {
super.onDestroyView()
root.removeAllViews()
}
}

View file

@ -0,0 +1,144 @@
package au.gov.health.covidsafe.ui.onboarding.fragment.enternumber
import android.app.AlertDialog
import android.os.Bundle
import android.text.Editable
import android.text.InputFilter
import android.text.TextWatcher
import android.text.method.LinkMovementMethod
import android.view.LayoutInflater
import android.view.View
import android.view.View.VISIBLE
import android.view.ViewGroup
import androidx.annotation.NavigationRes
import androidx.core.os.bundleOf
import au.gov.health.covidsafe.R
import au.gov.health.covidsafe.TracerApp
import au.gov.health.covidsafe.ui.PagerChildFragment
import au.gov.health.covidsafe.ui.UploadButtonLayout
import au.gov.health.covidsafe.ui.onboarding.fragment.enterpin.EnterPinFragment.Companion.ENTER_PIN_CHALLENGE_NAME
import au.gov.health.covidsafe.ui.onboarding.fragment.enterpin.EnterPinFragment.Companion.ENTER_PIN_DESTINATION_ID
import au.gov.health.covidsafe.ui.onboarding.fragment.enterpin.EnterPinFragment.Companion.ENTER_PIN_PHONE_NUMBER
import au.gov.health.covidsafe.ui.onboarding.fragment.enterpin.EnterPinFragment.Companion.ENTER_PIN_PROGRESS
import au.gov.health.covidsafe.ui.onboarding.fragment.enterpin.EnterPinFragment.Companion.ENTER_PIN_SESSION
import kotlinx.android.synthetic.main.fragment_enter_number.*
import kotlinx.android.synthetic.main.fragment_enter_number.view.*
class EnterNumberFragment : PagerChildFragment() {
companion object {
const val ENTER_NUMBER_DESTINATION_ID = "destination_id"
const val ENTER_NUMBER_PROGRESS = "progress"
}
override val navigationIcon: Int? = R.drawable.ic_up
override var stepProgress: Int? = 2
private val enterNumberPresenter = EnterNumberPresenter(this)
private var alertDialog: AlertDialog? = null
@NavigationRes
private var destinationId: Int? = null
private val phoneNumberTextWatcher: TextWatcher = object : TextWatcher {
override fun afterTextChanged(s: Editable?) {
// change LengthFilter if user making a mistake of entering phone number starting with 0
val phoneNumberLength = TracerApp.AppContext.resources.getInteger(R.integer.australian_phone_number_length)
val filters = enter_number_phone_number.filters
val newFilterLength = if (s?.toString()?.startsWith("0") == true) {
phoneNumberLength + 1
} else {
phoneNumberLength
}
enter_number_phone_number.filters = filters.filterNot { it is InputFilter.LengthFilter }.toTypedArray() +
InputFilter.LengthFilter(newFilterLength)
updateButtonState()
}
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
}
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?)
: View? = inflater.inflate(R.layout.fragment_enter_number, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
view.use_oz_phone_number.movementMethod = LinkMovementMethod.getInstance()
arguments?.let {
destinationId = it.getInt(ENTER_NUMBER_DESTINATION_ID)
stepProgress = if (it.containsKey(ENTER_NUMBER_PROGRESS)) it.getInt(ENTER_PIN_PROGRESS) else null
}
}
override fun onResume() {
super.onResume()
enter_number_phone_number.selectAll()
enter_number_phone_number.addTextChangedListener(phoneNumberTextWatcher)
updateButtonState()
}
override fun onPause() {
super.onPause()
enter_number_phone_number.removeTextChangedListener(phoneNumberTextWatcher)
}
fun showInvalidPhoneNumber() {
invalid_phone_number.visibility = VISIBLE
enter_number_phone_number.background = context?.getDrawable(R.drawable.phone_number_invalid_background)
}
override fun updateButtonState() {
if (enterNumberPresenter.validateAuNumber(enter_number_phone_number?.text?.toString())) {
enableContinueButton()
} else {
disableContinueButton()
}
}
fun showGenericError() {
alertDialog?.dismiss()
alertDialog = AlertDialog.Builder(activity)
.setMessage(R.string.generic_error)
.setIcon(android.R.drawable.ic_dialog_alert)
.setPositiveButton(android.R.string.yes, null).show()
}
fun navigateToOTPPage(
session: String?,
challengeName: String?,
phoneNumber: String) {
val bundle = bundleOf(
ENTER_PIN_SESSION to session,
ENTER_PIN_CHALLENGE_NAME to challengeName,
ENTER_PIN_PHONE_NUMBER to phoneNumber,
ENTER_PIN_DESTINATION_ID to destinationId).also { bundle ->
stepProgress?.let {
bundle.putInt(ENTER_PIN_PROGRESS, it + 1)
}
}
navigateTo(R.id.action_enterNumberFragment_to_otpFragment, bundle)
}
override fun onDestroyView() {
super.onDestroyView()
alertDialog?.dismiss()
root.removeAllViews()
}
override fun getUploadButtonLayout() = UploadButtonLayout.ContinueLayout(R.string.enter_number_button) {
enterNumberPresenter.requestOTP(enter_number_phone_number.text.toString().trim())
}
fun showCheckInternetError() {
alertDialog?.dismiss()
alertDialog = AlertDialog.Builder(activity)
.setMessage(R.string.generic_internet_error)
.setIcon(android.R.drawable.ic_dialog_alert)
.setPositiveButton(android.R.string.yes, null).show()
}
}

View file

@ -0,0 +1,91 @@
package au.gov.health.covidsafe.ui.onboarding.fragment.enternumber
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleObserver
import androidx.lifecycle.OnLifecycleEvent
import au.gov.health.covidsafe.Preference
import au.gov.health.covidsafe.R
import au.gov.health.covidsafe.TracerApp
import au.gov.health.covidsafe.extensions.isInternetAvailable
import au.gov.health.covidsafe.factory.NetworkFactory
import au.gov.health.covidsafe.interactor.usecase.GetOnboardingOtp
import au.gov.health.covidsafe.interactor.usecase.GetOnboardingOtpException
import au.gov.health.covidsafe.interactor.usecase.GetOtpParams
class EnterNumberPresenter(private val enterNumberFragment: EnterNumberFragment) : LifecycleObserver {
private val TAG = this.javaClass.simpleName
private lateinit var phoneNumber: String
private lateinit var getOnboardingOtp: GetOnboardingOtp
init {
enterNumberFragment.lifecycle.addObserver(this)
}
@OnLifecycleEvent(Lifecycle.Event.ON_CREATE)
private fun onCreate() {
getOnboardingOtp = GetOnboardingOtp(NetworkFactory.awsClient, enterNumberFragment.lifecycle)
}
internal fun requestOTP(phoneNumber: String) {
when {
enterNumberFragment.activity?.isInternetAvailable() == false -> {
enterNumberFragment.showCheckInternetError()
}
validateAuNumber(phoneNumber) -> {
val cleansedNumber = if (phoneNumber.startsWith("0")) {
phoneNumber.takeLast(TracerApp.AppContext.resources.getInteger(R.integer.australian_phone_number_length))
} else phoneNumber
val fullNumber = "${enterNumberFragment.resources.getString(R.string.enter_number_prefix)}$cleansedNumber"
Preference.putPhoneNumber(TracerApp.AppContext, fullNumber)
this.phoneNumber = cleansedNumber
makeOTPCall(cleansedNumber)
}
else -> {
enterNumberFragment.showInvalidPhoneNumber()
}
}
}
/**
* @param phoneNumber cleansed phone number, 9 digits, doesn't start with 0
*/
private fun makeOTPCall(phoneNumber: String) {
enterNumberFragment.activity?.let {
enterNumberFragment.disableContinueButton()
enterNumberFragment.showLoading()
getOnboardingOtp.invoke(GetOtpParams(phoneNumber,
Preference.getDeviceID(enterNumberFragment.requireContext()),
Preference.getPostCode(enterNumberFragment.requireContext()),
Preference.getAge(enterNumberFragment.requireContext()),
Preference.getName(enterNumberFragment.requireContext())),
onSuccess = {
enterNumberFragment.navigateToOTPPage(
it.session,
it.challengeName,
phoneNumber)
},
onFailure = {
if (it is GetOnboardingOtpException.GetOtpInvalidNumberException) {
enterNumberFragment.showInvalidPhoneNumber()
} else {
enterNumberFragment.showGenericError()
}
enterNumberFragment.hideLoading()
enterNumberFragment.enableContinueButton()
})
}
}
internal fun validateAuNumber(phoneNumber: String?): Boolean {
var australianPhoneNumberLength = enterNumberFragment.resources.getInteger(R.integer.australian_phone_number_length)
if (phoneNumber?.startsWith("0") == true) {
australianPhoneNumberLength++
}
return (phoneNumber?.length ?: 0) == australianPhoneNumberLength
}
}

View file

@ -0,0 +1,191 @@
package au.gov.health.covidsafe.ui.onboarding.fragment.enterpin
import android.app.AlertDialog
import android.os.Bundle
import android.os.CountDownTimer
import android.text.method.LinkMovementMethod
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.annotation.NavigationRes
import androidx.core.content.ContextCompat
import au.gov.health.covidsafe.R
import au.gov.health.covidsafe.extensions.toHyperlink
import au.gov.health.covidsafe.ui.PagerChildFragment
import au.gov.health.covidsafe.ui.UploadButtonLayout
import com.atlassian.mobilekit.module.core.utils.SystemUtils
import kotlinx.android.synthetic.main.fragment_enter_pin.*
import kotlinx.android.synthetic.main.fragment_enter_pin.view.*
import kotlin.math.floor
class EnterPinFragment : PagerChildFragment() {
companion object {
const val ENTER_PIN_SESSION = "session"
const val ENTER_PIN_CHALLENGE_NAME = "challenge_name"
const val ENTER_PIN_PHONE_NUMBER = "phone_number"
const val ENTER_PIN_DESTINATION_ID = "destination_id"
const val ENTER_PIN_PROGRESS = "progress"
}
override val navigationIcon = R.drawable.ic_up
override var stepProgress: Int? = 3
private val COUNTDOWN_DURATION = 5 * 60L // OTP Code expiry
private var alertDialog: AlertDialog? = null
private var stopWatch: CountDownTimer? = null
private lateinit var presenter: EnterPinPresenter
@NavigationRes
private var destinationId: Int? = null
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?)
: View? = inflater.inflate(R.layout.fragment_enter_pin, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
arguments?.let {
val session = it.getString(ENTER_PIN_SESSION)
val challengeName = it.getString(ENTER_PIN_CHALLENGE_NAME)
val phoneNumber = it.getString(ENTER_PIN_PHONE_NUMBER)
destinationId = it.getInt(ENTER_PIN_DESTINATION_ID)
stepProgress = if (it.containsKey(ENTER_PIN_PROGRESS)) it.getInt(ENTER_PIN_PROGRESS) else null
enter_pin_headline.text = resources.getString(R.string.enter_pin_headline, resources.getString(R.string.enter_number_prefix), phoneNumber)
presenter = EnterPinPresenter(this@EnterPinFragment,
session,
challengeName,
phoneNumber)
}
enter_pin_wrong_number.toHyperlink {
popBackStack()
}
enter_pin_resend_pin.toHyperlink {
presenter.resendCode()
}
view.pin_issue.movementMethod = LinkMovementMethod.getInstance()
startTimer()
}
override fun onResume() {
super.onResume()
updateButtonState()
pin.onPinChanged = {
updateButtonState()
hideInvalidOtp()
}
}
override fun onPause() {
super.onPause()
pin.onPinChanged = null
}
private fun startTimer() {
stopWatch = object : CountDownTimer(COUNTDOWN_DURATION * 1000, 1000) {
override fun onTick(millisUntilFinished: Long) {
val numberOfMins = floor((millisUntilFinished * 1.0) / 60000)
val numberOfMinsInt = numberOfMins.toInt()
val numberOfSeconds = floor((millisUntilFinished / 1000.0) % 60)
val numberOfSecondsInt = numberOfSeconds.toInt()
val finalNumberOfSecondsString = if (numberOfSecondsInt < 10) {
"0$numberOfSecondsInt"
} else {
"$numberOfSecondsInt"
}
enter_pin_timer_value?.text = "$numberOfMinsInt:$finalNumberOfSecondsString"
}
override fun onFinish() {
enter_pin_timer_value?.text = "0:00"
enter_pin_resend_pin.isEnabled = true
activity?.let {
enter_pin_resend_pin.setLinkTextColor(ContextCompat.getColor(it, R.color.hyperlink_enabled))
}
}
}
stopWatch?.start()
enter_pin_resend_pin.isEnabled = false
activity?.let {
enter_pin_resend_pin.setLinkTextColor(ContextCompat.getColor(it, R.color.hyperlink_disabled))
}
}
fun resetTimer() {
stopWatch?.cancel()
startTimer()
}
override fun onDestroyView() {
super.onDestroyView()
stopWatch?.cancel()
alertDialog?.dismiss()
enter_pin_resend_pin.setOnClickListener(null)
enter_pin_wrong_number.setOnClickListener(null)
root.removeAllViews()
}
fun hideKeyboard() {
activity?.currentFocus?.let { view ->
SystemUtils.hideSoftKeyboard(view)
}
}
fun showInvalidOtp() {
enter_pin_error_label.visibility = View.VISIBLE
}
private fun hideInvalidOtp() {
enter_pin_error_label.visibility = View.GONE
}
fun showGenericError() {
alertDialog?.dismiss()
alertDialog = AlertDialog.Builder(activity)
.setMessage(R.string.generic_error)
.setIcon(android.R.drawable.ic_dialog_alert)
.setPositiveButton(android.R.string.yes, null).show()
}
private fun isIncorrectPinFormat(): Boolean {
return requireView().pin.isIncomplete
}
override fun updateButtonState() {
if (isIncorrectPinFormat()) {
disableContinueButton()
} else {
enableContinueButton()
}
}
private fun validateOtp() {
presenter.validateOTP(requireView().pin.value)
}
fun showErrorOtpMustBeSixDigits() {
}
fun navigateToNextPage() {
navigateTo(destinationId ?: R.id.action_otpFragment_to_permissionFragment)
}
override fun getUploadButtonLayout() = UploadButtonLayout.ContinueLayout(R.string.enter_pin_button) {
validateOtp()
}
fun showCheckInternetError() {
alertDialog?.dismiss()
alertDialog = AlertDialog.Builder(activity)
.setMessage(R.string.generic_internet_error)
.setIcon(android.R.drawable.ic_dialog_alert)
.setPositiveButton(android.R.string.yes, null).show()
}
}

View file

@ -0,0 +1,112 @@
package au.gov.health.covidsafe.ui.onboarding.fragment.enterpin
import android.text.TextUtils
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleObserver
import androidx.lifecycle.OnLifecycleEvent
import au.gov.health.covidsafe.Preference
import au.gov.health.covidsafe.extensions.isInternetAvailable
import au.gov.health.covidsafe.factory.NetworkFactory
import au.gov.health.covidsafe.interactor.usecase.GetOnboardingOtp
import au.gov.health.covidsafe.interactor.usecase.GetOtpParams
import au.gov.health.covidsafe.logging.CentralLog
import au.gov.health.covidsafe.networking.request.AuthChallengeRequest
import au.gov.health.covidsafe.networking.response.AuthChallengeResponse
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
class EnterPinPresenter(private val enterPinFragment: EnterPinFragment,
private var session: String?,
private var challengeName: String?,
private val phoneNumber: String?) : LifecycleObserver {
private val TAG = this.javaClass.simpleName
private var awsClient = NetworkFactory.awsClient
private lateinit var getOtp: GetOnboardingOtp
init {
enterPinFragment.lifecycle.addObserver(this)
}
@OnLifecycleEvent(Lifecycle.Event.ON_CREATE)
private fun onCreate() {
getOtp = GetOnboardingOtp(awsClient, enterPinFragment.lifecycle)
}
internal fun resendCode() {
enterPinFragment.activity?.let {
when {
!it.isInternetAvailable() -> {
enterPinFragment.showCheckInternetError()
}
phoneNumber == null -> {
enterPinFragment.showGenericError()
}
else -> {
getOtp.invoke(GetOtpParams(phoneNumber,
Preference.getDeviceID(enterPinFragment.requireContext()),
Preference.getPostCode(enterPinFragment.requireContext()),
Preference.getAge(enterPinFragment.requireContext()),
Preference.getName(enterPinFragment.requireContext())),
onSuccess = {
session = it.session
challengeName = it.challengeName
enterPinFragment.resetTimer()
},
onFailure = {
enterPinFragment.showGenericError()
})
}
}
}
}
internal fun validateOTP(otp: String) {
if (TextUtils.isEmpty(otp) || otp.length != 6) {
enterPinFragment.showErrorOtpMustBeSixDigits()
return
}
if (enterPinFragment.activity?.isInternetAvailable() == false) {
enterPinFragment.showCheckInternetError()
return
}
enterPinFragment.disableContinueButton()
enterPinFragment.showLoading()
val authChallengeCall: Call<AuthChallengeResponse> = awsClient.respondToAuthChallenge(AuthChallengeRequest(session, otp))
authChallengeCall.enqueue(object : Callback<AuthChallengeResponse> {
override fun onResponse(call: Call<AuthChallengeResponse>, response: Response<AuthChallengeResponse>) {
if (response.code() == 200) {
CentralLog.d(TAG, "code received")
val authChallengeResponse = response.body()
val handShakePin = authChallengeResponse?.pin
handShakePin?.let {
Preference.putHandShakePin(enterPinFragment.context, handShakePin)
}
val jwtToken = authChallengeResponse?.token
jwtToken.let {
Preference.putEncrypterJWTToken(enterPinFragment.requireContext(), jwtToken)
}
enterPinFragment.hideKeyboard()
enterPinFragment.navigateToNextPage()
} else {
onError()
}
}
override fun onFailure(call: Call<AuthChallengeResponse>, t: Throwable) {
onError()
}
})
}
private fun onError() {
enterPinFragment.enableContinueButton()
enterPinFragment.hideLoading()
enterPinFragment.hideKeyboard()
enterPinFragment.showInvalidOtp()
}
}

View file

@ -0,0 +1,39 @@
package au.gov.health.covidsafe.ui.onboarding.fragment.howitworks
import android.os.Bundle
import android.text.method.LinkMovementMethod
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import au.gov.health.covidsafe.R
import au.gov.health.covidsafe.ui.PagerChildFragment
import au.gov.health.covidsafe.ui.UploadButtonLayout
import kotlinx.android.synthetic.main.fragment_how_it_works.*
import kotlinx.android.synthetic.main.fragment_how_it_works.view.*
class HowItWorksFragment : PagerChildFragment() {
override val navigationIcon: Int? = R.drawable.ic_up
override var stepProgress: Int? = null
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?)
: View? = inflater.inflate(R.layout.fragment_how_it_works, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
view.how_it_works_content.movementMethod = LinkMovementMethod.getInstance()
}
override fun getUploadButtonLayout() = UploadButtonLayout.ContinueLayout(R.string.how_it_works_button) {
navigateTo(R.id.action_howItWorksFragment_to_dataPrivacy)
}
override fun updateButtonState() {
enableContinueButton()
}
override fun onDestroyView() {
super.onDestroyView()
root.removeAllViews()
}
}

View file

@ -0,0 +1,32 @@
package au.gov.health.covidsafe.ui.onboarding.fragment.introduction
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import au.gov.health.covidsafe.R
import au.gov.health.covidsafe.ui.PagerChildFragment
import au.gov.health.covidsafe.ui.UploadButtonLayout
import kotlinx.android.synthetic.main.fragment_intro.*
class IntroductionFragment : PagerChildFragment() {
override val navigationIcon: Int? = null
override var stepProgress: Int? = null
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?)
: View? = inflater.inflate(R.layout.fragment_intro, container, false)
override fun getUploadButtonLayout() = UploadButtonLayout.ContinueLayout(R.string.intro_button) {
navigateTo(R.id.action_introFragment_to_howItWorksFragment)
}
override fun updateButtonState() {
enableContinueButton()
}
override fun onDestroyView() {
super.onDestroyView()
root.removeAllViews()
}
}

View file

@ -0,0 +1,121 @@
package au.gov.health.covidsafe.ui.onboarding.fragment.permission
import android.Manifest
import android.app.Activity
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Bundle
import android.os.Handler
import android.os.PowerManager
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import au.gov.health.covidsafe.HomeActivity
import au.gov.health.covidsafe.Preference
import au.gov.health.covidsafe.R
import au.gov.health.covidsafe.TracerApp
import au.gov.health.covidsafe.extensions.*
import au.gov.health.covidsafe.ui.PagerChildFragment
import au.gov.health.covidsafe.ui.UploadButtonLayout
import kotlinx.android.synthetic.main.fragment_permission.*
import pub.devrel.easypermissions.EasyPermissions
class PermissionFragment : PagerChildFragment(), EasyPermissions.PermissionCallbacks {
companion object {
val requiredPermissions = arrayOf(
Manifest.permission.BLUETOOTH,
Manifest.permission.BLUETOOTH_ADMIN,
Manifest.permission.ACCESS_FINE_LOCATION
)
}
override val navigationIcon: Int? = R.drawable.ic_up
override var stepProgress: Int? = 5
private var navigationStarted = false
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?)
: View? = inflater.inflate(R.layout.fragment_permission, container, false)
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == REQUEST_ENABLE_BT) {
if (resultCode == Activity.RESULT_CANCELED) {
excludeFromBatteryOptimization { navigateToNextPage() }
return
} else {
requestAllPermissions { navigateToNextPage() }
}
} else if (requestCode == BATTERY_OPTIMISER) {
Handler().postDelayed({
navigateToNextPage()
}, 1000)
} else super.onActivityResult(requestCode, resultCode, data)
}
private fun navigateToNextPage() {
navigationStarted = false
if (hasAllPermissionsAndBluetoothOn()) {
navigateTo(R.id.action_permissionFragment_to_permissionSuccessFragment)
} else {
navigateToMainActivity()
}
}
private fun hasAllPermissionsAndBluetoothOn(): Boolean {
val context = TracerApp.AppContext
return isBlueToothEnabled() == true
&& requiredPermissions.all { ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED }
&& ContextCompat.getSystemService(context, PowerManager::class.java)?.isIgnoringBatteryOptimizations(context.packageName) ?: true
}
private fun navigateToMainActivity() {
val intent = Intent(context, HomeActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
activity?.startActivity(intent)
activity?.finish()
}
override fun onPermissionsDenied(requestCode: Int, perms: MutableList<String>) {
if (requestCode == LOCATION) {
excludeFromBatteryOptimization { navigateToNextPage() }
} else {
requestAllPermissions { navigateToNextPage() }
}
}
override fun onPermissionsGranted(requestCode: Int, perms: MutableList<String>) {
requestAllPermissions { navigateToNextPage() }
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
EasyPermissions.onRequestPermissionsResult(requestCode, permissions, grantResults, this)
}
override fun getUploadButtonLayout() = UploadButtonLayout.ContinueLayout(R.string.permission_button) {
disableContinueButton()
navigationStarted = true
activity?.let {
Preference.putIsOnBoarded(it, true)
}
requestAllPermissions {
navigateToNextPage()
}
}
override fun updateButtonState() {
if (navigationStarted) {
disableContinueButton()
} else {
enableContinueButton()
}
}
override fun onDestroyView() {
super.onDestroyView()
root.removeAllViews()
}
}

View file

@ -0,0 +1,41 @@
package au.gov.health.covidsafe.ui.onboarding.fragment.permissionsuccess
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import kotlinx.android.synthetic.main.fragment_permission_success.*
import au.gov.health.covidsafe.HomeActivity
import au.gov.health.covidsafe.R
import au.gov.health.covidsafe.ui.PagerChildFragment
import au.gov.health.covidsafe.ui.UploadButtonLayout
class PermissionSuccessFragment : PagerChildFragment() {
override val navigationIcon: Int? = R.drawable.ic_up
override var stepProgress: Int? = 5
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?)
: View? = inflater.inflate(R.layout.fragment_permission_success, container, false)
private fun navigateToNextPage() {
val intent = Intent(context, HomeActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
activity?.startActivity(intent)
activity?.finish()
}
override fun getUploadButtonLayout() = UploadButtonLayout.ContinueLayout(R.string.permission_success_button) {
navigateToNextPage()
}
override fun updateButtonState() {
enableContinueButton()
}
override fun onDestroyView() {
super.onDestroyView()
root.removeAllViews()
}
}

View file

@ -0,0 +1,182 @@
package au.gov.health.covidsafe.ui.onboarding.fragment.personal
import android.app.Activity
import android.os.Bundle
import android.text.Editable
import android.text.TextWatcher
import android.text.method.LinkMovementMethod
import android.view.LayoutInflater
import android.view.View
import android.view.View.GONE
import android.view.View.VISIBLE
import android.view.ViewGroup
import android.view.inputmethod.InputMethodManager
import android.widget.NumberPicker
import androidx.appcompat.app.AlertDialog
import androidx.core.os.bundleOf
import au.gov.health.covidsafe.R
import au.gov.health.covidsafe.ui.PagerChildFragment
import au.gov.health.covidsafe.ui.UploadButtonLayout
import au.gov.health.covidsafe.ui.onboarding.fragment.enternumber.EnterNumberFragment
import kotlinx.android.synthetic.main.fragment_personal_details.*
class PersonalDetailsFragment : PagerChildFragment() {
private var picker: NumberPicker? = null
private var alertDialog: AlertDialog? = null
override var stepProgress: Int? = 1
override val navigationIcon: Int = R.drawable.ic_up
private var ageSelected: Pair<String, String>? = null
private val presenter = PersonalDetailsPresenter(this)
private val nameTextWatcher: TextWatcher = object : TextWatcher {
override fun afterTextChanged(s: Editable?) {
hideNameError()
updateButtonState()
}
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
}
}
private val postCodeTextWatcher: TextWatcher = object : TextWatcher {
override fun afterTextChanged(s: Editable?) {
presenter.validateInlinePostCode(s.toString())
updateButtonState()
}
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
}
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?)
: View? = inflater.inflate(R.layout.fragment_personal_details, container, false)
override fun onResume() {
super.onResume()
personal_details_name.addTextChangedListener(nameTextWatcher)
personal_details_post_code.addTextChangedListener(postCodeTextWatcher)
personal_details_age.setOnClickListener {
showAgePicker()
}
personal_details_age.text = ageSelected?.second
}
override fun onPause() {
super.onPause()
personal_details_name.removeTextChangedListener(nameTextWatcher)
personal_details_post_code.removeTextChangedListener(postCodeTextWatcher)
personal_details_age.setOnClickListener(null)
alertDialog?.dismiss()
}
override fun getUploadButtonLayout(): UploadButtonLayout = UploadButtonLayout.ContinueLayout(R.string.personal_details_button) {
presenter.saveInfos(personal_details_name.text.toString(), personal_details_post_code.text.toString(), getMidAgeToSend())
}
override fun updateButtonState() {
if (presenter.validateInputsForButtonUpdate(personal_details_name.text.toString(), personal_details_post_code.text.toString(), getMidAgeToSend())) {
enableContinueButton()
} else {
disableContinueButton()
}
}
fun showGenericError() {
activity?.let { activity ->
alertDialog?.dismiss()
alertDialog = AlertDialog.Builder(activity)
.setMessage(R.string.generic_error)
.setIcon(android.R.drawable.ic_dialog_alert)
.setPositiveButton(android.R.string.yes, null).show()
}
}
fun navigateToNextPage(minor: Boolean) {
if (minor) {
navigateTo(PersonalDetailsFragmentDirections.actionPersonalDetailsToUnderSixteenFragment().actionId)
} else {
val bundle = bundleOf(
EnterNumberFragment.ENTER_NUMBER_DESTINATION_ID to R.id.action_otpFragment_to_permissionFragment,
EnterNumberFragment.ENTER_NUMBER_PROGRESS to 2)
navigateTo(PersonalDetailsFragmentDirections.actionPersonalDetailsToEnterNumberFragment().actionId, bundle)
}
}
fun showPostcodeError() {
personal_details_post_code_error.visibility = VISIBLE
}
fun hidePostcodeError() {
personal_details_post_code_error.visibility = GONE
}
fun showNameError() {
personal_details_name_error.visibility = VISIBLE
}
fun hideNameError() {
personal_details_name_error.visibility = GONE
}
fun showAgeError() {
personal_details_age_error.visibility = VISIBLE
}
fun hideAgeError() {
personal_details_age_error.visibility = GONE
}
private fun showAgePicker() {
activity?.let { activity ->
val ages = resources.getStringArray(R.array.personal_details_age_array).map {
it.split(":").let { it[0] to it[1] }
}
var selected = ages.firstOrNull { it == ageSelected }?.let {
ages.indexOf(it)
} ?: 0
picker = NumberPicker(activity)
picker?.minValue = 0
picker?.maxValue = ages.size - 1
picker?.displayedValues = ages.map { it.second }.toTypedArray()
picker?.setOnValueChangedListener { _, _, newVal ->
selected = newVal
}
picker?.value = selected
alertDialog?.dismiss()
alertDialog = AlertDialog.Builder(activity)
.setTitle(R.string.personal_details_age_dialog_title)
.setView(picker)
.setPositiveButton(R.string.personal_details_dialog_ok) { _, _ ->
ageSelected = ages[selected]
personal_details_age.text = ages[selected].second
hideAgeError()
updateButtonState()
}
.setNegativeButton(android.R.string.no, null)
.show()
}
}
private fun getMidAgeToSend(): String? {
val ages = resources.getStringArray(R.array.personal_details_age_array).map {
it.split(":").let { it[0] to it[1] }
}
val selected = ages.firstOrNull { it == ageSelected }?.let {
ages.indexOf(it)
}
return selected?.let {
ages[selected].first
}
}
}

View file

@ -0,0 +1,96 @@
package au.gov.health.covidsafe.ui.onboarding.fragment.personal
import au.gov.health.covidsafe.Preference
import java.util.regex.Pattern
class PersonalDetailsPresenter(private val personalDetailsFragment: PersonalDetailsFragment) {
private val TAG = this.javaClass.simpleName
private val POST_CODE_REGEX = Pattern.compile("^(?:(?:[2-8]\\d|9[0-7]|0?[28]|0?9(?=09))(?:\\d{2}))$")
fun saveInfos(name: String?, postCode: String?, age: String?) {
personalDetailsFragment.showLoading()
personalDetailsFragment.context?.let { context ->
val ageInt = age?.toIntOrNull()
val nameValid = name.isNullOrBlank().not()
val postCodeValid = postCode.isNullOrBlank().not() && isPostCodeValid(postCode)
val ageValid = age.isNullOrBlank().not()
if (nameValid && postCodeValid && ageValid) {
val valid = (name?.let { name ->
Preference.putName(context, name)
} ?: false) &&
(age?.let { age ->
Preference.putAge(context, age)
} ?: false) &&
(postCode?.let { postCode ->
Preference.putPostCode(context, postCode)
} ?: false)
if (valid) {
personalDetailsFragment.hideLoading()
personalDetailsFragment.navigateToNextPage(ageInt?.let { it < 16 } ?: false)
} else {
personalDetailsFragment.hideLoading()
personalDetailsFragment.showGenericError()
}
} else {
showFieldsError(name, postCode, age)
personalDetailsFragment.hideLoading()
}
} ?: run {
personalDetailsFragment.hideLoading()
personalDetailsFragment.showGenericError()
}
}
private fun showFieldsError(name: String?, postCode: String?, age: String?) {
updateNameFieldError(name)
updateAgeFieldError(age)
updatePostcodeFieldError(postCode)
}
private fun updateAgeFieldError(age: String?) {
return if (age.isNullOrBlank()) {
personalDetailsFragment.showAgeError()
} else {
personalDetailsFragment.hideAgeError()
}
}
private fun updateNameFieldError(name: String?) {
return if (name.isNullOrBlank()) {
personalDetailsFragment.showNameError()
} else {
personalDetailsFragment.hideNameError()
}
}
private fun updatePostcodeFieldError(postCode: String?) {
return if (postCode.isNullOrBlank()) {
personalDetailsFragment.showPostcodeError()
} else {
personalDetailsFragment.hidePostcodeError()
}
}
fun validateInputsForButtonUpdate(name: String?, postCode: String?, age: String?): Boolean {
val nameValid = name.isNullOrBlank().not()
val postCodeValid = postCode.isNullOrBlank().not() && isPostCodeValid(postCode)
val ageValid = age.isNullOrBlank().not()
return nameValid && postCodeValid && ageValid
}
internal fun validateInlinePostCode(postCode: String?) {
if (!postCode.isNullOrEmpty() && postCode.length == 4 && !isPostCodeValid(postCode)) {
personalDetailsFragment.showPostcodeError()
} else {
personalDetailsFragment.hidePostcodeError()
}
}
private fun isPostCodeValid(postCode: String?) = POST_CODE_REGEX.matcher(postCode.toString()).matches()
}

View file

@ -0,0 +1,44 @@
package au.gov.health.covidsafe.ui.onboarding.fragment.registrationcontent
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import au.gov.health.covidsafe.R
import au.gov.health.covidsafe.ui.PagerChildFragment
import au.gov.health.covidsafe.ui.PagerContainer
import au.gov.health.covidsafe.ui.UploadButtonLayout
import kotlinx.android.synthetic.main.fragment_registration_consent.*
class RegistrationContentFragment : PagerChildFragment() {
override val navigationIcon: Int? = R.drawable.ic_up
override var stepProgress: Int? = null
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?)
: View? = inflater.inflate(R.layout.fragment_registration_consent, container, false)
override fun onResume() {
super.onResume()
registration_consent_checkbox.setOnCheckedChangeListener { buttonView, isChecked ->
updateButtonState()
}
}
override fun updateButtonState() {
if (registration_consent_checkbox.isChecked) {
(activity as? PagerContainer)?.enableNextButton()
} else {
(activity as? PagerContainer)?.disableNextButton()
}
}
override fun onDestroyView() {
super.onDestroyView()
root.removeAllViews()
}
override fun getUploadButtonLayout() = UploadButtonLayout.ContinueLayout(R.string.registration_consent_button) {
navigateTo(RegistrationContentFragmentDirections.actionRegistrationConsentFragmentToPersonalDetailsFragment().actionId)
}
}

View file

@ -0,0 +1,51 @@
package au.gov.health.covidsafe.ui.onboarding.fragment.undersixteen
import android.os.Bundle
import android.text.method.LinkMovementMethod
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.os.bundleOf
import au.gov.health.covidsafe.R
import au.gov.health.covidsafe.ui.PagerChildFragment
import au.gov.health.covidsafe.ui.UploadButtonLayout
import au.gov.health.covidsafe.ui.onboarding.fragment.enternumber.EnterNumberFragment
import kotlinx.android.synthetic.main.fragment_under_sixteen.*
class UnderSixteenFragment : PagerChildFragment() {
override val navigationIcon: Int? = R.drawable.ic_up
override var stepProgress: Int? = null
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?)
: View? = inflater.inflate(R.layout.fragment_under_sixteen, container, false)
override fun onResume() {
super.onResume()
under_sixteen_checkbox.setOnCheckedChangeListener { buttonView, isChecked ->
updateButtonState()
}
}
override fun onPause() {
super.onPause()
under_sixteen_checkbox.setOnCheckedChangeListener(null)
}
override fun getUploadButtonLayout(): UploadButtonLayout = UploadButtonLayout.ContinueLayout(R.string.under_sixteen_button) {
val bundle = bundleOf(
EnterNumberFragment.ENTER_NUMBER_DESTINATION_ID to R.id.action_otpFragment_to_permissionFragment,
EnterNumberFragment.ENTER_NUMBER_PROGRESS to 2)
navigateTo(UnderSixteenFragmentDirections.actionUnderSixteenFragmentToEnterNumberFragment().actionId, bundle)
}
override fun updateButtonState() {
if (under_sixteen_checkbox.isChecked) {
enableContinueButton()
} else {
disableContinueButton()
}
}
}

View file

@ -0,0 +1,107 @@
package au.gov.health.covidsafe.ui.upload
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.View.*
import android.view.ViewGroup
import androidx.annotation.StringRes
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import au.gov.health.covidsafe.R
import au.gov.health.covidsafe.ui.PagerContainer
import au.gov.health.covidsafe.ui.UploadButtonLayout
import com.github.razir.progressbutton.hideProgress
import com.github.razir.progressbutton.showProgress
import kotlinx.android.synthetic.main.fragment_upload_master.*
class UploadContainerFragment : Fragment(), PagerContainer {
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? = inflater.inflate(R.layout.fragment_upload_master, container, false)
override fun onResume() {
super.onResume()
toolbar.setNavigationOnClickListener {
activity?.onBackPressed()
}
}
override fun onPause() {
super.onPause()
toolbar.setNavigationOnClickListener(null)
}
override fun updateProgressBar(stepProgress: Int?) {
if (stepProgress == null) {
upload_progress.visibility = INVISIBLE
} else {
upload_progress.visibility = VISIBLE
upload_progress.progress = stepProgress
}
}
override fun setNavigationIcon(navigationIcon: Int?) {
if (navigationIcon == null) {
toolbar.navigationIcon = null
} else {
activity?.let {
toolbar.navigationIcon = ContextCompat.getDrawable(it, navigationIcon)
}
}
}
override fun refreshButton(uploadButtonLayout: UploadButtonLayout) {
when (uploadButtonLayout) {
is UploadButtonLayout.ContinueLayout -> {
upload_continue.setOnClickListener {
uploadButtonLayout.buttonListener?.invoke()
}
upload_continue.setText(uploadButtonLayout.buttonText)
upload_continue.visibility = VISIBLE
upload_answerNo.setOnClickListener(null)
upload_answerYes.setOnClickListener(null)
upload_answerNo.visibility = GONE
upload_answerYes.visibility = GONE
}
is UploadButtonLayout.QuestionLayout -> {
upload_continue.setOnClickListener(null)
upload_continue.visibility = GONE
upload_answerNo.setOnClickListener {
uploadButtonLayout.buttonNoListener.invoke()
}
upload_answerYes.setOnClickListener {
uploadButtonLayout.buttonYesListener.invoke()
}
upload_answerNo.visibility = VISIBLE
upload_answerYes.visibility = VISIBLE
}
}
}
override fun enableNextButton() {
upload_continue.isEnabled = true
}
override fun disableNextButton() {
upload_continue.isEnabled = false
}
override fun showLoading() {
upload_continue.showProgress {
progressColorRes = R.color.slack_black_2
}
}
override fun hideLoading(@StringRes stringRes: Int?) {
if (stringRes == null) {
upload_continue.hideProgress()
} else {
upload_continue.hideProgress(newTextRes = stringRes)
}
}
override fun onDestroyView() {
super.onDestroyView()
root.removeAllViews()
}
}

View file

@ -0,0 +1,5 @@
package au.gov.health.covidsafe.ui.upload.model
import au.gov.health.covidsafe.streetpass.persistence.StreetPassRecord
class DebugData constructor(var records: List<StreetPassRecord>)

Some files were not shown because too many files have changed in this diff Show more