2020-01-23 05:46:41 +00:00
|
|
|
package com.geeksville.mesh
|
2020-01-20 23:53:22 +00:00
|
|
|
|
2020-04-07 16:36:12 +00:00
|
|
|
// import kotlinx.android.synthetic.main.tabs.*
|
2020-01-21 21:12:01 +00:00
|
|
|
import android.Manifest
|
2020-04-18 23:30:30 +00:00
|
|
|
import android.annotation.SuppressLint
|
|
|
|
import android.app.Activity
|
2020-01-24 20:49:27 +00:00
|
|
|
import android.bluetooth.BluetoothAdapter
|
2020-04-18 23:30:30 +00:00
|
|
|
import android.bluetooth.BluetoothDevice
|
2020-01-24 20:49:27 +00:00
|
|
|
import android.bluetooth.BluetoothManager
|
2020-04-18 23:30:30 +00:00
|
|
|
import android.companion.CompanionDeviceManager
|
2020-02-25 16:10:23 +00:00
|
|
|
import android.content.BroadcastReceiver
|
|
|
|
import android.content.Context
|
|
|
|
import android.content.Intent
|
|
|
|
import android.content.IntentFilter
|
2020-01-21 21:12:01 +00:00
|
|
|
import android.content.pm.PackageManager
|
2020-03-02 16:54:57 +00:00
|
|
|
import android.net.Uri
|
2020-02-14 17:09:40 +00:00
|
|
|
import android.os.Build
|
2020-01-20 23:53:22 +00:00
|
|
|
import android.os.Bundle
|
2020-04-07 18:27:51 +00:00
|
|
|
import android.view.Menu
|
|
|
|
import android.view.MenuItem
|
|
|
|
import android.view.MotionEvent
|
2020-01-22 21:02:24 +00:00
|
|
|
import android.widget.Toast
|
2020-04-08 15:16:06 +00:00
|
|
|
import androidx.activity.viewModels
|
2020-01-24 20:49:27 +00:00
|
|
|
import androidx.appcompat.app.AppCompatActivity
|
2020-01-21 21:12:01 +00:00
|
|
|
import androidx.core.app.ActivityCompat
|
|
|
|
import androidx.core.content.ContextCompat
|
2020-04-07 16:36:12 +00:00
|
|
|
import androidx.fragment.app.Fragment
|
2020-04-08 18:57:31 +00:00
|
|
|
import androidx.lifecycle.Observer
|
2020-04-07 16:36:12 +00:00
|
|
|
import androidx.viewpager2.adapter.FragmentStateAdapter
|
2020-04-11 20:20:30 +00:00
|
|
|
import com.geeksville.android.GeeksvilleApplication
|
2020-01-22 22:27:22 +00:00
|
|
|
import com.geeksville.android.Logging
|
2020-02-25 16:10:23 +00:00
|
|
|
import com.geeksville.android.ServiceClient
|
2020-04-10 00:06:41 +00:00
|
|
|
import com.geeksville.mesh.model.Channel
|
2020-04-08 16:53:04 +00:00
|
|
|
import com.geeksville.mesh.model.TextMessage
|
|
|
|
import com.geeksville.mesh.model.UIViewModel
|
2020-02-10 23:31:56 +00:00
|
|
|
import com.geeksville.mesh.service.*
|
2020-04-09 01:42:17 +00:00
|
|
|
import com.geeksville.mesh.ui.*
|
2020-03-05 17:50:33 +00:00
|
|
|
import com.geeksville.util.Exceptions
|
2020-02-05 05:23:52 +00:00
|
|
|
import com.geeksville.util.exceptionReporter
|
2020-02-14 15:47:20 +00:00
|
|
|
import com.google.android.gms.auth.api.signin.GoogleSignIn
|
|
|
|
import com.google.android.gms.auth.api.signin.GoogleSignInAccount
|
2020-04-11 16:39:34 +00:00
|
|
|
import com.google.android.gms.common.ConnectionResult
|
|
|
|
import com.google.android.gms.common.GoogleApiAvailability
|
2020-02-14 15:47:20 +00:00
|
|
|
import com.google.android.gms.tasks.Task
|
2020-04-10 00:06:41 +00:00
|
|
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
2020-04-07 16:36:12 +00:00
|
|
|
import com.google.android.material.tabs.TabLayoutMediator
|
2020-04-11 16:39:34 +00:00
|
|
|
import com.vorlonsoft.android.rate.AppRate
|
|
|
|
import com.vorlonsoft.android.rate.StoreType
|
2020-04-08 18:57:31 +00:00
|
|
|
import kotlinx.android.synthetic.main.activity_main.*
|
2020-02-09 13:52:17 +00:00
|
|
|
import java.nio.charset.Charset
|
2020-01-20 23:53:22 +00:00
|
|
|
|
2020-02-16 22:22:24 +00:00
|
|
|
/*
|
|
|
|
UI design
|
|
|
|
|
|
|
|
material setup instructions: https://material.io/develop/android/docs/getting-started/
|
|
|
|
dark theme (or use system eventually) https://material.io/develop/android/theming/dark/
|
|
|
|
|
|
|
|
NavDrawer is a standard draw which can be dragged in from the left or the menu icon inside the app
|
|
|
|
title.
|
|
|
|
|
|
|
|
Fragments:
|
|
|
|
|
2020-02-17 02:54:29 +00:00
|
|
|
SettingsFragment shows "Settings"
|
2020-02-16 22:22:24 +00:00
|
|
|
username
|
|
|
|
shortname
|
|
|
|
bluetooth pairing list
|
|
|
|
(eventually misc device settings that are not channel related)
|
|
|
|
|
2020-02-17 02:54:29 +00:00
|
|
|
Channel fragment "Channel"
|
2020-02-16 22:22:24 +00:00
|
|
|
qr code, copy link button
|
|
|
|
ch number
|
|
|
|
misc other settings
|
|
|
|
(eventually a way of choosing between past channels)
|
|
|
|
|
2020-02-17 02:54:29 +00:00
|
|
|
ChatFragment "Messages"
|
2020-02-16 22:22:24 +00:00
|
|
|
a text box to enter new texts
|
|
|
|
a scrolling list of rows. each row is a text and a sender info layout
|
|
|
|
|
2020-02-17 02:54:29 +00:00
|
|
|
NodeListFragment "Users"
|
2020-02-16 22:22:24 +00:00
|
|
|
a node info row for every node
|
|
|
|
|
|
|
|
ViewModels:
|
|
|
|
|
|
|
|
BTScanModel starts/stops bt scan and provides list of devices (manages entire scan lifecycle)
|
|
|
|
|
|
|
|
MeshModel contains: (manages entire service relationship)
|
|
|
|
current received texts
|
|
|
|
current radio macaddr
|
|
|
|
current node infos (updated dynamically)
|
|
|
|
|
|
|
|
eventually use bottom navigation bar to switch between, Members, Chat, Channel, Settings. https://material.io/develop/android/components/bottom-navigation-view/
|
|
|
|
use numbers of # chat messages and # of members in the badges.
|
|
|
|
|
|
|
|
(per this recommendation to not use top tabs: https://ux.stackexchange.com/questions/102439/android-ux-when-to-use-bottom-navigation-and-when-to-use-tabs )
|
|
|
|
|
|
|
|
|
|
|
|
eventually:
|
|
|
|
make a custom theme: https://github.com/material-components/material-components-android/tree/master/material-theme-builder
|
|
|
|
*/
|
|
|
|
|
2020-02-17 23:39:49 +00:00
|
|
|
val utf8 = Charset.forName("UTF-8")
|
|
|
|
|
|
|
|
|
2020-02-11 04:17:42 +00:00
|
|
|
class MainActivity : AppCompatActivity(), Logging,
|
|
|
|
ActivityCompat.OnRequestPermissionsResultCallback {
|
2020-01-20 23:53:22 +00:00
|
|
|
|
2020-01-21 17:37:39 +00:00
|
|
|
companion object {
|
|
|
|
const val REQUEST_ENABLE_BT = 10
|
2020-01-21 21:12:01 +00:00
|
|
|
const val DID_REQUEST_PERM = 11
|
2020-02-14 15:47:20 +00:00
|
|
|
const val RC_SIGN_IN = 12 // google signin completed
|
2020-04-19 01:45:50 +00:00
|
|
|
const val RC_SELECT_DEVICE =
|
|
|
|
13 // seems to be hardwired in CompanionDeviceManager to add 65536
|
2020-01-21 17:37:39 +00:00
|
|
|
}
|
|
|
|
|
2020-02-09 15:28:24 +00:00
|
|
|
|
2020-01-22 21:02:24 +00:00
|
|
|
private val bluetoothAdapter: BluetoothAdapter? by lazy(LazyThreadSafetyMode.NONE) {
|
2020-01-21 17:37:39 +00:00
|
|
|
val bluetoothManager = getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
2020-01-22 21:02:24 +00:00
|
|
|
bluetoothManager.adapter
|
2020-01-21 17:37:39 +00:00
|
|
|
}
|
|
|
|
|
2020-04-20 14:46:06 +00:00
|
|
|
val model: UIViewModel by viewModels()
|
2020-04-08 15:16:06 +00:00
|
|
|
|
2020-04-07 17:40:01 +00:00
|
|
|
data class TabInfo(val text: String, val icon: Int, val content: Fragment)
|
|
|
|
|
|
|
|
// private val tabIndexes = generateSequence(0) { it + 1 } FIXME, instead do withIndex or zip? to get the ids below, also stop duplicating strings
|
|
|
|
private val tabInfos = arrayOf(
|
2020-04-08 23:49:27 +00:00
|
|
|
TabInfo(
|
|
|
|
"Messages",
|
|
|
|
R.drawable.ic_twotone_message_24,
|
|
|
|
MessagesFragment()
|
|
|
|
),
|
2020-04-08 22:25:57 +00:00
|
|
|
TabInfo(
|
|
|
|
"Users",
|
|
|
|
R.drawable.ic_twotone_people_24,
|
|
|
|
UsersFragment()
|
|
|
|
),
|
2020-04-09 01:42:17 +00:00
|
|
|
TabInfo(
|
|
|
|
"Map",
|
|
|
|
R.drawable.ic_twotone_map_24,
|
|
|
|
MapFragment()
|
|
|
|
),
|
2020-04-07 17:40:01 +00:00
|
|
|
TabInfo(
|
|
|
|
"Channel",
|
|
|
|
R.drawable.ic_twotone_contactless_24,
|
2020-04-07 23:04:58 +00:00
|
|
|
ChannelFragment()
|
|
|
|
),
|
2020-04-07 19:13:50 +00:00
|
|
|
TabInfo(
|
|
|
|
"Settings",
|
|
|
|
R.drawable.ic_twotone_settings_applications_24,
|
2020-04-09 01:42:17 +00:00
|
|
|
SettingsFragment()
|
|
|
|
)
|
2020-04-07 17:40:01 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
private
|
|
|
|
val tabsAdapter = object : FragmentStateAdapter(this) {
|
|
|
|
|
|
|
|
override fun getItemCount(): Int = tabInfos.size
|
2020-04-07 16:36:12 +00:00
|
|
|
|
|
|
|
override fun createFragment(position: Int): Fragment {
|
|
|
|
// Return a NEW fragment instance in createFragment(int)
|
|
|
|
/*
|
|
|
|
fragment.arguments = Bundle().apply {
|
|
|
|
// Our object is just an integer :-P
|
|
|
|
putInt(ARG_OBJECT, position + 1)
|
|
|
|
} */
|
2020-04-07 17:40:01 +00:00
|
|
|
return tabInfos[position].content
|
2020-04-07 16:36:12 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-04-23 15:52:25 +00:00
|
|
|
|
2020-04-20 16:56:38 +00:00
|
|
|
private val btStateReceiver = BluetoothStateReceiver { enabled ->
|
2020-04-23 15:52:25 +00:00
|
|
|
updateBluetoothEnabled()
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Don't tell our app we have bluetooth until we have bluetooth _and_ location access
|
|
|
|
*/
|
|
|
|
private fun updateBluetoothEnabled() {
|
|
|
|
var enabled = false // assume failure
|
|
|
|
|
|
|
|
val requiredPerms = listOf(
|
|
|
|
Manifest.permission.ACCESS_FINE_LOCATION,
|
|
|
|
Manifest.permission.BLUETOOTH,
|
|
|
|
Manifest.permission.BLUETOOTH_ADMIN
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if (getMissingPermissions(requiredPerms).isEmpty()) {
|
|
|
|
/// ask the adapter if we have access
|
|
|
|
bluetoothAdapter?.apply {
|
|
|
|
enabled = isEnabled
|
|
|
|
}
|
|
|
|
} else
|
|
|
|
errormsg("Still missing needed bluetooth permissions")
|
|
|
|
|
|
|
|
debug("Detected our bluetooth access=$enabled")
|
2020-04-20 16:56:38 +00:00
|
|
|
model.bluetoothEnabled.value = enabled
|
|
|
|
}
|
|
|
|
|
2020-04-23 15:52:25 +00:00
|
|
|
/**
|
|
|
|
* return a list of the permissions we don't have
|
|
|
|
*/
|
|
|
|
private fun getMissingPermissions(perms: List<String>) = perms.filter {
|
|
|
|
ContextCompat.checkSelfPermission(
|
|
|
|
this,
|
|
|
|
it
|
|
|
|
) != PackageManager.PERMISSION_GRANTED
|
|
|
|
}
|
|
|
|
|
2020-02-11 04:17:42 +00:00
|
|
|
private fun requestPermission() {
|
2020-01-22 22:27:22 +00:00
|
|
|
debug("Checking permissions")
|
|
|
|
|
2020-02-14 17:09:40 +00:00
|
|
|
val perms = mutableListOf(
|
2020-02-16 22:22:24 +00:00
|
|
|
Manifest.permission.ACCESS_COARSE_LOCATION,
|
2020-01-23 00:45:27 +00:00
|
|
|
Manifest.permission.ACCESS_FINE_LOCATION,
|
2020-01-21 21:12:01 +00:00
|
|
|
Manifest.permission.BLUETOOTH,
|
|
|
|
Manifest.permission.BLUETOOTH_ADMIN,
|
2020-04-20 03:03:38 +00:00
|
|
|
Manifest.permission.WAKE_LOCK
|
2020-04-20 14:46:06 +00:00
|
|
|
|
2020-04-13 00:13:13 +00:00
|
|
|
// We only need this for logging to capture files for the simulator - turn off for most users
|
|
|
|
// Manifest.permission.WRITE_EXTERNAL_STORAGE
|
2020-01-23 00:45:27 +00:00
|
|
|
)
|
|
|
|
|
2020-02-14 17:09:40 +00:00
|
|
|
if (Build.VERSION.SDK_INT >= 29) // only added later
|
|
|
|
perms.add(Manifest.permission.ACCESS_BACKGROUND_LOCATION)
|
|
|
|
|
2020-04-20 03:03:38 +00:00
|
|
|
// Some old phones complain about requesting perms they don't understand
|
|
|
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
|
|
perms.add(Manifest.permission.REQUEST_COMPANION_RUN_IN_BACKGROUND)
|
|
|
|
perms.add(Manifest.permission.REQUEST_COMPANION_USE_DATA_IN_BACKGROUND)
|
|
|
|
}
|
|
|
|
|
2020-04-23 15:52:25 +00:00
|
|
|
val missingPerms = getMissingPermissions(perms)
|
2020-01-21 21:12:01 +00:00
|
|
|
if (missingPerms.isNotEmpty()) {
|
|
|
|
missingPerms.forEach {
|
|
|
|
// Permission is not granted
|
|
|
|
// Should we show an explanation?
|
|
|
|
if (ActivityCompat.shouldShowRequestPermissionRationale(this, it)) {
|
|
|
|
// FIXME
|
|
|
|
// Show an explanation to the user *asynchronously* -- don't block
|
|
|
|
// this thread waiting for the user's response! After the user
|
|
|
|
// sees the explanation, try again to request the permission.
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// Ask for all the missing perms
|
2020-04-07 16:36:12 +00:00
|
|
|
ActivityCompat.requestPermissions(
|
|
|
|
this,
|
|
|
|
missingPerms.toTypedArray(),
|
|
|
|
DID_REQUEST_PERM
|
|
|
|
)
|
2020-01-21 21:12:01 +00:00
|
|
|
|
|
|
|
// DID_REQUEST_PERM is an
|
|
|
|
// app-defined int constant. The callback method gets the
|
|
|
|
// result of the request.
|
|
|
|
} else {
|
|
|
|
// Permission has already been granted
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-01-23 16:09:50 +00:00
|
|
|
|
2020-02-11 04:17:42 +00:00
|
|
|
override fun onRequestPermissionsResult(
|
|
|
|
requestCode: Int,
|
|
|
|
permissions: Array<out String>,
|
|
|
|
grantResults: IntArray
|
|
|
|
) {
|
|
|
|
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
2020-04-12 23:58:37 +00:00
|
|
|
|
2020-04-19 16:48:12 +00:00
|
|
|
// Older versions of android don't know about these permissions - ignore failure to grant
|
|
|
|
val ignoredPermissions = arrayOf(
|
|
|
|
Manifest.permission.ACCESS_COARSE_LOCATION,
|
|
|
|
Manifest.permission.REQUEST_COMPANION_RUN_IN_BACKGROUND,
|
|
|
|
Manifest.permission.REQUEST_COMPANION_USE_DATA_IN_BACKGROUND
|
|
|
|
)
|
|
|
|
|
|
|
|
val deniedPermissions = permissions.filterIndexed { index, name ->
|
|
|
|
grantResults[index] == PackageManager.PERMISSION_DENIED &&
|
|
|
|
!ignoredPermissions.contains(name)
|
|
|
|
}
|
|
|
|
|
|
|
|
if (deniedPermissions.isNotEmpty()) {
|
|
|
|
warn("Denied permissions: ${deniedPermissions.joinToString(",")}")
|
2020-04-12 23:58:37 +00:00
|
|
|
Toast.makeText(
|
|
|
|
this,
|
|
|
|
getString(R.string.permission_missing),
|
|
|
|
Toast.LENGTH_LONG
|
|
|
|
).show()
|
|
|
|
}
|
2020-04-23 15:52:25 +00:00
|
|
|
updateBluetoothEnabled()
|
2020-02-11 04:17:42 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
2020-02-14 15:47:20 +00:00
|
|
|
private fun sendTestPackets() {
|
|
|
|
exceptionReporter {
|
2020-04-08 16:53:04 +00:00
|
|
|
val m = model.meshService!!
|
2020-02-14 15:47:20 +00:00
|
|
|
|
|
|
|
// Do some test operations
|
|
|
|
val testPayload = "hello world".toByteArray()
|
|
|
|
m.sendData(
|
|
|
|
"+16508675310",
|
|
|
|
testPayload,
|
2020-04-29 01:02:01 +00:00
|
|
|
MeshProtos.Data.Type.OPAQUE_VALUE
|
2020-02-14 15:47:20 +00:00
|
|
|
)
|
|
|
|
m.sendData(
|
|
|
|
"+16508675310",
|
|
|
|
testPayload,
|
|
|
|
MeshProtos.Data.Type.CLEAR_TEXT_VALUE
|
|
|
|
)
|
|
|
|
}
|
|
|
|
}
|
2020-02-11 04:17:42 +00:00
|
|
|
|
|
|
|
|
2020-04-11 16:39:34 +00:00
|
|
|
/// Ask user to rate in play store
|
|
|
|
private fun askToRate() {
|
|
|
|
AppRate.with(this)
|
|
|
|
.setInstallDays(10.toByte()) // default is 10, 0 means install day, 10 means app is launched 10 or more days later than installation
|
|
|
|
.setLaunchTimes(10.toByte()) // default is 10, 3 means app is launched 3 or more times
|
|
|
|
.setRemindInterval(1.toByte()) // default is 1, 1 means app is launched 1 or more days after neutral button clicked
|
|
|
|
.setRemindLaunchesNumber(1.toByte()) // default is 0, 1 means app is launched 1 or more times after neutral button clicked
|
|
|
|
.monitor() // Monitors the app launch times
|
|
|
|
|
|
|
|
// Only ask to rate if the user has a suitable store
|
|
|
|
if (AppRate.with(this).storeType == StoreType.GOOGLEPLAY) { // Checks that current app store type from library options is StoreType.GOOGLEPLAY
|
|
|
|
if (GoogleApiAvailability.getInstance()
|
|
|
|
.isGooglePlayServicesAvailable(this) != ConnectionResult.SERVICE_MISSING
|
|
|
|
) { // Checks that Google Play is available
|
|
|
|
AppRate.showRateDialogIfMeetsConditions(this) // Shows the Rate Dialog when conditions are met
|
|
|
|
|
|
|
|
// Force the dialog - for testing
|
|
|
|
// AppRate.with(this).showRateDialog(this)
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
AppRate.showRateDialogIfMeetsConditions(this); // Shows the Rate Dialog when conditions are met
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-04-20 16:56:38 +00:00
|
|
|
private val isInTestLab: Boolean by lazy {
|
|
|
|
(application as GeeksvilleApplication).isInTestLab
|
|
|
|
}
|
2020-04-11 20:20:30 +00:00
|
|
|
|
2020-02-14 15:47:20 +00:00
|
|
|
override fun onCreate(savedInstanceState: Bundle?) {
|
|
|
|
super.onCreate(savedInstanceState)
|
|
|
|
|
2020-04-08 16:53:04 +00:00
|
|
|
val prefs = UIViewModel.getPreferences(this)
|
|
|
|
model.ownerName.value = prefs.getString("owner", "")!!
|
2020-02-11 04:17:42 +00:00
|
|
|
|
2020-04-20 16:56:38 +00:00
|
|
|
/// Set initial bluetooth state
|
2020-04-23 15:52:25 +00:00
|
|
|
updateBluetoothEnabled()
|
2020-01-28 03:23:34 +00:00
|
|
|
|
2020-04-20 16:56:38 +00:00
|
|
|
/// We now want to be informed of bluetooth state
|
|
|
|
registerReceiver(btStateReceiver, btStateReceiver.intent)
|
|
|
|
|
2020-04-13 00:20:42 +00:00
|
|
|
// if (!isInTestLab) - very important - even in test lab we must request permissions because we need location perms for some of our tests to pass
|
|
|
|
requestPermission()
|
2020-02-18 18:40:02 +00:00
|
|
|
|
2020-02-17 19:22:47 +00:00
|
|
|
/* not yet working
|
2020-02-14 15:47:20 +00:00
|
|
|
// Configure sign-in to request the user's ID, email address, and basic
|
|
|
|
// profile. ID and basic profile are included in DEFAULT_SIGN_IN.
|
|
|
|
// Configure sign-in to request the user's ID, email address, and basic
|
|
|
|
// profile. ID and basic profile are included in DEFAULT_SIGN_IN.
|
|
|
|
val gso =
|
|
|
|
GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
|
|
|
|
.requestEmail()
|
|
|
|
.build()
|
2020-01-25 18:00:57 +00:00
|
|
|
|
2020-02-14 15:47:20 +00:00
|
|
|
// Build a GoogleSignInClient with the options specified by gso.
|
|
|
|
UIState.googleSignInClient = GoogleSignIn.getClient(this, gso);
|
2020-02-17 19:22:47 +00:00
|
|
|
|
|
|
|
*/
|
2020-03-02 16:54:57 +00:00
|
|
|
|
|
|
|
// Handle any intent
|
|
|
|
handleIntent(intent)
|
2020-03-17 18:35:19 +00:00
|
|
|
|
2020-04-07 16:36:12 +00:00
|
|
|
/* setContent {
|
2020-03-17 18:35:19 +00:00
|
|
|
MeshApp()
|
2020-04-07 16:36:12 +00:00
|
|
|
} */
|
|
|
|
setContentView(R.layout.activity_main)
|
|
|
|
|
|
|
|
pager.adapter = tabsAdapter
|
2020-04-07 19:32:42 +00:00
|
|
|
pager.isUserInputEnabled =
|
|
|
|
false // Gestures for screen switching doesn't work so good with the map view
|
2020-04-09 18:03:17 +00:00
|
|
|
// pager.offscreenPageLimit = 0 // Don't keep any offscreen pages around, because we want to make sure our bluetooth scanning stops
|
2020-04-07 16:36:12 +00:00
|
|
|
TabLayoutMediator(tab_layout, pager) { tab, position ->
|
2020-04-07 19:13:50 +00:00
|
|
|
// tab.text = tabInfos[position].text // I think it looks better with icons only
|
2020-04-07 17:40:01 +00:00
|
|
|
tab.icon = getDrawable(tabInfos[position].icon)
|
2020-04-07 16:36:12 +00:00
|
|
|
}.attach()
|
2020-04-08 18:57:31 +00:00
|
|
|
|
|
|
|
model.isConnected.observe(this, Observer { connected ->
|
2020-04-08 22:36:49 +00:00
|
|
|
val image = when (connected) {
|
|
|
|
MeshService.ConnectionState.CONNECTED -> R.drawable.cloud_on
|
|
|
|
MeshService.ConnectionState.DEVICE_SLEEP -> R.drawable.ic_twotone_cloud_upload_24
|
|
|
|
MeshService.ConnectionState.DISCONNECTED -> R.drawable.cloud_off
|
2020-04-19 23:24:47 +00:00
|
|
|
else -> R.drawable.cloud_off
|
2020-04-08 22:36:49 +00:00
|
|
|
}
|
2020-04-08 23:49:27 +00:00
|
|
|
|
2020-04-08 18:57:31 +00:00
|
|
|
connectStatusImage.setImageDrawable(getDrawable(image))
|
|
|
|
})
|
2020-04-11 16:39:34 +00:00
|
|
|
|
|
|
|
askToRate()
|
2020-03-02 16:54:57 +00:00
|
|
|
}
|
|
|
|
|
2020-04-07 16:36:12 +00:00
|
|
|
|
2020-03-02 16:54:57 +00:00
|
|
|
override fun onNewIntent(intent: Intent) {
|
|
|
|
super.onNewIntent(intent)
|
|
|
|
handleIntent(intent)
|
|
|
|
}
|
|
|
|
|
2020-04-10 00:06:41 +00:00
|
|
|
private var requestedChannelUrl: Uri? = null
|
|
|
|
|
2020-03-02 16:54:57 +00:00
|
|
|
/// Handle any itents that were passed into us
|
|
|
|
private fun handleIntent(intent: Intent) {
|
|
|
|
val appLinkAction = intent.action
|
|
|
|
val appLinkData: Uri? = intent.data
|
|
|
|
|
|
|
|
// Were we asked to open one our channel URLs?
|
2020-03-17 18:35:19 +00:00
|
|
|
if (Intent.ACTION_VIEW == appLinkAction) {
|
2020-04-10 00:06:41 +00:00
|
|
|
debug("Asked to open a channel URL - ask user if they want to switch to that channel. If so send the config to the radio")
|
|
|
|
requestedChannelUrl = appLinkData
|
|
|
|
|
|
|
|
// if the device is connected already, process it now
|
2020-04-19 23:24:47 +00:00
|
|
|
if (model.isConnected.value == MeshService.ConnectionState.CONNECTED)
|
2020-04-10 00:06:41 +00:00
|
|
|
perhapsChangeChannel()
|
|
|
|
|
|
|
|
// We now wait for the device to connect, once connected, we ask the user if they want to switch to the new channel
|
2020-03-02 16:54:57 +00:00
|
|
|
}
|
2020-02-14 15:47:20 +00:00
|
|
|
}
|
2020-01-21 17:37:39 +00:00
|
|
|
|
2020-02-14 15:47:20 +00:00
|
|
|
override fun onDestroy() {
|
2020-04-20 16:56:38 +00:00
|
|
|
|
|
|
|
unregisterReceiver(btStateReceiver)
|
2020-02-14 15:47:20 +00:00
|
|
|
unregisterMeshReceiver()
|
|
|
|
super.onDestroy()
|
|
|
|
}
|
2020-02-14 12:41:20 +00:00
|
|
|
|
2020-02-14 15:47:20 +00:00
|
|
|
/**
|
|
|
|
* Dispatch incoming result to the correct fragment.
|
|
|
|
*/
|
2020-04-18 23:30:30 +00:00
|
|
|
@SuppressLint("InlinedApi")
|
2020-04-07 17:40:01 +00:00
|
|
|
override fun onActivityResult(
|
|
|
|
requestCode: Int,
|
|
|
|
resultCode: Int,
|
|
|
|
data: Intent?
|
|
|
|
) {
|
2020-02-14 15:47:20 +00:00
|
|
|
super.onActivityResult(requestCode, resultCode, data)
|
|
|
|
|
|
|
|
// Result returned from launching the Intent from GoogleSignInClient.getSignInIntent(...);
|
|
|
|
// Result returned from launching the Intent from GoogleSignInClient.getSignInIntent(...);
|
2020-04-18 23:30:30 +00:00
|
|
|
when (requestCode) {
|
|
|
|
RC_SIGN_IN -> {
|
|
|
|
// The Task returned from this call is always completed, no need to attach
|
|
|
|
// a listener.
|
|
|
|
val task: Task<GoogleSignInAccount> =
|
|
|
|
GoogleSignIn.getSignedInAccountFromIntent(data)
|
|
|
|
handleSignInResult(task)
|
|
|
|
}
|
2020-04-19 01:45:50 +00:00
|
|
|
(65536 + RC_SELECT_DEVICE) -> when (resultCode) {
|
2020-04-18 23:30:30 +00:00
|
|
|
Activity.RESULT_OK -> {
|
|
|
|
// User has chosen to pair with the Bluetooth device.
|
|
|
|
val device: BluetoothDevice =
|
2020-04-19 23:24:47 +00:00
|
|
|
data?.getParcelableExtra(CompanionDeviceManager.EXTRA_DEVICE)!!
|
2020-04-18 23:30:30 +00:00
|
|
|
debug("Received BLE pairing ${device.address}")
|
2020-04-19 01:45:50 +00:00
|
|
|
if (device.bondState != BluetoothDevice.BOND_BONDED) {
|
|
|
|
device.createBond()
|
|
|
|
// FIXME - wait for bond to complete
|
|
|
|
}
|
|
|
|
|
2020-04-18 23:30:30 +00:00
|
|
|
// ... Continue interacting with the paired device.
|
2020-04-20 02:23:20 +00:00
|
|
|
model.meshService?.let { service ->
|
|
|
|
service.setDeviceAddress(device.address)
|
|
|
|
}
|
2020-04-18 23:30:30 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
else ->
|
|
|
|
warn("BLE device select intent failed")
|
|
|
|
}
|
2020-01-20 23:53:22 +00:00
|
|
|
}
|
2020-02-14 15:47:20 +00:00
|
|
|
}
|
2020-02-09 13:52:17 +00:00
|
|
|
|
2020-02-14 15:47:20 +00:00
|
|
|
private fun handleSignInResult(completedTask: Task<GoogleSignInAccount>) {
|
2020-02-18 18:40:02 +00:00
|
|
|
/*
|
2020-02-14 15:47:20 +00:00
|
|
|
try {
|
2020-02-18 18:40:02 +00:00
|
|
|
val account = completedTask.getResult(ApiException::class.java)
|
2020-02-14 15:47:20 +00:00
|
|
|
// Signed in successfully, show authenticated UI.
|
|
|
|
//updateUI(account)
|
|
|
|
} catch (e: ApiException) { // The ApiException status code indicates the detailed failure reason.
|
|
|
|
// Please refer to the GoogleSignInStatusCodes class reference for more information.
|
|
|
|
warn("signInResult:failed code=" + e.statusCode)
|
|
|
|
//updateUI(null)
|
2020-02-18 18:40:02 +00:00
|
|
|
} */
|
2020-02-14 15:47:20 +00:00
|
|
|
}
|
2020-02-11 04:17:42 +00:00
|
|
|
|
2020-04-07 17:40:01 +00:00
|
|
|
private
|
|
|
|
var receiverRegistered = false
|
2020-02-11 04:17:42 +00:00
|
|
|
|
2020-02-14 15:47:20 +00:00
|
|
|
private fun registerMeshReceiver() {
|
2020-04-09 20:28:44 +00:00
|
|
|
unregisterMeshReceiver()
|
2020-02-14 15:47:20 +00:00
|
|
|
val filter = IntentFilter()
|
|
|
|
filter.addAction(MeshService.ACTION_MESH_CONNECTED)
|
|
|
|
filter.addAction(MeshService.ACTION_NODE_CHANGE)
|
|
|
|
filter.addAction(MeshService.ACTION_RECEIVED_DATA)
|
|
|
|
registerReceiver(meshServiceReceiver, filter)
|
2020-02-18 02:46:20 +00:00
|
|
|
receiverRegistered = true;
|
2020-02-14 15:47:20 +00:00
|
|
|
}
|
2020-02-09 13:52:17 +00:00
|
|
|
|
2020-02-14 15:47:20 +00:00
|
|
|
private fun unregisterMeshReceiver() {
|
|
|
|
if (receiverRegistered) {
|
|
|
|
receiverRegistered = false
|
|
|
|
unregisterReceiver(meshServiceReceiver)
|
2020-02-11 04:17:42 +00:00
|
|
|
}
|
2020-02-14 15:47:20 +00:00
|
|
|
}
|
2020-02-09 13:52:17 +00:00
|
|
|
|
2020-02-17 02:14:40 +00:00
|
|
|
|
2020-04-19 19:15:42 +00:00
|
|
|
/// Pull our latest node db from the device
|
|
|
|
private fun updateNodesFromDevice() {
|
|
|
|
model.meshService?.let { service ->
|
|
|
|
// Update our nodeinfos based on data from the device
|
|
|
|
val nodes = service.nodes.map {
|
|
|
|
it.user?.id!! to it
|
|
|
|
}.toMap()
|
|
|
|
|
|
|
|
model.nodeDB.nodes.value = nodes
|
|
|
|
|
|
|
|
try {
|
|
|
|
// Pull down our real node ID - This must be done AFTER reading the nodedb because we need the DB to find our nodeinof object
|
|
|
|
model.nodeDB.myId.value = service.myId
|
|
|
|
} catch (ex: Exception) {
|
|
|
|
warn("Ignoring failure to get myId, service is probably just uninited... ${ex.message}")
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-02-14 15:47:20 +00:00
|
|
|
/// Called when we gain/lose a connection to our mesh radio
|
2020-04-04 22:29:16 +00:00
|
|
|
private fun onMeshConnectionChanged(connected: MeshService.ConnectionState) {
|
2020-04-08 16:53:04 +00:00
|
|
|
model.isConnected.value = connected
|
|
|
|
debug("connchange ${model.isConnected.value}")
|
2020-04-04 22:29:16 +00:00
|
|
|
if (connected == MeshService.ConnectionState.CONNECTED) {
|
2020-02-17 02:14:40 +00:00
|
|
|
|
|
|
|
// everytime the radio reconnects, we slam in our current owner data, the radio is smart enough to only broadcast if needed
|
2020-04-09 23:33:42 +00:00
|
|
|
model.setOwner()
|
2020-04-10 00:06:41 +00:00
|
|
|
|
2020-04-09 19:22:41 +00:00
|
|
|
model.meshService?.let { service ->
|
|
|
|
debug("Getting latest radioconfig from service")
|
|
|
|
model.radioConfig.value =
|
2020-04-19 19:15:42 +00:00
|
|
|
MeshProtos.RadioConfig.parseFrom(service.radioConfig)
|
2020-04-09 20:28:44 +00:00
|
|
|
|
2020-05-14 00:00:23 +00:00
|
|
|
model.myNodeInfo.value = service.myNodeInfo
|
|
|
|
|
2020-04-19 19:15:42 +00:00
|
|
|
updateNodesFromDevice()
|
2020-04-13 22:42:36 +00:00
|
|
|
|
2020-04-10 00:06:41 +00:00
|
|
|
// we have a connection to our device now, do the channel change
|
|
|
|
perhapsChangeChannel()
|
2020-04-09 19:22:41 +00:00
|
|
|
}
|
2020-02-11 04:17:42 +00:00
|
|
|
}
|
2020-02-14 15:47:20 +00:00
|
|
|
}
|
2020-02-09 13:52:17 +00:00
|
|
|
|
2020-04-10 00:06:41 +00:00
|
|
|
private fun perhapsChangeChannel() {
|
|
|
|
// If the is opening a channel URL, handle it now
|
|
|
|
requestedChannelUrl?.let { url ->
|
|
|
|
val channel = Channel(url)
|
|
|
|
requestedChannelUrl = null
|
|
|
|
|
|
|
|
MaterialAlertDialogBuilder(this)
|
|
|
|
.setTitle(R.string.new_channel_rcvd)
|
|
|
|
.setMessage(getString(R.string.do_you_want_switch).format(channel.name))
|
|
|
|
.setNeutralButton(R.string.cancel) { _, _ ->
|
|
|
|
// Do nothing
|
|
|
|
}
|
|
|
|
.setPositiveButton(R.string.accept) { _, _ ->
|
|
|
|
debug("Setting channel from URL")
|
|
|
|
model.setChannel(channel.settings)
|
|
|
|
}
|
|
|
|
.show()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-03-05 17:50:33 +00:00
|
|
|
override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
|
|
|
|
return try {
|
|
|
|
super.dispatchTouchEvent(ev)
|
2020-03-10 01:54:33 +00:00
|
|
|
} catch (ex: Throwable) {
|
2020-03-05 17:50:33 +00:00
|
|
|
Exceptions.report(
|
|
|
|
ex,
|
|
|
|
"dispatchTouchEvent"
|
|
|
|
) // hide this Compose error from the user but report to the mothership
|
|
|
|
false
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-04-07 17:40:01 +00:00
|
|
|
private
|
|
|
|
val meshServiceReceiver = object : BroadcastReceiver() {
|
2020-02-09 13:52:17 +00:00
|
|
|
|
2020-04-07 17:40:01 +00:00
|
|
|
override fun onReceive(context: Context, intent: Intent) =
|
|
|
|
exceptionReporter {
|
|
|
|
debug("Received from mesh service $intent")
|
2020-02-09 13:52:17 +00:00
|
|
|
|
2020-04-07 17:40:01 +00:00
|
|
|
when (intent.action) {
|
|
|
|
MeshService.ACTION_NODE_CHANGE -> {
|
|
|
|
val info: NodeInfo =
|
|
|
|
intent.getParcelableExtra(EXTRA_NODEINFO)!!
|
|
|
|
debug("UI nodechange $info")
|
2020-02-09 13:52:17 +00:00
|
|
|
|
2020-04-07 17:40:01 +00:00
|
|
|
// We only care about nodes that have user info
|
|
|
|
info.user?.id?.let {
|
2020-04-08 16:53:04 +00:00
|
|
|
val newnodes = model.nodeDB.nodes.value!! + Pair(it, info)
|
|
|
|
model.nodeDB.nodes.value = newnodes
|
2020-04-07 17:40:01 +00:00
|
|
|
}
|
2020-02-09 13:52:17 +00:00
|
|
|
}
|
2020-02-17 23:56:04 +00:00
|
|
|
|
2020-04-07 17:40:01 +00:00
|
|
|
MeshService.ACTION_RECEIVED_DATA -> {
|
2020-04-19 18:47:34 +00:00
|
|
|
debug("received new data from service")
|
2020-04-07 17:40:01 +00:00
|
|
|
val payload =
|
2020-04-19 18:47:34 +00:00
|
|
|
intent.getParcelableExtra<DataPacket>(EXTRA_PAYLOAD)!!
|
2020-04-07 17:40:01 +00:00
|
|
|
|
2020-04-19 18:47:34 +00:00
|
|
|
when (payload.dataType) {
|
2020-04-07 17:40:01 +00:00
|
|
|
MeshProtos.Data.Type.CLEAR_TEXT_VALUE -> {
|
2020-04-19 18:47:34 +00:00
|
|
|
model.messagesState.addMessage(payload)
|
2020-04-07 17:40:01 +00:00
|
|
|
}
|
2020-04-19 18:47:34 +00:00
|
|
|
else ->
|
|
|
|
errormsg("Unhandled dataType ${payload.dataType}")
|
2020-02-09 13:52:17 +00:00
|
|
|
}
|
|
|
|
}
|
2020-04-07 17:40:01 +00:00
|
|
|
MeshService.ACTION_MESH_CONNECTED -> {
|
|
|
|
val connected =
|
|
|
|
MeshService.ConnectionState.valueOf(
|
|
|
|
intent.getStringExtra(
|
|
|
|
EXTRA_CONNECTED
|
|
|
|
)!!
|
|
|
|
)
|
|
|
|
onMeshConnectionChanged(connected)
|
|
|
|
}
|
|
|
|
else -> TODO()
|
2020-02-09 13:52:17 +00:00
|
|
|
}
|
|
|
|
}
|
2020-02-14 15:47:20 +00:00
|
|
|
}
|
2020-01-20 23:53:22 +00:00
|
|
|
|
2020-01-28 00:00:00 +00:00
|
|
|
|
2020-04-07 17:40:01 +00:00
|
|
|
private
|
|
|
|
val mesh = object :
|
|
|
|
ServiceClient<com.geeksville.mesh.IMeshService>({
|
|
|
|
com.geeksville.mesh.IMeshService.Stub.asInterface(it)
|
|
|
|
}) {
|
2020-04-08 22:25:57 +00:00
|
|
|
override fun onConnected(service: com.geeksville.mesh.IMeshService) = exceptionReporter {
|
2020-04-08 15:16:06 +00:00
|
|
|
model.meshService = service
|
2020-02-11 04:17:42 +00:00
|
|
|
|
2020-02-25 16:10:23 +00:00
|
|
|
// We don't start listening for packets until after we are connected to the service
|
|
|
|
registerMeshReceiver()
|
2020-01-28 03:23:34 +00:00
|
|
|
|
2020-04-19 18:47:34 +00:00
|
|
|
// Init our messages table with the service's record of past text messages
|
2020-04-19 18:56:06 +00:00
|
|
|
model.messagesState.messages.value = service.oldMessages.map {
|
2020-04-19 18:47:34 +00:00
|
|
|
TextMessage(it)
|
|
|
|
}
|
|
|
|
|
2020-04-07 17:40:01 +00:00
|
|
|
val connectionState =
|
|
|
|
MeshService.ConnectionState.valueOf(service.connectionState())
|
2020-04-19 19:15:42 +00:00
|
|
|
|
|
|
|
// if we are not connected, onMeshConnectionChange won't fetch nodes from the service
|
|
|
|
// in that case, we do it here - because the service certainly has a better idea of node db that we have
|
|
|
|
if (connectionState != MeshService.ConnectionState.CONNECTED)
|
|
|
|
updateNodesFromDevice()
|
|
|
|
|
|
|
|
// We won't receive a notify for the initial state of connection, so we force an update here
|
2020-04-04 22:29:16 +00:00
|
|
|
onMeshConnectionChanged(connectionState)
|
2020-02-10 23:39:04 +00:00
|
|
|
|
2020-04-08 16:53:04 +00:00
|
|
|
debug("connected to mesh service, isConnected=${model.isConnected.value}")
|
2020-02-25 16:10:23 +00:00
|
|
|
}
|
2020-02-14 15:47:20 +00:00
|
|
|
|
2020-02-25 16:10:23 +00:00
|
|
|
override fun onDisconnected() {
|
2020-02-14 15:47:20 +00:00
|
|
|
unregisterMeshReceiver()
|
2020-04-08 16:53:04 +00:00
|
|
|
model.meshService = null
|
2020-01-23 16:09:50 +00:00
|
|
|
}
|
2020-02-14 15:47:20 +00:00
|
|
|
}
|
2020-01-23 16:09:50 +00:00
|
|
|
|
2020-04-20 02:23:20 +00:00
|
|
|
private fun bindMeshService() {
|
2020-02-14 15:47:20 +00:00
|
|
|
debug("Binding to mesh service!")
|
|
|
|
// we bind using the well known name, to make sure 3rd party apps could also
|
2020-04-08 16:53:04 +00:00
|
|
|
if (model.meshService != null)
|
2020-03-08 21:47:00 +00:00
|
|
|
Exceptions.reportError("meshService was supposed to be null, ignoring (but reporting a bug)")
|
2020-01-23 17:04:06 +00:00
|
|
|
|
2020-05-11 20:12:44 +00:00
|
|
|
MeshService.startService(this) // Start the service so it stays running even after we unbind
|
|
|
|
|
|
|
|
// ALSO bind so we can use the api
|
|
|
|
mesh.connect(this, MeshService.intent, Context.BIND_AUTO_CREATE + Context.BIND_ABOVE_CLIENT)
|
2020-02-14 15:47:20 +00:00
|
|
|
}
|
2020-01-23 16:09:50 +00:00
|
|
|
|
2020-04-20 02:23:20 +00:00
|
|
|
private fun unbindMeshService() {
|
2020-02-14 15:47:20 +00:00
|
|
|
// If we have received the service, and hence registered with
|
|
|
|
// it, then now is the time to unregister.
|
|
|
|
// if we never connected, do nothing
|
2020-04-19 23:24:47 +00:00
|
|
|
debug("Unbinding from mesh service!")
|
|
|
|
mesh.close()
|
|
|
|
model.meshService = null
|
2020-02-14 15:47:20 +00:00
|
|
|
}
|
2020-01-23 16:09:50 +00:00
|
|
|
|
2020-02-18 17:09:49 +00:00
|
|
|
override fun onStop() {
|
2020-02-14 15:47:20 +00:00
|
|
|
unregisterMeshReceiver() // No point in receiving updates while the GUI is gone, we'll get them when the user launches the activity
|
|
|
|
unbindMeshService()
|
2020-01-23 16:09:50 +00:00
|
|
|
|
2020-02-18 17:09:49 +00:00
|
|
|
super.onStop()
|
2020-02-14 15:47:20 +00:00
|
|
|
}
|
2020-01-23 16:09:50 +00:00
|
|
|
|
2020-02-18 17:09:49 +00:00
|
|
|
override fun onStart() {
|
|
|
|
super.onStart()
|
2020-01-23 16:09:50 +00:00
|
|
|
|
2020-04-20 16:56:38 +00:00
|
|
|
// Ensures Bluetooth is available on the device and it is enabled. If not,
|
|
|
|
// displays a dialog requesting user permission to enable Bluetooth.
|
|
|
|
if (!isInTestLab) {
|
|
|
|
bluetoothAdapter?.takeIf { !it.isEnabled }?.apply {
|
|
|
|
val enableBtIntent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)
|
|
|
|
startActivityForResult(enableBtIntent, REQUEST_ENABLE_BT)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-02-14 15:47:20 +00:00
|
|
|
bindMeshService()
|
2020-02-25 23:07:09 +00:00
|
|
|
|
2020-04-07 17:40:01 +00:00
|
|
|
val bonded =
|
|
|
|
RadioInterfaceService.getBondedDeviceAddress(this) != null
|
2020-02-25 23:07:09 +00:00
|
|
|
if (!bonded)
|
2020-04-09 20:28:44 +00:00
|
|
|
pager.currentItem = 5
|
2020-02-14 15:47:20 +00:00
|
|
|
}
|
2020-01-23 16:09:50 +00:00
|
|
|
|
2020-02-14 15:47:20 +00:00
|
|
|
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
|
|
|
// Inflate the menu; this adds items to the action bar if it is present.
|
|
|
|
menuInflater.inflate(R.menu.menu_main, menu)
|
|
|
|
return true
|
|
|
|
}
|
2020-01-20 23:53:22 +00:00
|
|
|
|
2020-02-14 15:47:20 +00:00
|
|
|
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
|
|
|
// Handle action bar item clicks here. The action bar will
|
|
|
|
// automatically handle clicks on the Home/Up button, so long
|
|
|
|
// as you specify a parent activity in AndroidManifest.xml.
|
|
|
|
return when (item.itemId) {
|
|
|
|
R.id.action_settings -> true
|
|
|
|
else -> super.onOptionsItemSelected(item)
|
2020-01-20 23:53:22 +00:00
|
|
|
}
|
|
|
|
}
|
2020-02-14 15:47:20 +00:00
|
|
|
}
|
2020-01-21 17:37:39 +00:00
|
|
|
|
2020-02-09 18:18:26 +00:00
|
|
|
|