diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000..f25903e74 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,9 @@ +## Thank you for sending in a pull request, here's some tips to get started! + +(Please delete all these tips and replace with your text) + +- Mention "#(issue)" in the description, when applicable +- Please do not check in files that don't have real changes +- Please do not reformat lines that you didn't have to change the code on +- If your other co-developers have comments on your PR please tweak as needed +- Do not use any external image service, just paste or drag and drop the image here and it will be uploaded automatically diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 09eb3acf5..46c175f61 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -17,18 +17,17 @@ jobs: - name: Load secrets run: | - rm ./app/google-services.json - cp ./app/google-services-example.json ./app/google-services.json rm ./app/src/main/res/values/mapbox-token.xml echo -e "\n $MAPBOXTOKEN\n" > ./app/src/main/res/values/mapbox-token.xml mkdir -p ~/.gradle echo "MAPBOX_DOWNLOADS_TOKEN=$MAPBOXTOKEN" >>~/.gradle/gradle.properties env: - GSERVICES: ${{ secrets.GSERVICES }} MAPBOXTOKEN: ${{ secrets.MAPBOXTOKEN }} - - name: Mock curfirmware version for CI + - name: Mock files for CI run: | + rm ./app/google-services.json + cp ./app/google-services-example.json ./app/google-services.json rm ./app/src/main/res/values/curfirmwareversion.xml cp ./app/special/curfirmwareversion.xml ./app/src/main/res/values/ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1917dc546..8c8d7336e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -98,7 +98,7 @@ jobs: with: draft: true prerelease: true - release_name: ${{ github.event.inputs.version}} alpha + release_name: Meshtastic Android ${{ github.event.inputs.version}} alpha tag_name: ${{ github.event.inputs.version}} body: | Autogenerated by github action, developer should edit as required before publishing... diff --git a/README.md b/README.md index 51122d3a9..95ee92947 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ cp ./app/special/curfirmwareversion.xml ./app/src/main/res/values/ cat ~/.gradle/gradle.properties MAPBOX_DOWNLOADS_TOKEN=sk.yourtokenherexxx ``` +- (optional) to run CI tests on your fork: 1) allow GitHub Actions; 2) add your token at: Settings > Secrets > Actions > New repository secret: Name: MAPBOXTOKEN Value: sk.yourtokenherexxx - Now you should be able to select "Run / Run" in the IDE and it will happily start running on your phone or the emulator. Note: The emulators don't support bluetooth, so some features can not be used in diff --git a/app/build.gradle b/app/build.gradle index 796d04a2e..316d6f1c4 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -42,8 +42,8 @@ android { applicationId "com.geeksville.mesh" minSdkVersion 21 // The oldest emulator image I have tried is 22 (though 21 probably works) targetSdkVersion 30 // 30 can't work until an explicit location permissions dialog is added - versionCode 20255 // format is Mmmss (where M is 1+the numeric major number - versionName "1.2.55" + versionCode 20256 // format is Mmmss (where M is 1+the numeric major number + versionName "1.2.56" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" // per https://developer.android.com/studio/write/vector-asset-studio @@ -199,5 +199,7 @@ dependencies { // implementation "androidx.work:work-runtime:$work_version" implementation "androidx.work:work-runtime-ktx:$work_version" + implementation "androidx.core:core-splashscreen:1.0.0-beta01" + implementation project(':geeksville-androidlib') } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 94c6aab71..c33d08766 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -128,7 +128,7 @@ android:label="@string/app_name" android:screenOrientation="portrait" android:windowSoftInputMode="stateAlwaysHidden" - android:theme="@style/AppTheme" + android:theme="@style/Theme.App.Starting" android:exported="true"> diff --git a/app/src/main/java/com/geeksville/mesh/MainActivity.kt b/app/src/main/java/com/geeksville/mesh/MainActivity.kt index 9839c59c7..3acc7c012 100644 --- a/app/src/main/java/com/geeksville/mesh/MainActivity.kt +++ b/app/src/main/java/com/geeksville/mesh/MainActivity.kt @@ -33,6 +33,7 @@ import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.widget.Toolbar import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentTransaction @@ -43,7 +44,6 @@ import com.geeksville.android.Logging import com.geeksville.android.ServiceClient import com.geeksville.concurrent.handledLaunch import com.geeksville.mesh.android.* -import com.geeksville.mesh.database.entity.Packet import com.geeksville.mesh.databinding.ActivityMainBinding import com.geeksville.mesh.model.ChannelSet import com.geeksville.mesh.model.DeviceVersion @@ -60,16 +60,16 @@ import com.google.android.gms.tasks.Task import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar import com.google.android.material.tabs.TabLayoutMediator -import com.google.protobuf.InvalidProtocolBufferException import com.vorlonsoft.android.rate.AppRate import com.vorlonsoft.android.rate.StoreType -import kotlinx.coroutines.* -import java.io.FileOutputStream +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel import java.nio.charset.Charset import java.text.DateFormat import java.util.* import java.util.regex.Pattern -import kotlin.math.roundToInt /* @@ -388,12 +388,7 @@ class MainActivity : AppCompatActivity(), Logging, return if (message != null) { errormsg("Denied permissions: $message") - Snackbar.make(findViewById(android.R.id.content), message, Snackbar.LENGTH_INDEFINITE) - .apply { view.findViewById(R.id.snackbar_text).isSingleLine = false } - .setAction(R.string.okay) { - // dismiss - } - .show() + showSnackbar(message) true } else false @@ -483,6 +478,7 @@ class MainActivity : AppCompatActivity(), Logging, } override fun onCreate(savedInstanceState: Bundle?) { + installSplashScreen() super.onCreate(savedInstanceState) binding = ActivityMainBinding.inflate(layoutInflater) @@ -661,20 +657,7 @@ class MainActivity : AppCompatActivity(), Logging, } CREATE_CSV_FILE -> { if (resultCode == Activity.RESULT_OK) { - data?.data?.let { file_uri -> - // model.allPackets is a result of a query, so we need to use observer for - // the query to materialize - model.allPackets.observe(this, { packets -> - if (packets != null) { - // no need for observer once got non-null list - model.allPackets.removeObservers(this) - // execute on the default thread pool to not block the main thread - CoroutineScope(Dispatchers.Default + Job()).handledLaunch { - saveMessagesCSV(file_uri, packets) - } - } - }) - } + data?.data?.let { file_uri -> model.saveMessagesCSV(file_uri) } } } } @@ -814,30 +797,35 @@ class MainActivity : AppCompatActivity(), Logging, } } - private fun showToast(msgId: Int) { - Toast.makeText( - this, + private fun showSnackbar(msgId: Int) { + Snackbar.make( + findViewById(android.R.id.content), msgId, - Toast.LENGTH_LONG + Snackbar.LENGTH_LONG ).show() } - private fun showToast(msg: String) { - Toast.makeText( - this, + private fun showSnackbar(msg: String) { + Snackbar.make( + findViewById(android.R.id.content), msg, - Toast.LENGTH_LONG - ).show() + Snackbar.LENGTH_INDEFINITE + ) + .apply { view.findViewById(R.id.snackbar_text).isSingleLine = false } + .setAction(R.string.okay) { + // dismiss + } + .show() } - private fun perhapsChangeChannel() { + fun perhapsChangeChannel(url: Uri? = requestedChannelUrl) { // If the is opening a channel URL, handle it now - requestedChannelUrl?.let { url -> + if (url != null) { try { val channels = ChannelSet(url) val primary = channels.primaryChannel if (primary == null) - showToast(R.string.channel_invalid) + showSnackbar(R.string.channel_invalid) else { requestedChannelUrl = null @@ -853,13 +841,14 @@ class MainActivity : AppCompatActivity(), Logging, model.setChannels(channels) } catch (ex: RemoteException) { errormsg("Couldn't change channel ${ex.message}") - showToast(R.string.cant_change_no_radio) + showSnackbar(R.string.cant_change_no_radio) } } .show() } - } catch (ex: InvalidProtocolBufferException) { - showToast(R.string.channel_invalid) + } catch (ex: Throwable) { + errormsg("Channel url error: ${ex.message}") + showSnackbar("${getString(R.string.channel_invalid)}: ${ex.message}") } } } @@ -1184,46 +1173,12 @@ class MainActivity : AppCompatActivity(), Logging, try { val packageInfo: PackageInfo = packageManager.getPackageInfo(packageName, 0) val versionName = packageInfo.versionName - showToast(versionName) + Toast.makeText(this, versionName, Toast.LENGTH_LONG).show() } catch (e: PackageManager.NameNotFoundException) { errormsg("Can not find the version: ${e.message}") } } - private fun saveMessagesCSV(file_uri: Uri, packets: List) { - // Extract distances to this device from position messages and put (node,SNR,distance) in - // the file_uri - val myNodeNum = model.myNodeInfo.value?.myNodeNum ?: return - - applicationContext.contentResolver.openFileDescriptor(file_uri, "w")?.use { - FileOutputStream(it.fileDescriptor).use { fs -> - // Write header - fs.write(("from,rssi,snr,time,dist\n").toByteArray()) - // Packets are ordered by time, we keep most recent position of - // our device in my_position. - var my_position: MeshProtos.Position? = null - packets.forEach { - it.proto?.let { packet_proto -> - it.position?.let { position -> - if (packet_proto.from == myNodeNum) { - my_position = position - } else if (my_position != null) { - val dist = positionToMeter(my_position!!, position).roundToInt() - fs.write( - "%x,%d,%f,%d,%d\n".format( - packet_proto.from, packet_proto.rxRssi, - packet_proto.rxSnr, packet_proto.rxTime, dist - ).toByteArray() - ) - } - } - } - } - } - } - } - - /// Theme functions private fun chooseThemeDialog() { diff --git a/app/src/main/java/com/geeksville/mesh/NodeInfo.kt b/app/src/main/java/com/geeksville/mesh/NodeInfo.kt index ef72d478e..6b1f8ad68 100644 --- a/app/src/main/java/com/geeksville/mesh/NodeInfo.kt +++ b/app/src/main/java/com/geeksville/mesh/NodeInfo.kt @@ -71,6 +71,13 @@ data class Position( /// @return bearing to the other position in degrees fun bearing(o: Position) = bearing(latitude, longitude, o.latitude, o.longitude) + // If GPS gives a crap position don't crash our app + fun isValid(): Boolean { + return (latitude <= 90.0 && latitude >= -90) && + latitude != 0.0 && + longitude != 0.0 + } + override fun toString(): String { return "Position(lat=${latitude.anonymize}, lon=${longitude.anonymize}, alt=${altitude.anonymize}, time=${time}, batteryPctLevel=${batteryPctLevel})" } @@ -112,11 +119,7 @@ data class NodeInfo( /// return the position if it is valid, else null val validPosition: Position? get() { - return position?.takeIf { - (it.latitude <= 90.0 && it.latitude >= -90) && // If GPS gives a crap position don't crash our app - it.latitude != 0.0 && - it.longitude != 0.0 - } + return position?.takeIf { it.isValid() } } /// @return distance in meters to some other node (or null if unknown) diff --git a/app/src/main/java/com/geeksville/mesh/database/PacketRepository.kt b/app/src/main/java/com/geeksville/mesh/database/PacketRepository.kt index f82240b24..060cc446e 100644 --- a/app/src/main/java/com/geeksville/mesh/database/PacketRepository.kt +++ b/app/src/main/java/com/geeksville/mesh/database/PacketRepository.kt @@ -3,9 +3,11 @@ package com.geeksville.mesh.database import androidx.lifecycle.LiveData import com.geeksville.mesh.database.dao.PacketDao import com.geeksville.mesh.database.entity.Packet +import kotlinx.coroutines.flow.Flow class PacketRepository(private val packetDao : PacketDao) { - val allPackets : LiveData> = packetDao.getAllPacket(500) + val allPackets : LiveData> = packetDao.getAllPacket(MAX_ITEMS) + val allPacketsInReceiveOrder : Flow> = packetDao.getAllPacketsInReceiveOrder(MAX_ITEMS) suspend fun insert(packet: Packet) { packetDao.insert(packet) @@ -14,4 +16,9 @@ class PacketRepository(private val packetDao : PacketDao) { suspend fun deleteAll() { packetDao.deleteAll() } + + companion object { + private const val MAX_ITEMS = 500 + } + } \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/database/dao/PacketDao.kt b/app/src/main/java/com/geeksville/mesh/database/dao/PacketDao.kt index a02016a94..55d33d7b8 100644 --- a/app/src/main/java/com/geeksville/mesh/database/dao/PacketDao.kt +++ b/app/src/main/java/com/geeksville/mesh/database/dao/PacketDao.kt @@ -5,6 +5,7 @@ import androidx.room.Dao import androidx.room.Insert import androidx.room.Query import com.geeksville.mesh.database.entity.Packet +import kotlinx.coroutines.flow.Flow @Dao interface PacketDao { @@ -12,6 +13,9 @@ interface PacketDao { @Query("Select * from packet order by received_date desc limit 0,:maxItem") fun getAllPacket(maxItem: Int): LiveData> + @Query("Select * from packet order by received_date asc limit 0,:maxItem") + fun getAllPacketsInReceiveOrder(maxItem: Int): Flow> + @Insert fun insert(packet: Packet) diff --git a/app/src/main/java/com/geeksville/mesh/model/UIState.kt b/app/src/main/java/com/geeksville/mesh/model/UIState.kt index b33f2bfd3..ec77dd70a 100644 --- a/app/src/main/java/com/geeksville/mesh/model/UIState.kt +++ b/app/src/main/java/com/geeksville/mesh/model/UIState.kt @@ -12,15 +12,21 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import com.geeksville.android.Logging -import com.geeksville.mesh.IMeshService -import com.geeksville.mesh.MyNodeInfo -import com.geeksville.mesh.RadioConfigProtos +import com.geeksville.mesh.* import com.geeksville.mesh.database.MeshtasticDatabase import com.geeksville.mesh.database.PacketRepository import com.geeksville.mesh.database.entity.Packet import com.geeksville.mesh.service.MeshService +import com.geeksville.mesh.ui.positionToMeter import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.BufferedWriter +import java.io.FileWriter +import java.text.SimpleDateFormat +import java.util.* +import kotlin.math.roundToInt /// Given a human name, strip out the first letter of the first three words and return that as the initials for /// that user. If the original name is only one word, strip vowels from the original name and if the result is @@ -257,5 +263,96 @@ class UIViewModel(private val app: Application) : AndroidViewModel(app), Logging errormsg("Can't set username on device, is device offline? ${ex.message}") } } + + /** + * Write the persisted packet data out to a CSV file in the specified location. + */ + fun saveMessagesCSV(file_uri: Uri) { + viewModelScope.launch(Dispatchers.Main) { + // Extract distances to this device from position messages and put (node,SNR,distance) in + // the file_uri + val myNodeNum = myNodeInfo.value?.myNodeNum ?: return@launch + + // Capture the current node value while we're still on main thread + val nodes = nodeDB.nodes.value ?: emptyMap() + + writeToUri(file_uri) { writer -> + // Create a map of nodes keyed by their ID + val nodesById = nodes.values.associateBy { it.num } + + writer.appendLine("date,time,from,sender name,sender lat,sender long,rx lat,rx long,rx elevation,rx snr,distance,hop limit,payload") + + // Packets are ordered by time, we keep most recent position of + // our device in localNodePosition. + var localNodePosition: MeshProtos.Position? = null + val dateFormat = SimpleDateFormat("yyyy-MM-dd,HH:mm:ss", Locale.getDefault()) + repository.allPacketsInReceiveOrder.first().forEach { packet -> + packet.proto?.let { proto -> + packet.position?.let { position -> + if (proto.from == myNodeNum) { + localNodePosition = position + } else { + val rxDateTime = dateFormat.format(packet.received_date) + val rxFrom = proto.from.toUInt() + val senderName = nodesById[proto.from]?.user?.longName ?: "" + + // sender lat & long + val senderPos = packet.position + ?.let { p -> Position(p) } + ?.takeIf { p -> p.isValid() } + val senderLat = senderPos?.latitude ?: "" + val senderLong = senderPos?.longitude ?: "" + + // rx lat, long, and elevation + val rxPos = localNodePosition + ?.let { p -> Position(p) } + ?.takeIf { p -> p.isValid() } + val rxLat = rxPos?.latitude ?: "" + val rxLong = rxPos?.longitude ?: "" + val rxAlt = rxPos?.altitude ?: "" + val rxSnr = "%f".format(proto.rxSnr) + + // Calculate the distance if both positions are valid + val dist = if (senderPos == null || rxPos == null) { + "" + } else { + positionToMeter( + localNodePosition!!, + position + ).roundToInt().toString() + } + + val hopLimit = proto.hopLimit + + val payload = when { + proto.decoded.portnumValue != Portnums.PortNum.TEXT_MESSAGE_APP_VALUE -> "<${proto.decoded.portnum}>" + proto.hasDecoded() -> "\"" + proto.decoded.payload.toStringUtf8() + .replace("\"", "\\\"") + "\"" + proto.hasEncrypted() -> "${proto.encrypted.size()} encrypted bytes" + else -> "" + } + + // date,time,from,sender name,sender lat,sender long,rx lat,rx long,rx elevation,rx snr,distance,hop limit,payload + writer.appendLine("$rxDateTime,$rxFrom,$senderName,$senderLat,$senderLong,$rxLat,$rxLong,$rxAlt,$rxSnr,$dist,$hopLimit,$payload") + } + } + } + } + } + } + } + + private suspend inline fun writeToUri(uri: Uri, crossinline block: suspend (BufferedWriter) -> Unit) { + withContext(Dispatchers.IO) { + app.contentResolver.openFileDescriptor(uri, "wt")?.use { parcelFileDescriptor -> + FileWriter(parcelFileDescriptor.fileDescriptor).use { fileWriter -> + BufferedWriter(fileWriter).use { writer -> + block.invoke(writer) + } + } + } + } + } + } diff --git a/app/src/main/java/com/geeksville/mesh/service/RadioInterfaceService.kt b/app/src/main/java/com/geeksville/mesh/service/RadioInterfaceService.kt index 52f6b57cc..c5f1deabc 100644 --- a/app/src/main/java/com/geeksville/mesh/service/RadioInterfaceService.kt +++ b/app/src/main/java/com/geeksville/mesh/service/RadioInterfaceService.kt @@ -2,6 +2,7 @@ package com.geeksville.mesh.service import android.annotation.SuppressLint import android.app.Service +import android.companion.CompanionDeviceManager import android.content.Context import android.content.Intent import android.content.SharedPreferences @@ -285,6 +286,22 @@ class RadioInterfaceService : Service(), Logging { debug("Setting bonded device to ${address.anonymize}") + // We only keep an association to one device at a time... (move to BluetoothInterface?) + if (BluetoothInterface.hasCompanionDeviceApi(this)) { + if (address != null) { + val deviceManager = getSystemService(CompanionDeviceManager::class.java) + val c = address[0] + val rest = address.substring(1) + + deviceManager.associations.forEach { old -> + if (rest != old) { + debug("Forgetting old BLE association ${old.anonymize}") + deviceManager.disassociate(old) + } + } + } + } + getPrefs(this).edit(commit = true) { if (address == null) this.remove(DEVADDR_KEY) diff --git a/app/src/main/java/com/geeksville/mesh/ui/ChannelFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/ChannelFragment.kt index 73b644550..daf62a33c 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/ChannelFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/ChannelFragment.kt @@ -33,7 +33,6 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar import com.google.protobuf.ByteString import com.google.zxing.integration.android.IntentIntegrator -import java.net.MalformedURLException import java.security.SecureRandom @@ -320,18 +319,8 @@ class ChannelFragment : ScreenFragment("Channel"), Logging { override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { val result = IntentIntegrator.parseActivityResult(requestCode, resultCode, data) if (result != null) { - if (result.contents == null) { - Snackbar.make(binding.scanButton, R.string.channel_invalid, Snackbar.LENGTH_LONG).show() - } else { - try { - val intent = Intent(Intent.ACTION_VIEW) - intent.data = ChannelSet(Uri.parse(result.contents)).getChannelUrl(false) - startActivity(intent) - } catch (ex: ActivityNotFoundException) { - Snackbar.make(binding.scanButton, R.string.channel_invalid, Snackbar.LENGTH_LONG).show() - } catch (ex: MalformedURLException) { - Snackbar.make(binding.scanButton, R.string.channel_invalid, Snackbar.LENGTH_LONG).show() - } + if (result.contents != null) { + ((requireActivity() as MainActivity).perhapsChangeChannel(Uri.parse(result.contents))) } } else { super.onActivityResult(requestCode, resultCode, data) diff --git a/app/src/main/res/drawable/ic_twotone_public_24.xml b/app/src/main/res/drawable/ic_twotone_public_24.xml deleted file mode 100644 index 403051e61..000000000 --- a/app/src/main/res/drawable/ic_twotone_public_24.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - diff --git a/app/src/main/res/layout/channel_fragment.xml b/app/src/main/res/layout/channel_fragment.xml index d916757ca..77b252e7d 100644 --- a/app/src/main/res/layout/channel_fragment.xml +++ b/app/src/main/res/layout/channel_fragment.xml @@ -100,7 +100,6 @@ android:layout_height="wrap_content" android:layout_marginEnd="10dp" android:text="@string/reset" - app:icon="@drawable/ic_twotone_public_24" app:layout_constraintBottom_toBottomOf="@id/bottomButtonsGuideline" app:layout_constraintEnd_toStartOf="@id/editableCheckbox" /> diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 07e362752..664b688ac 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -73,4 +73,18 @@ @style/MyThemeOverlay_Toolbar + +