mirror of
https://github.com/AU-COVIDSafe/mobile-android.git
synced 2025-04-28 09:25:17 +00:00
COVIDSafe code from version 1.0.16
This commit is contained in:
commit
b827cf3cce
341 changed files with 28036 additions and 0 deletions
98
app/src/main/AndroidManifest.xml
Normal file
98
app/src/main/AndroidManifest.xml
Normal 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>
|
4650
app/src/main/assets/loading_upload.json
Normal file
4650
app/src/main/assets/loading_upload.json
Normal file
File diff suppressed because it is too large
Load diff
5500
app/src/main/assets/spinner_home.json
Normal file
5500
app/src/main/assets/spinner_home.json
Normal file
File diff suppressed because it is too large
Load diff
1
app/src/main/assets/spinner_home_upload_complete.json
Normal file
1
app/src/main/assets/spinner_home_upload_complete.json
Normal file
File diff suppressed because one or more lines are too long
BIN
app/src/main/ic_launcher-playstore.png
Normal file
BIN
app/src/main/ic_launcher-playstore.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 40 KiB |
|
@ -0,0 +1,5 @@
|
|||
package au.gov.health.covidsafe
|
||||
|
||||
interface HasBlockingState {
|
||||
var isUiBlocked: Boolean
|
||||
}
|
15
app/src/main/java/au/gov/health/covidsafe/HomeActivity.kt
Normal file
15
app/src/main/java/au/gov/health/covidsafe/HomeActivity.kt
Normal 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)
|
||||
|
||||
}
|
||||
}
|
147
app/src/main/java/au/gov/health/covidsafe/PeekActivity.kt
Normal file
147
app/src/main/java/au/gov/health/covidsafe/PeekActivity.kt
Normal 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)
|
||||
}
|
||||
|
||||
}
|
231
app/src/main/java/au/gov/health/covidsafe/PlotActivity.kt
Normal file
231
app/src/main/java/au/gov/health/covidsafe/PlotActivity.kt
Normal 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")
|
||||
}
|
||||
}
|
162
app/src/main/java/au/gov/health/covidsafe/Preference.kt
Normal file
162
app/src/main/java/au/gov/health/covidsafe/Preference.kt
Normal 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)
|
||||
}
|
||||
|
||||
}
|
170
app/src/main/java/au/gov/health/covidsafe/RecordListAdapter.kt
Normal file
170
app/src/main/java/au/gov/health/covidsafe/RecordListAdapter.kt
Normal 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
|
||||
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
82
app/src/main/java/au/gov/health/covidsafe/SplashActivity.kt
Normal file
82
app/src/main/java/au/gov/health/covidsafe/SplashActivity.kt
Normal 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)
|
||||
}
|
||||
}
|
48
app/src/main/java/au/gov/health/covidsafe/TracerApp.kt
Normal file
48
app/src/main/java/au/gov/health/covidsafe/TracerApp.kt
Normal 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")
|
||||
}
|
||||
|
||||
}
|
||||
}
|
247
app/src/main/java/au/gov/health/covidsafe/Utils.kt
Normal file
247
app/src/main/java/au/gov/health/covidsafe/Utils.kt
Normal 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
|
||||
}
|
||||
}
|
35
app/src/main/java/au/gov/health/covidsafe/WebViewActivity.kt
Normal file
35
app/src/main/java/au/gov/health/covidsafe/WebViewActivity.kt
Normal 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
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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}")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
|
@ -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))
|
||||
}
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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>()
|
|
@ -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
|
||||
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
|
@ -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?)
|
|
@ -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?)
|
|
@ -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)
|
|
@ -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?)
|
|
@ -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)
|
|
@ -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)
|
|
@ -0,0 +1,6 @@
|
|||
package au.gov.health.covidsafe.networking.response
|
||||
|
||||
import androidx.annotation.Keep
|
||||
|
||||
@Keep
|
||||
class UploadOTPResponse
|
|
@ -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>
|
||||
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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}")
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -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
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -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()
|
||||
}
|
|
@ -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)
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
package au.gov.health.covidsafe.streetpass
|
||||
|
||||
class BlacklistEntry(val uniqueIdentifier: String?)
|
|
@ -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}"
|
||||
}
|
||||
}
|
|
@ -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"
|
|
@ -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--
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
71
app/src/main/java/au/gov/health/covidsafe/streetpass/Work.kt
Normal file
71
app/src/main/java/au/gov/health/covidsafe/streetpass/Work.kt
Normal 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)
|
||||
}
|
||||
}
|
|
@ -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)"
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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()
|
||||
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
34
app/src/main/java/au/gov/health/covidsafe/ui/BaseFragment.kt
Normal file
34
app/src/main/java/au/gov/health/covidsafe/ui/BaseFragment.kt
Normal 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()
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
|
@ -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"
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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>)
|
|
@ -0,0 +1,6 @@
|
|||
package au.gov.health.covidsafe.ui.upload.model
|
||||
|
||||
import androidx.annotation.Keep
|
||||
import au.gov.health.covidsafe.streetpass.persistence.StreetPassRecord
|
||||
@Keep
|
||||
class ExportData constructor(var records: List<StreetPassRecord>)
|
|
@ -0,0 +1,32 @@
|
|||
package au.gov.health.covidsafe.ui.upload.presentation
|
||||
|
||||
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_upload_finished.*
|
||||
|
||||
class UploadFinishedFragment : 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_upload_finished, container, false)
|
||||
|
||||
override fun getUploadButtonLayout() = UploadButtonLayout.ContinueLayout(R.string.action_upload_done) {
|
||||
activity?.onBackPressed()
|
||||
}
|
||||
|
||||
override fun updateButtonState() {
|
||||
enableContinueButton()
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
root.removeAllViews()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,39 @@
|
|||
package au.gov.health.covidsafe.ui.upload.presentation
|
||||
|
||||
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_upload_page_4.*
|
||||
|
||||
class UploadInitialFragment : 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_upload_initial, container, false)
|
||||
|
||||
|
||||
override fun updateButtonState() {
|
||||
enableContinueButton()
|
||||
}
|
||||
|
||||
override fun getUploadButtonLayout() = UploadButtonLayout.QuestionLayout(
|
||||
buttonYesListener = {
|
||||
navigateTo(R.id.action_uploadInitial_to_uploadStepFourFragment)
|
||||
},
|
||||
buttonNoListener = {
|
||||
activity?.onBackPressed()
|
||||
})
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
root.removeAllViews()
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,59 @@
|
|||
package au.gov.health.covidsafe.ui.upload.presentation
|
||||
|
||||
import android.app.AlertDialog
|
||||
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_upload_page_4.*
|
||||
|
||||
class UploadStepFourFragment : PagerChildFragment() {
|
||||
|
||||
private var alertDialog: AlertDialog? = null
|
||||
override var stepProgress: Int? = null
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? =
|
||||
inflater.inflate(R.layout.fragment_upload_page_4, container, false)
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
subHeader.movementMethod = LinkMovementMethod.getInstance()
|
||||
}
|
||||
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
upload_consent_checkbox.setOnCheckedChangeListener { buttonView, isChecked ->
|
||||
updateButtonState()
|
||||
}
|
||||
}
|
||||
override fun updateButtonState() {
|
||||
if (upload_consent_checkbox.isChecked) {
|
||||
enableContinueButton()
|
||||
} else {
|
||||
disableContinueButton()
|
||||
}
|
||||
}
|
||||
|
||||
override val navigationIcon: Int? = R.drawable.ic_up
|
||||
|
||||
override fun getUploadButtonLayout() = UploadButtonLayout.ContinueLayout(
|
||||
R.string.action_agree) {
|
||||
navigateToVerifyUploadPin()
|
||||
}
|
||||
|
||||
private fun navigateToVerifyUploadPin() {
|
||||
navigateTo(R.id.action_uploadStepFourFragment_to_verifyUploadPinFragment)
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
alertDialog?.dismiss()
|
||||
root.removeAllViews()
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,136 @@
|
|||
package au.gov.health.covidsafe.ui.upload.presentation
|
||||
|
||||
import android.app.AlertDialog
|
||||
import android.app.Dialog
|
||||
import android.os.Bundle
|
||||
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 au.gov.health.covidsafe.ui.view.UploadingDialog
|
||||
import au.gov.health.covidsafe.ui.view.UploadingErrorDialog
|
||||
import com.atlassian.mobilekit.module.core.utils.SystemUtils
|
||||
import kotlinx.android.synthetic.main.fragment_verify_upload_pin.*
|
||||
import kotlinx.android.synthetic.main.fragment_verify_upload_pin.view.*
|
||||
|
||||
|
||||
class VerifyUploadPinFragment : PagerChildFragment() {
|
||||
|
||||
interface OnUploadErrorInterface {
|
||||
fun onPositiveClicked()
|
||||
fun onNegativeClicked()
|
||||
}
|
||||
|
||||
private var dialog: Dialog? = null
|
||||
|
||||
private lateinit var presenter : VerifyUploadPinPresenter
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? =
|
||||
inflater.inflate(R.layout.fragment_verify_upload_pin, container, false)
|
||||
|
||||
override val navigationIcon: Int? = R.drawable.ic_up
|
||||
override var stepProgress: Int? = null
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
presenter = VerifyUploadPinPresenter(this)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
pin.onPinChanged = {
|
||||
updateButtonState()
|
||||
hideInvalidOtp()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
pin.onPinChanged = null
|
||||
}
|
||||
|
||||
override fun getUploadButtonLayout() = UploadButtonLayout.ContinueLayout(R.string.action_verify_upload_pin) {
|
||||
presenter.uploadData(requireView().pin.value)
|
||||
}
|
||||
|
||||
override fun updateButtonState() {
|
||||
if (isIncorrectPinFormat()) {
|
||||
disableContinueButton()
|
||||
} else {
|
||||
enableContinueButton()
|
||||
}
|
||||
}
|
||||
|
||||
private fun isIncorrectPinFormat(): Boolean {
|
||||
return requireView().pin.isIncomplete
|
||||
}
|
||||
|
||||
fun hideKeyboard() {
|
||||
activity?.currentFocus?.let { view ->
|
||||
SystemUtils.hideSoftKeyboard(view)
|
||||
}
|
||||
}
|
||||
|
||||
fun showInvalidOtp() {
|
||||
dialog?.dismiss()
|
||||
enter_pin_error_label.visibility = View.VISIBLE
|
||||
}
|
||||
|
||||
private fun hideInvalidOtp() {
|
||||
enter_pin_error_label.visibility = View.GONE
|
||||
}
|
||||
|
||||
fun showGenericError() {
|
||||
dialog?.dismiss()
|
||||
activity?.let {
|
||||
dialog = UploadingErrorDialog(it, object : OnUploadErrorInterface {
|
||||
override fun onPositiveClicked() {
|
||||
presenter.uploadData(requireView().pin.value)
|
||||
}
|
||||
|
||||
override fun onNegativeClicked() {
|
||||
dialog?.dismiss()
|
||||
}
|
||||
})
|
||||
dialog?.show()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
dialog?.dismiss()
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
root.removeAllViews()
|
||||
}
|
||||
|
||||
fun navigateToRegister() {
|
||||
val bundle = bundleOf(
|
||||
EnterNumberFragment.ENTER_NUMBER_DESTINATION_ID to R.id.action_enterPinFragment_to_uploadStepFourFragment)
|
||||
navigateTo(VerifyUploadPinFragmentDirections.actionVerifyUploadPinFragmentToEnterNumberFragment().actionId, bundle)
|
||||
}
|
||||
|
||||
fun navigateToNextPage() {
|
||||
navigateTo(R.id.action_verifyUploadPinFragment_to_uploadFinishedFragment)
|
||||
}
|
||||
|
||||
fun showDialogLoading() {
|
||||
dialog?.dismiss()
|
||||
dialog = UploadingDialog(requireActivity())
|
||||
dialog?.show()
|
||||
}
|
||||
|
||||
fun showCheckInternetError() {
|
||||
dialog?.dismiss()
|
||||
dialog = AlertDialog.Builder(activity)
|
||||
.setMessage(R.string.generic_internet_error)
|
||||
.setIcon(android.R.drawable.ic_dialog_alert)
|
||||
.setPositiveButton(android.R.string.yes, null).show()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,74 @@
|
|||
package au.gov.health.covidsafe.ui.upload.presentation
|
||||
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleObserver
|
||||
import androidx.lifecycle.OnLifecycleEvent
|
||||
import au.gov.health.covidsafe.BuildConfig
|
||||
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.UploadData
|
||||
import au.gov.health.covidsafe.interactor.usecase.UploadDataException
|
||||
import au.gov.health.covidsafe.streetpass.persistence.StreetPassRecordStorage
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
|
||||
class VerifyUploadPinPresenter(private val fragment: VerifyUploadPinFragment) : LifecycleObserver {
|
||||
|
||||
private val TAG = this.javaClass.simpleName
|
||||
|
||||
private var awsClient = NetworkFactory.awsClient
|
||||
private lateinit var uploadData: UploadData
|
||||
|
||||
private lateinit var recordStorage: StreetPassRecordStorage
|
||||
|
||||
init {
|
||||
fragment.lifecycle.addObserver(this)
|
||||
fragment.context?.let { context ->
|
||||
recordStorage = StreetPassRecordStorage(context)
|
||||
}
|
||||
}
|
||||
|
||||
@OnLifecycleEvent(Lifecycle.Event.ON_CREATE)
|
||||
private fun onCreate() {
|
||||
uploadData = UploadData(awsClient, NetworkFactory.okHttpClient, fragment.context, fragment.lifecycle)
|
||||
}
|
||||
|
||||
internal fun uploadData(otp: String) {
|
||||
if (fragment.activity?.isInternetAvailable() == false) {
|
||||
fragment.showCheckInternetError()
|
||||
} else {
|
||||
fragment.disableContinueButton()
|
||||
fragment.showDialogLoading()
|
||||
uploadData.invoke(otp,
|
||||
onSuccess = {
|
||||
if (!BuildConfig.DEBUG) {
|
||||
GlobalScope.launch { recordStorage.nukeDbAsync() }
|
||||
}
|
||||
fragment.context?.let { context ->
|
||||
Preference.setDataIsUploaded(context, true)
|
||||
}
|
||||
fragment.navigateToNextPage()
|
||||
},
|
||||
onFailure = {
|
||||
when (it) {
|
||||
is UploadDataException.UploadDataIncorrectPinException -> {
|
||||
fragment.showInvalidOtp()
|
||||
}
|
||||
is UploadDataException.UploadDataJwtExpiredException -> {
|
||||
fragment.navigateToRegister()
|
||||
}
|
||||
else -> {
|
||||
fragment.showGenericError()
|
||||
}
|
||||
}
|
||||
fragment.enableContinueButton()
|
||||
fragment.hideKeyboard()
|
||||
fragment.hideLoading()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
package au.gov.health.covidsafe.ui.view
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.KeyEvent
|
||||
import android.view.LayoutInflater
|
||||
import android.widget.EditText
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.core.widget.doAfterTextChanged
|
||||
import kotlinx.android.synthetic.main.view_pin.view.*
|
||||
import au.gov.health.covidsafe.R
|
||||
|
||||
class PinInputView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyle: Int = -1) : ConstraintLayout(context, attrs, defStyle) {
|
||||
|
||||
private val pinOne: EditText? by lazy { pin_1 }
|
||||
private val pinTwo: EditText? by lazy { pin_2 }
|
||||
private val pinThree: EditText? by lazy { pin_3 }
|
||||
private val pinFour: EditText? by lazy { pin_4 }
|
||||
private val pinFive: EditText? by lazy { pin_5 }
|
||||
private val pinSix: EditText? by lazy { pin_6 }
|
||||
var onPinChanged: (() -> Unit)? = null
|
||||
|
||||
private val allInputs by lazy {
|
||||
listOf(pinOne, pinTwo, pinThree, pinFour, pinFive, pinSix)
|
||||
}
|
||||
|
||||
val value: String
|
||||
get() = allInputs.mapNotNull { it?.text }.joinToString("")
|
||||
|
||||
val isIncomplete: Boolean
|
||||
get() = allInputs.any { it?.text.isNullOrEmpty() }
|
||||
|
||||
init {
|
||||
LayoutInflater.from(context).inflate(R.layout.view_pin, this, true)
|
||||
pinOne?.onDigitChanged(pinTwo)
|
||||
pinOne?.onDeletePressed(null)
|
||||
|
||||
pinTwo?.onDigitChanged(pinThree)
|
||||
pinTwo?.onDeletePressed(pinOne)
|
||||
|
||||
pinThree?.onDigitChanged(pinFour)
|
||||
pinThree?.onDeletePressed(pinTwo)
|
||||
|
||||
pinFour?.onDigitChanged(pinFive)
|
||||
pinFour?.onDeletePressed(pinThree)
|
||||
|
||||
pinFive?.onDigitChanged(pinSix)
|
||||
pinFive?.onDeletePressed(pinFour)
|
||||
|
||||
pinSix?.onDigitChanged(null)
|
||||
pinSix?.onDeletePressed(pinFive)
|
||||
}
|
||||
|
||||
private fun EditText.onDigitChanged(next: EditText? = null) {
|
||||
doAfterTextChanged {
|
||||
if (it?.length == 1) {
|
||||
next?.requestFocus()
|
||||
onPinChanged?.invoke()
|
||||
} else if (it.isNullOrBlank()) {
|
||||
onPinChanged?.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun EditText.onDeletePressed(prev: EditText? = null) {
|
||||
setOnKeyListener { view, keyCode, keyEvent ->
|
||||
if (keyCode == KeyEvent.KEYCODE_DEL && text.isNullOrEmpty()) {
|
||||
prev?.requestFocus()
|
||||
onPinChanged?.invoke()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,53 @@
|
|||
package au.gov.health.covidsafe.ui.view
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.widget.LinearLayout
|
||||
import androidx.core.content.ContextCompat
|
||||
import au.gov.health.covidsafe.R
|
||||
|
||||
class SegmentedProgressBar @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyle: Int = -1) : LinearLayout(context, attrs, defStyle) {
|
||||
private val maxValue: Int
|
||||
var progress: Int = DEFAULT_PROGRESS
|
||||
set(value) {
|
||||
field = value
|
||||
for (i in 0..childCount) {
|
||||
val segment = getChildAt(i)
|
||||
segment?.isSelected = i + 1 <= progress
|
||||
}
|
||||
}
|
||||
|
||||
private val segmentSpacing: Int
|
||||
|
||||
init {
|
||||
orientation = HORIZONTAL
|
||||
val values = context.obtainStyledAttributes(attrs, R.styleable.SegmentedProgressBar, defStyle, 0)
|
||||
maxValue = values.getInt(R.styleable.SegmentedProgressBar_progress_max_value, DEFAULT_MAX_VALUE)
|
||||
segmentSpacing = values.getDimensionPixelSize(R.styleable.SegmentedProgressBar_segment_spacing,
|
||||
DEFAULT_SEGMENT_SPACING_DP * resources.displayMetrics.density.toInt())
|
||||
progress = values.getInt(R.styleable.SegmentedProgressBar_progress_value, DEFAULT_PROGRESS)
|
||||
drawProgress()
|
||||
values.recycle()
|
||||
}
|
||||
|
||||
private fun drawProgress() {
|
||||
repeat(maxValue) { index ->
|
||||
val lp = generateDefaultLayoutParams()
|
||||
lp.height = LayoutParams.WRAP_CONTENT
|
||||
lp.width = 0
|
||||
lp.weight = 1.0f
|
||||
lp.rightMargin = if (index in 1 until maxValue - 1) segmentSpacing else 0
|
||||
lp.leftMargin = if (index > 0) segmentSpacing else 0
|
||||
|
||||
val view = View(context)
|
||||
view.background = ContextCompat.getDrawable(context, R.drawable.progress_segment)
|
||||
view.isSelected = index + 1 <= progress
|
||||
addView(view, lp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val DEFAULT_MAX_VALUE = 5
|
||||
private const val DEFAULT_PROGRESS = 0
|
||||
private const val DEFAULT_SEGMENT_SPACING_DP = 4
|
30
app/src/main/java/au/gov/health/covidsafe/ui/view/UlView.kt
Normal file
30
app/src/main/java/au/gov/health/covidsafe/ui/view/UlView.kt
Normal file
|
@ -0,0 +1,30 @@
|
|||
package au.gov.health.covidsafe.ui.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.LinearLayout
|
||||
import au.gov.health.covidsafe.R
|
||||
import kotlinx.android.synthetic.main.view_ul.view.*
|
||||
|
||||
class UlView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : LinearLayout(context, attrs, defStyleAttr) {
|
||||
|
||||
init {
|
||||
LayoutInflater.from(context).inflate(R.layout.view_ul, this, true)
|
||||
|
||||
val a: TypedArray = context.obtainStyledAttributes(attrs, R.styleable.UlView)
|
||||
val title = a.getString(R.styleable.UlView_ul_view_text)
|
||||
|
||||
ul_content.text = title
|
||||
|
||||
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
|
||||
|
||||
a.recycle()
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue