diff --git a/.gitignore b/.gitignore index 603b14077..3087fc811 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ /captures .externalNativeBuild .cxx +/app/release \ No newline at end of file diff --git a/TODO.md b/TODO.md index 810ea59c1..330bbde5e 100644 --- a/TODO.md +++ b/TODO.md @@ -1,8 +1,7 @@ # High priority MVP features required for first public alpha -* use google signin to get user name (later give other options) -* always set a _unique_ owner id (if changed) +* let user set name and shortname * stop scan when we start the service * set the radio by using the service * startforegroundservice only if we have a valid radio @@ -52,6 +51,8 @@ Do this "Signal app compatible" release relatively soon after the alpha release # Medium priority Things for the betaish period. +* use google signin to get user name +* use Firebase Test Lab * let user pick/specify a name through ways other than google signin (for the privacy concerned, or devices without Play API) * make my android app show mesh state * show qr code for each channel https://medium.com/@aanandshekharroy/generate-barcode-in-android-app-using-zxing-64c076a5d83a @@ -113,4 +114,5 @@ Don't leave device discoverable. Don't let unpaired users do things with device * use https://codelabs.developers.google.com/codelabs/jetpack-compose-basics/#4 to show service state * all chat in the app defaults to group chat * start bt receive on boot -* warn user to bt pair \ No newline at end of file +* warn user to bt pair +* suppress logging output if running a release build (required for play store) \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle index e7da61068..e1ffd777b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -14,10 +14,10 @@ android { buildToolsVersion "29.0.2" defaultConfig { applicationId "com.geeksville.mesh" - minSdkVersion 21 + minSdkVersion 22 // The oldest emulator image I have tried is 22 (though 21 probably works) targetSdkVersion 29 - versionCode 1 - versionName "1.0" + versionCode 2 + versionName "0.1" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } buildTypes { @@ -99,6 +99,9 @@ dependencies { androidTestImplementation("androidx.ui:ui-platform:$compose_version") androidTestImplementation("androidx.ui:ui-test:$compose_version") + // For Google Sign-In (owner name accesss) + implementation 'com.google.android.gms:play-services-auth:17.0.0' + // Add the Firebase SDK for Crashlytics. implementation 'com.google.firebase:firebase-crashlytics:17.0.0-beta01' diff --git a/app/src/main/java/com/geeksville/mesh/MainActivity.kt b/app/src/main/java/com/geeksville/mesh/MainActivity.kt index 78d678524..19be0b4a7 100644 --- a/app/src/main/java/com/geeksville/mesh/MainActivity.kt +++ b/app/src/main/java/com/geeksville/mesh/MainActivity.kt @@ -20,6 +20,11 @@ import com.geeksville.mesh.ui.MeshApp import com.geeksville.mesh.ui.TextMessage import com.geeksville.mesh.ui.UIState import com.geeksville.util.exceptionReporter +import com.google.android.gms.auth.api.signin.GoogleSignIn +import com.google.android.gms.auth.api.signin.GoogleSignInAccount +import com.google.android.gms.auth.api.signin.GoogleSignInOptions +import com.google.android.gms.common.api.ApiException +import com.google.android.gms.tasks.Task import java.nio.charset.Charset import java.util.* @@ -30,12 +35,12 @@ class MainActivity : AppCompatActivity(), Logging, companion object { const val REQUEST_ENABLE_BT = 10 const val DID_REQUEST_PERM = 11 + const val RC_SIGN_IN = 12 // google signin completed } private val utf8 = Charset.forName("UTF-8") - private val bluetoothAdapter: BluetoothAdapter? by lazy(LazyThreadSafetyMode.NONE) { val bluetoothManager = getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager bluetoothManager.adapter @@ -93,214 +98,253 @@ class MainActivity : AppCompatActivity(), Logging, private fun setOwner() { - try { + // Note: we are careful to not set a new unique ID + meshService!!.setOwner(null, "Kevin Xter", "kx") + } + private fun sendTestPackets() { + exceptionReporter { + val m = meshService!! - // Note: we are careful to not set a new unique ID - meshService!!.setOwner(null, "Kevin Xter", "kx") - } - - private fun sendTestPackets() { - exceptionReporter { - val m = meshService!! - - // Do some test operations - 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 - ) - } - } - - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - setContent { - MeshApp() - } - - // Ensures Bluetooth is available on the device and it is enabled. If not, - // displays a dialog requesting user permission to enable Bluetooth. - if (bluetoothAdapter != null) { - bluetoothAdapter!!.takeIf { !it.isEnabled }?.apply { - val enableBtIntent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE) - startActivityForResult(enableBtIntent, REQUEST_ENABLE_BT) - } - } else { - Toast.makeText(this, "Error - this app requires bluetooth", Toast.LENGTH_LONG) - .show() - } - - requestPermission() - } - - override fun onDestroy() { - unregisterMeshReceiver() - super.onDestroy() - } - - private var receiverRegistered = false - - private fun registerMeshReceiver() { - logAssert(!receiverRegistered) - val filter = IntentFilter() - filter.addAction(MeshService.ACTION_MESH_CONNECTED) - filter.addAction(MeshService.ACTION_NODE_CHANGE) - filter.addAction(MeshService.ACTION_RECEIVED_DATA) - registerReceiver(meshServiceReceiver, filter) - - } - - private fun unregisterMeshReceiver() { - if (receiverRegistered) { - receiverRegistered = false - unregisterReceiver(meshServiceReceiver) - } - } - - /// Called when we gain/lose a connection to our mesh radio - private fun onMeshConnectionChanged(connected: Boolean) { - UIState.isConnected.value = connected - debug("connchange ${UIState.isConnected.value}") - if (connected) { - // everytime the radio reconnects, we slam in our current owner data - setOwner() - } - } - - 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 -> { - val info: NodeInfo = intent.getParcelableExtra(EXTRA_NODEINFO)!! - debug("UI nodechange $info") - - // We only care about nodes that have user info - info.user?.id?.let { - val newnodes = UIState.nodes.value.toMutableMap() - newnodes[it] = info - UIState.nodes.value = newnodes - } - } - - MeshService.ACTION_RECEIVED_DATA -> { - debug("TODO rxdata") - val sender = intent.getStringExtra(EXTRA_SENDER)!! - val payload = intent.getByteArrayExtra(EXTRA_PAYLOAD)!! - val typ = intent.getIntExtra(EXTRA_TYP, -1) - - when (typ) { - MeshProtos.Data.Type.CLEAR_TEXT_VALUE -> { - // FIXME - use the real time from the packet - val modded = UIState.messages.value.toMutableList() - modded.add(TextMessage(Date(), sender, payload.toString(utf8))) - UIState.messages.value = modded - } - else -> TODO() - } - } - MeshService.ACTION_MESH_CONNECTED -> { - val connected = intent.getBooleanExtra(EXTRA_CONNECTED, false) - onMeshConnectionChanged(connected) - } - else -> TODO() - } - } - } - - private var meshService: IMeshService? = null - private var isBound = false - - private var serviceConnection = object : ServiceConnection { - override fun onServiceConnected(name: ComponentName, service: IBinder) = - exceptionReporter { - val m = IMeshService.Stub.asInterface(service) - meshService = m - - // We don't start listening for packets until after we are connected to the service - registerMeshReceiver() - - // We won't receive a notify for the initial state of connection, so we force an update here - onMeshConnectionChanged(m.isConnected) - - debug("connected to mesh service, isConnected=${UIState.isConnected.value}") - - // make some placeholder nodeinfos - UIState.nodes.value = - m.nodes.toList().map { - it.user?.id!! to it - }.toMap() - } - - override fun onServiceDisconnected(name: ComponentName) { - warn("The mesh service has disconnected") - unregisterMeshReceiver() - meshService = null - } - } - - private fun bindMeshService() { - debug("Binding to mesh service!") - // we bind using the well known name, to make sure 3rd party apps could also - logAssert(meshService == null) - - val intent = MeshService.startService(this) - if (intent != null) { - // ALSO bind so we can use the api - logAssert(bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE)) - isBound = true; - } - } - - 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!") - if (isBound) - unbindService(serviceConnection) - meshService = null - } - - override fun onPause() { - unregisterMeshReceiver() // No point in receiving updates while the GUI is gone, we'll get them when the user launches the activity - unbindMeshService() - - super.onPause() - } - - override fun onResume() { - super.onResume() - - bindMeshService() - } - - 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. - return when (item.itemId) { - R.id.action_settings -> true - else -> super.onOptionsItemSelected(item) - } + // Do some test operations + 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 + ) } } + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + MeshApp() + } + + // Ensures Bluetooth is available on the device and it is enabled. If not, + // displays a dialog requesting user permission to enable Bluetooth. + if (bluetoothAdapter != null) { + bluetoothAdapter!!.takeIf { !it.isEnabled }?.apply { + val enableBtIntent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE) + startActivityForResult(enableBtIntent, REQUEST_ENABLE_BT) + } + } else { + Toast.makeText(this, "Error - this app requires bluetooth", Toast.LENGTH_LONG) + .show() + } + + requestPermission() + + // 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() + + // Build a GoogleSignInClient with the options specified by gso. + UIState.googleSignInClient = GoogleSignIn.getClient(this, gso); + } + + override fun onDestroy() { + unregisterMeshReceiver() + super.onDestroy() + } + + /** + * Dispatch incoming result to the correct fragment. + */ + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + + // Result returned from launching the Intent from GoogleSignInClient.getSignInIntent(...); + // Result returned from launching the Intent from GoogleSignInClient.getSignInIntent(...); + if (requestCode === RC_SIGN_IN) { + // The Task returned from this call is always completed, no need to attach + // a listener. + val task: Task = + GoogleSignIn.getSignedInAccountFromIntent(data) + handleSignInResult(task) + } + } + + private fun handleSignInResult(completedTask: Task) { + try { + val account = + completedTask.getResult(ApiException::class.java) + // 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) + } + } + + private var receiverRegistered = false + + private fun registerMeshReceiver() { + logAssert(!receiverRegistered) + val filter = IntentFilter() + filter.addAction(MeshService.ACTION_MESH_CONNECTED) + filter.addAction(MeshService.ACTION_NODE_CHANGE) + filter.addAction(MeshService.ACTION_RECEIVED_DATA) + registerReceiver(meshServiceReceiver, filter) + + } + + private fun unregisterMeshReceiver() { + if (receiverRegistered) { + receiverRegistered = false + unregisterReceiver(meshServiceReceiver) + } + } + + /// Called when we gain/lose a connection to our mesh radio + private fun onMeshConnectionChanged(connected: Boolean) { + UIState.isConnected.value = connected + debug("connchange ${UIState.isConnected.value}") + if (connected) { + // everytime the radio reconnects, we slam in our current owner data + setOwner() + } + } + + 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 -> { + val info: NodeInfo = intent.getParcelableExtra(EXTRA_NODEINFO)!! + debug("UI nodechange $info") + + // We only care about nodes that have user info + info.user?.id?.let { + val newnodes = UIState.nodes.value.toMutableMap() + newnodes[it] = info + UIState.nodes.value = newnodes + } + } + + MeshService.ACTION_RECEIVED_DATA -> { + debug("TODO rxdata") + val sender = intent.getStringExtra(EXTRA_SENDER)!! + val payload = intent.getByteArrayExtra(EXTRA_PAYLOAD)!! + val typ = intent.getIntExtra(EXTRA_TYP, -1) + + when (typ) { + MeshProtos.Data.Type.CLEAR_TEXT_VALUE -> { + // FIXME - use the real time from the packet + val modded = UIState.messages.value.toMutableList() + modded.add(TextMessage(Date(), sender, payload.toString(utf8))) + UIState.messages.value = modded + } + else -> TODO() + } + } + MeshService.ACTION_MESH_CONNECTED -> { + val connected = intent.getBooleanExtra(EXTRA_CONNECTED, false) + onMeshConnectionChanged(connected) + } + else -> TODO() + } + } + } + + private var meshService: IMeshService? = null + private var isBound = false + + private var serviceConnection = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName, service: IBinder) = + exceptionReporter { + val m = IMeshService.Stub.asInterface(service) + meshService = m + + // We don't start listening for packets until after we are connected to the service + registerMeshReceiver() + + // We won't receive a notify for the initial state of connection, so we force an update here + onMeshConnectionChanged(m.isConnected) + + debug("connected to mesh service, isConnected=${UIState.isConnected.value}") + + // make some placeholder nodeinfos + UIState.nodes.value = + m.nodes.toList().map { + it.user?.id!! to it + }.toMap() + } + + override fun onServiceDisconnected(name: ComponentName) { + warn("The mesh service has disconnected") + unregisterMeshReceiver() + meshService = null + } + } + + private fun bindMeshService() { + debug("Binding to mesh service!") + // we bind using the well known name, to make sure 3rd party apps could also + logAssert(meshService == null) + + val intent = MeshService.startService(this) + if (intent != null) { + // ALSO bind so we can use the api + logAssert(bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE)) + isBound = true; + } + } + + 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!") + if (isBound) + unbindService(serviceConnection) + meshService = null + } + + override fun onPause() { + unregisterMeshReceiver() // No point in receiving updates while the GUI is gone, we'll get them when the user launches the activity + unbindMeshService() + + super.onPause() + } + + override fun onResume() { + super.onResume() + + bindMeshService() + } + + 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. + return when (item.itemId) { + R.id.action_settings -> true + else -> super.onOptionsItemSelected(item) + } + } +} + + diff --git a/app/src/main/java/com/geeksville/mesh/MeshUtilApplication.kt b/app/src/main/java/com/geeksville/mesh/MeshUtilApplication.kt index 264ebbb80..7e437e04d 100644 --- a/app/src/main/java/com/geeksville/mesh/MeshUtilApplication.kt +++ b/app/src/main/java/com/geeksville/mesh/MeshUtilApplication.kt @@ -2,6 +2,7 @@ package com.geeksville.mesh import android.os.Debug import com.geeksville.android.GeeksvilleApplication +import com.geeksville.android.Logging import com.google.firebase.crashlytics.FirebaseCrashlytics @@ -10,6 +11,8 @@ class MeshUtilApplication : GeeksvilleApplication(null, "58e72ccc361883ea502510b override fun onCreate() { super.onCreate() + Logging.showLogs = BuildConfig.DEBUG + // 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()) diff --git a/app/src/main/java/com/geeksville/mesh/ui/MeshApp.kt b/app/src/main/java/com/geeksville/mesh/ui/MeshApp.kt index ebf0e2e3e..84d7e6dd8 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/MeshApp.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/MeshApp.kt @@ -36,6 +36,22 @@ fun HomeContent() { Text("Text: ${it.text}") } + /* FIXME - doens't work yet - probably because I'm not using release keys + // If account is null, then show the signin button, otherwise + val context = ambient(ContextAmbient) + val account = GoogleSignIn.getLastSignedInAccount(context) + if (account != null) + Text("We have an account") + else { + Text("No account yet") + if (context is Activity) { + Button("Google sign-in", onClick = { + val signInIntent: Intent = UIState.googleSignInClient.signInIntent + context.startActivityForResult(signInIntent, MainActivity.RC_SIGN_IN) + }) + } + } */ + /* Button(text = "Start scan", onClick = { diff --git a/app/src/main/java/com/geeksville/mesh/ui/Status.kt b/app/src/main/java/com/geeksville/mesh/ui/Status.kt index 097b34257..2f0fc37f9 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/Status.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/Status.kt @@ -5,6 +5,7 @@ import androidx.compose.mutableStateOf import com.geeksville.mesh.MeshUser import com.geeksville.mesh.NodeInfo import com.geeksville.mesh.Position +import com.google.android.gms.auth.api.signin.GoogleSignInClient import java.util.* // defines the screens we have in the app @@ -22,7 +23,10 @@ data class TextMessage(val date: Date, val from: String, val text: String) /// FIXME - figure out how to merge this staate with the AppStatus Model object UIState { - + + /// Kinda ugly - created in the activity but used from Compose - figure out if there is a cleaner way GIXME + lateinit var googleSignInClient: GoogleSignInClient + private val testPositions = arrayOf( Position(32.776665, -96.796989, 35), // dallas Position(32.960758, -96.733521, 35), // richardson diff --git a/images/play-store-feature-graphic.jpg b/images/play-store-feature-graphic.jpg new file mode 100644 index 000000000..92b286898 Binary files /dev/null and b/images/play-store-feature-graphic.jpg differ diff --git a/images/screenshot1.png b/images/screenshot1.png new file mode 100644 index 000000000..f22dc3d98 Binary files /dev/null and b/images/screenshot1.png differ diff --git a/images/screenshot2.png b/images/screenshot2.png new file mode 100644 index 000000000..0207b75d1 Binary files /dev/null and b/images/screenshot2.png differ