Meshtastic-Android/app/src/main/java/com/geeksville/mesh/MainActivity.kt

557 lines
18 KiB
Kotlin
Raw Normal View History

2020-01-22 21:46:41 -08:00
package com.geeksville.mesh
2020-01-20 15:53:22 -08:00
2020-01-21 13:12:01 -08:00
import android.Manifest
2020-01-24 12:49:27 -08:00
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothManager
2020-02-09 05:52:17 -08:00
import android.content.*
2020-01-21 13:12:01 -08:00
import android.content.pm.PackageManager
2020-02-04 13:24:04 -08:00
import android.os.Build
2020-01-20 15:53:22 -08:00
import android.os.Bundle
2020-01-25 10:00:57 -08:00
import android.os.Debug
2020-01-23 08:09:50 -08:00
import android.os.IBinder
2020-01-20 15:53:22 -08:00
import android.view.Menu
import android.view.MenuItem
2020-01-22 13:02:24 -08:00
import android.widget.Toast
2020-02-10 07:40:45 -08:00
import androidx.annotation.DrawableRes
2020-01-24 12:49:27 -08:00
import androidx.appcompat.app.AppCompatActivity
2020-01-22 14:48:06 -08:00
import androidx.compose.Composable
2020-02-10 07:40:45 -08:00
import androidx.compose.Model
2020-02-09 07:28:24 -08:00
import androidx.compose.mutableStateOf
2020-02-10 07:40:45 -08:00
import androidx.compose.state
2020-01-21 13:12:01 -08:00
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
2020-02-10 07:40:45 -08:00
import androidx.ui.animation.Crossfade
import androidx.ui.core.Modifier
2020-01-22 14:48:06 -08:00
import androidx.ui.core.Text
2020-02-10 07:40:45 -08:00
import androidx.ui.core.WithDensity
2020-01-22 14:48:06 -08:00
import androidx.ui.core.setContent
2020-02-10 07:40:45 -08:00
import androidx.ui.foundation.Clickable
import androidx.ui.foundation.VerticalScroller
import androidx.ui.foundation.shape.corner.RoundedCornerShape
import androidx.ui.graphics.Color
import androidx.ui.graphics.vector.DrawVector
import androidx.ui.layout.*
import androidx.ui.material.*
import androidx.ui.material.ripple.Ripple
import androidx.ui.material.surface.Surface
import androidx.ui.res.vectorResource
2020-01-22 14:48:06 -08:00
import androidx.ui.tooling.preview.Preview
2020-02-10 07:40:45 -08:00
import androidx.ui.unit.dp
2020-01-22 14:27:22 -08:00
import com.geeksville.android.Logging
2020-02-04 21:23:52 -08:00
import com.geeksville.util.exceptionReporter
2020-01-25 10:00:57 -08:00
import com.google.firebase.crashlytics.FirebaseCrashlytics
2020-02-09 05:52:17 -08:00
import java.nio.charset.Charset
import java.util.*
2020-01-20 15:53:22 -08:00
2020-02-10 07:40:45 -08:00
// defines the screens we have in the app
sealed class Screen {
object Home : Screen()
object Settings : Screen()
}
@Model
object AppStatus {
var currentScreen: Screen = Screen.Home
}
/**
* Temporary solution pending navigation support.
*/
fun navigateTo(destination: Screen) {
AppStatus.currentScreen = destination
}
2020-01-21 09:37:39 -08:00
2020-01-22 14:27:22 -08:00
class MainActivity : AppCompatActivity(), Logging {
2020-01-20 15:53:22 -08:00
2020-01-21 09:37:39 -08:00
companion object {
const val REQUEST_ENABLE_BT = 10
2020-01-21 13:12:01 -08:00
const val DID_REQUEST_PERM = 11
2020-02-09 10:18:26 -08:00
private val testPositions = arrayOf(
Position(32.776665, -96.796989, 35), // dallas
Position(32.960758, -96.733521, 35), // richardson
Position(32.912901, -96.781776, 35) // north dallas
)
private val testNodes = testPositions.mapIndexed { index, it ->
NodeInfo(
9 + index,
MeshUser("+65087653%02d".format(9 + index), "Kevin Mester$index", "KM$index"),
it,
12345
)
}
data class TextMessage(val date: Date, val from: String, val text: String)
private val testTexts = listOf(
TextMessage(Date(), "+6508675310", "I found the cache"),
TextMessage(Date(), "+6508675311", "Help! I've fallen and I can't get up.")
)
2020-01-21 09:37:39 -08:00
}
2020-02-09 07:28:24 -08:00
/// A map from nodeid to to nodeinfo
2020-02-09 10:18:26 -08:00
private val nodes = mutableStateOf(testNodes.map { it.user!!.id to it }.toMap())
2020-02-09 07:28:24 -08:00
2020-02-09 10:18:26 -08:00
private val messages = mutableStateOf(testTexts)
2020-02-09 07:28:24 -08:00
/// Are we connected to our radio device
private var isConnected = mutableStateOf(false)
private val utf8 = Charset.forName("UTF-8")
2020-01-24 12:49:27 -08:00
2020-01-23 08:09:50 -08:00
2020-01-22 13:02:24 -08:00
private val bluetoothAdapter: BluetoothAdapter? by lazy(LazyThreadSafetyMode.NONE) {
2020-01-21 09:37:39 -08:00
val bluetoothManager = getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
2020-01-22 13:02:24 -08:00
bluetoothManager.adapter
2020-01-21 09:37:39 -08:00
}
2020-01-21 13:12:01 -08:00
fun requestPermission() {
2020-01-22 14:27:22 -08:00
debug("Checking permissions")
2020-01-22 16:45:27 -08:00
val perms = arrayOf(
Manifest.permission.ACCESS_FINE_LOCATION,
2020-01-21 13:12:01 -08:00
Manifest.permission.ACCESS_BACKGROUND_LOCATION,
Manifest.permission.BLUETOOTH,
Manifest.permission.BLUETOOTH_ADMIN,
2020-01-24 17:47:32 -08:00
Manifest.permission.WAKE_LOCK,
Manifest.permission.WRITE_EXTERNAL_STORAGE
2020-01-22 16:45:27 -08:00
)
val missingPerms = perms.filter {
ContextCompat.checkSelfPermission(
this,
it
) != PackageManager.PERMISSION_GRANTED
}
2020-01-21 13:12:01 -08: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
ActivityCompat.requestPermissions(this, missingPerms.toTypedArray(), DID_REQUEST_PERM)
// 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 08:09:50 -08:00
private fun sendTestPackets() {
2020-02-04 21:23:52 -08:00
exceptionReporter {
val m = meshService!!
// Do some test operations
m.setOwner("+16508675309", "Kevin Xter", "kx")
val testPayload = "hello world".toByteArray()
m.sendData(
"+16508675310",
testPayload,
MeshProtos.Data.Type.SIGNAL_OPAQUE_VALUE
)
m.sendData(
"+16508675310",
testPayload,
MeshProtos.Data.Type.CLEAR_TEXT_VALUE
)
}
}
2020-02-10 07:40:45 -08:00
@Composable
fun composeNodeInfo(it: NodeInfo) {
Text("Node: ${it.user?.longName}")
}
@Composable
fun VectorImageButton(@DrawableRes id: Int, onClick: () -> Unit) {
Ripple(bounded = false) {
Clickable(onClick = onClick) {
VectorImage(id = id)
}
}
}
@Composable
fun VectorImage(
modifier: Modifier = Modifier.None, @DrawableRes id: Int,
tint: Color = Color.Transparent
) {
val vector = vectorResource(id)
WithDensity {
Container(
modifier = modifier + LayoutSize(
vector.defaultWidth,
vector.defaultHeight
)
) {
DrawVector(vector, tint)
}
}
}
2020-02-10 07:49:55 -08:00
@Composable
fun HomeContent() {
Column {
Text(text = "Meshtastic")
Text("Radio connected: ${isConnected.value}")
nodes.value.values.forEach {
composeNodeInfo(it)
}
messages.value.forEach {
Text("Text: ${it.text}")
}
Button(text = "Start scan",
onClick = {
if (bluetoothAdapter != null) {
// Note: We don't want this service to die just because our activity goes away (because it is doing a software update)
// So we use the application context instead of the activity
SoftwareUpdateService.enqueueWork(
applicationContext,
SoftwareUpdateService.startUpdateIntent
)
}
})
Button(text = "send packets",
onClick = { sendTestPackets() })
}
}
2020-02-10 07:40:45 -08:00
@Composable
fun HomeScreen(openDrawer: () -> Unit) {
Column {
TopAppBar(
title = { Text(text = "Meshtastic") },
navigationIcon = {
VectorImageButton(R.drawable.ic_launcher_foreground) {
openDrawer()
}
}
)
VerticalScroller(modifier = LayoutFlexible(1f)) {
2020-02-10 07:49:55 -08:00
HomeContent()
2020-02-10 07:40:45 -08:00
}
}
}
2020-01-23 08:09:50 -08:00
@Composable
2020-02-09 07:28:24 -08:00
fun composeView() {
2020-02-10 07:40:45 -08:00
val (drawerState, onDrawerStateChange) = state { DrawerState.Closed }
2020-01-22 14:48:06 -08:00
MaterialTheme {
2020-02-10 07:40:45 -08:00
ModalDrawerLayout(
drawerState = drawerState,
onStateChange = onDrawerStateChange,
gesturesEnabled = drawerState == DrawerState.Opened,
drawerContent = {
2020-02-09 07:28:24 -08:00
2020-02-10 07:40:45 -08:00
AppDrawer(
currentScreen = AppStatus.currentScreen,
closeDrawer = { onDrawerStateChange(DrawerState.Closed) }
)
2020-02-09 07:28:24 -08:00
2020-02-10 07:40:45 -08:00
/*
// modifier = Spacing(8.dp)
Column() {
*/
}, bodyContent = { AppContent { onDrawerStateChange(DrawerState.Opened) } })
}
}
2020-01-22 14:48:06 -08:00
2020-02-10 07:49:55 -08:00
@Preview
@Composable
fun previewView() {
// It seems modaldrawerlayout not yet supported in preview
HomeContent()
}
2020-02-10 07:40:45 -08:00
@Composable
private fun AppContent(openDrawer: () -> Unit) {
Crossfade(AppStatus.currentScreen) { screen ->
Surface(color = (MaterialTheme.colors()).background) {
when (screen) {
is Screen.Home -> HomeScreen { openDrawer() }
/* is Screen.Interests -> InterestsScreen { openDrawer() }
is Screen.Article -> ArticleScreen(postId = screen.postId) */
2020-01-23 08:09:50 -08:00
}
2020-02-10 07:40:45 -08:00
}
}
}
2020-01-23 08:09:50 -08:00
2020-02-10 07:40:45 -08:00
@Composable
private fun AppDrawer(
currentScreen: Screen,
closeDrawer: () -> Unit
) {
Column(modifier = LayoutSize.Fill) {
Spacer(LayoutHeight(24.dp))
Row(modifier = LayoutPadding(16.dp)) {
VectorImage(
id = R.drawable.ic_launcher_foreground,
tint = (MaterialTheme.colors()).primary
)
Spacer(LayoutWidth(8.dp))
VectorImage(id = R.drawable.ic_launcher_foreground)
}
Divider(color = Color(0x14333333))
DrawerButton(
icon = R.drawable.ic_launcher_foreground,
label = "Home",
isSelected = currentScreen == Screen.Home
) {
navigateTo(Screen.Home)
closeDrawer()
2020-01-22 14:48:06 -08:00
}
2020-02-10 07:40:45 -08:00
/*
DrawerButton(
icon = R.drawable.ic_interests,
label = "Interests",
isSelected = currentScreen == Screen.Interests
) {
navigateTo(Screen.Interests)
closeDrawer()
}
*/
2020-01-22 16:45:27 -08:00
}
2020-01-22 14:48:06 -08:00
}
2020-01-22 14:27:22 -08:00
2020-02-10 07:40:45 -08:00
@Composable
private fun DrawerButton(
modifier: Modifier = Modifier.None,
@DrawableRes icon: Int,
label: String,
isSelected: Boolean,
action: () -> Unit
) {
val colors = MaterialTheme.colors()
val textIconColor = if (isSelected) {
colors.primary
} else {
colors.onSurface.copy(alpha = 0.6f)
}
val backgroundColor = if (isSelected) {
colors.primary.copy(alpha = 0.12f)
} else {
colors.surface
}
Surface(
modifier = modifier + LayoutPadding(
left = 8.dp,
top = 8.dp,
right = 8.dp,
bottom = 0.dp
),
color = backgroundColor,
shape = RoundedCornerShape(4.dp)
) {
Button(onClick = action, style = TextButtonStyle()) {
Row {
VectorImage(
modifier = LayoutGravity.Center,
id = icon,
tint = textIconColor
)
Spacer(LayoutWidth(16.dp))
Text(
text = label,
style = (MaterialTheme.typography()).body2.copy(
color = textIconColor
)
)
}
}
}
}
2020-01-22 14:48:06 -08:00
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
2020-01-25 10:00:57 -08:00
// We default to off in the manifest, FIXME turn on only if user approves
// leave off when running in the debugger
if (false && !Debug.isDebuggerConnected())
FirebaseCrashlytics.getInstance().setCrashlyticsCollectionEnabled(true)
2020-01-22 14:48:06 -08:00
setContent {
2020-02-09 07:28:24 -08:00
composeView()
2020-01-21 09:37:39 -08:00
}
// Ensures Bluetooth is available on the device and it is enabled. If not,
// displays a dialog requesting user permission to enable Bluetooth.
2020-01-22 16:45:27 -08:00
if (bluetoothAdapter != null) {
2020-01-22 13:02:24 -08:00
bluetoothAdapter!!.takeIf { !it.isEnabled }?.apply {
val enableBtIntent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)
startActivityForResult(enableBtIntent, REQUEST_ENABLE_BT)
}
2020-01-22 16:45:27 -08:00
} else {
2020-01-22 13:02:24 -08:00
Toast.makeText(this, "Error - this app requires bluetooth", Toast.LENGTH_LONG).show()
2020-01-20 15:53:22 -08:00
}
2020-01-21 12:07:03 -08:00
2020-01-21 13:12:01 -08:00
requestPermission()
2020-02-09 05:52:17 -08:00
val filter = IntentFilter()
filter.addAction("")
registerReceiver(meshServiceReceiver, filter)
}
override fun onDestroy() {
unregisterReceiver(meshServiceReceiver)
super.onDestroy()
}
private val meshServiceReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) = exceptionReporter {
debug("Received from mesh service $intent")
when (intent.action) {
MeshService.ACTION_NODE_CHANGE -> {
warn("TODO nodechange")
val info: NodeInfo = intent.getParcelableExtra(EXTRA_NODEINFO)!!
// We only care about nodes that have user info
info.user?.id?.let {
2020-02-09 07:28:24 -08:00
val newnodes = nodes.value.toMutableMap()
newnodes[it] = info
nodes.value = newnodes
2020-02-09 05:52:17 -08:00
}
}
2020-02-09 07:28:24 -08:00
2020-02-09 05:52:17 -08:00
MeshService.ACTION_RECEIVED_DATA -> {
warn("TODO rxopaqe")
val sender = intent.getStringExtra(EXTRA_SENDER)!!
val payload = intent.getByteArrayExtra(EXTRA_PAYLOAD)!!
2020-02-09 07:28:24 -08:00
val typ = intent.getIntExtra(EXTRA_TYP, -1)
2020-02-09 05:52:17 -08:00
when (typ) {
MeshProtos.Data.Type.CLEAR_TEXT_VALUE -> {
// FIXME - use the real time from the packet
2020-02-09 07:28:24 -08:00
val modded = messages.value.toMutableList()
modded.add(TextMessage(Date(), sender, payload.toString(utf8)))
messages.value = modded
2020-02-09 05:52:17 -08:00
}
else -> TODO()
}
}
RadioInterfaceService.CONNECTCHANGED_ACTION -> {
2020-02-09 07:28:24 -08:00
isConnected.value = intent.getBooleanExtra(EXTRA_CONNECTED, false)
2020-02-09 05:52:17 -08:00
debug("connchange $isConnected")
}
else -> TODO()
}
}
2020-01-20 15:53:22 -08:00
}
2020-02-04 13:24:04 -08:00
private var meshService: IMeshService? = null
private var isBound = false
2020-01-23 08:09:50 -08:00
2020-02-04 13:24:04 -08:00
private var serviceConnection = object : ServiceConnection {
2020-02-09 05:52:17 -08:00
override fun onServiceConnected(name: ComponentName, service: IBinder) = exceptionReporter {
2020-01-25 10:00:57 -08:00
val m = IMeshService.Stub.asInterface(service)
meshService = m
2020-02-09 05:52:17 -08:00
// FIXME - do actions for when we connect to the service
// FIXME - do actions for when we connect to the service
debug("did connect")
2020-02-09 07:28:24 -08:00
isConnected.value = m.isConnected
2020-02-09 07:28:24 -08:00
// make some placeholder nodeinfos
2020-02-09 10:18:26 -08:00
nodes.value =
m.online.toList().map { it to NodeInfo(0, MeshUser(it, "unknown", "unk")) }.toMap()
2020-01-23 08:09:50 -08:00
}
override fun onServiceDisconnected(name: ComponentName) {
2020-01-24 12:49:27 -08:00
meshService = null
2020-01-23 08:09:50 -08:00
}
}
private fun bindMeshService() {
debug("Binding to mesh service!")
2020-01-23 08:09:50 -08:00
// we bind using the well known name, to make sure 3rd party apps could also
logAssert(meshService == null)
// bind to our service using the same mechanism an external client would use (for testing coverage)
// The following would work for us, but not external users
//val intent = Intent(this, MeshService::class.java)
//intent.action = IMeshService::class.java.name
2020-02-04 13:24:04 -08:00
val intent = Intent()
intent.setClassName("com.geeksville.mesh", "com.geeksville.mesh.MeshService")
// Before binding we want to explicitly create - so the service stays alive forever (so it can keep
// listening for the bluetooth packets arriving from the radio. And when they arrive forward them
// to Signal or whatever.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(intent)
} else {
startService(intent)
}
2020-02-04 13:24:04 -08:00
// ALSO bind so we can use the api
logAssert(bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE))
2020-02-04 13:24:04 -08:00
isBound = true;
2020-01-23 08:09:50 -08:00
}
private fun unbindMeshService() {
// If we have received the service, and hence registered with
// it, then now is the time to unregister.
// if we never connected, do nothing
debug("Unbinding from mesh service!")
2020-02-04 13:24:04 -08:00
if (isBound)
unbindService(serviceConnection)
meshService = null
2020-01-23 08:09:50 -08:00
}
override fun onPause() {
unbindMeshService()
super.onPause()
}
override fun onResume() {
super.onResume()
bindMeshService()
}
2020-01-20 15:53:22 -08: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
}
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.
2020-01-20 16:13:40 -08:00
return when (item.itemId) {
2020-01-20 15:53:22 -08:00
R.id.action_settings -> true
else -> super.onOptionsItemSelected(item)
}
}
}
2020-01-21 09:37:39 -08:00
2020-02-09 10:18:26 -08:00