diff --git a/README.md b/README.md
index fcc04f4d2..87f94d701 100644
--- a/README.md
+++ b/README.md
@@ -14,14 +14,10 @@ The production version of our application is here:
[](https://play.google.com/store/apps/details?id=com.geeksville.mesh&referrer=utm_source%3Dgithub-android-readme)
-But if you want the beta-test app now, we'd love to have your help testing. Three steps to opt-in to the test:
+To join the beta program for the app go to this [URL](https://play.google.com/apps/testing/com.geeksville.mesh) to opt-in to the alpha/beta test.
+If you encounter any problems or have questions, [post in the forum](https://meshtastic.discourse.group/) and we'll help.
-1. Join [this Google group](https://groups.google.com/forum/#!forum/meshtastic-alpha-testers) with the account you use in Google Play. **Optional** - if you just want 'beta builds'
-not bleeding edge alpha test builds skip to the next step.
-2. Go to this [URL](https://play.google.com/apps/testing/com.geeksville.mesh) to opt-in to the alpha/beta test.
-3. If you encounter any problems or have questions, [post in the forum](https://meshtastic.discourse.group/) and we'll help.
-
-The app is also distributed for Amazon Fire devices via the Amazon appstore: [](https://www.amazon.com/Geeksville-Industries-Meshtastic/dp/B08CY9394Q)
+The app is also distributed via F-Droid repo: [https://mesh.tastic.app/fdroid/repo](https://mesh.tastic.app/fdroid/repo)
However, if you must use 'raw' APKs you can get them from our [github releases](https://github.com/meshtastic/Meshtastic-Android/releases). This is not recommended because if you manually install an APK it will not automatically update.
@@ -73,10 +69,6 @@ for verbose logging:
adb shell setprop log.tag.FA VERBOSE
```
-## Publishing to google play
-
-(Only supported if you are a core developer that needs to do releases)
-
# Credits
This project is the work of volunteers:
diff --git a/app/build.gradle b/app/build.gradle
index 7f9fd7ed3..be3a73c25 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -43,8 +43,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 20328 // format is Mmmss (where M is 1+the numeric major number
- versionName "1.3.28"
+ versionCode 20330 // format is Mmmss (where M is 1+the numeric major number
+ versionName "1.3.30"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
// per https://developer.android.com/studio/write/vector-asset-studio
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 6215e4006..67799bf9e 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -140,7 +140,7 @@
@@ -150,11 +150,11 @@
+ android:pathPrefix="/e/" />
+ android:pathPrefix="/E/" />
diff --git a/app/src/main/java/com/geeksville/mesh/MainActivity.kt b/app/src/main/java/com/geeksville/mesh/MainActivity.kt
index f0a899a7b..e38b7d5d7 100644
--- a/app/src/main/java/com/geeksville/mesh/MainActivity.kt
+++ b/app/src/main/java/com/geeksville/mesh/MainActivity.kt
@@ -1080,6 +1080,15 @@ class MainActivity : BaseActivity(), Logging,
R.id.show_intro -> {
startActivity(Intent(this, AppIntroduction::class.java))
return true
+ }
+ R.id.preferences_quick_chat -> {
+ val fragmentManager: FragmentManager = supportFragmentManager
+ val fragmentTransaction: FragmentTransaction = fragmentManager.beginTransaction()
+ val nameFragment = QuickChatSettingsFragment()
+ fragmentTransaction.add(R.id.mainActivityLayout, nameFragment)
+ fragmentTransaction.addToBackStack(null)
+ fragmentTransaction.commit()
+ return true
}
else -> super.onOptionsItemSelected(item)
}
diff --git a/app/src/main/java/com/geeksville/mesh/android/ContextServices.kt b/app/src/main/java/com/geeksville/mesh/android/ContextServices.kt
index d9b9caa0a..3a9ddf10f 100644
--- a/app/src/main/java/com/geeksville/mesh/android/ContextServices.kt
+++ b/app/src/main/java/com/geeksville/mesh/android/ContextServices.kt
@@ -46,6 +46,16 @@ fun Context.hasCompanionDeviceApi(): Boolean =
fun Context.hasGps(): Boolean =
packageManager.hasSystemFeature(PackageManager.FEATURE_LOCATION_GPS)
+/**
+ * return app install source (sideload = null)
+ */
+fun Context.installSource(): String? {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)
+ packageManager.getInstallSourceInfo(packageName).installingPackageName
+ else
+ packageManager.getInstallerPackageName(packageName)
+}
+
/**
* return a list of the permissions we don't have
*/
diff --git a/app/src/main/java/com/geeksville/mesh/database/DatabaseModule.kt b/app/src/main/java/com/geeksville/mesh/database/DatabaseModule.kt
index 1f2732ddf..f849637b7 100644
--- a/app/src/main/java/com/geeksville/mesh/database/DatabaseModule.kt
+++ b/app/src/main/java/com/geeksville/mesh/database/DatabaseModule.kt
@@ -2,6 +2,7 @@ package com.geeksville.mesh.database
import android.app.Application
import com.geeksville.mesh.database.dao.PacketDao
+import com.geeksville.mesh.database.dao.QuickChatActionDao
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@@ -20,4 +21,9 @@ class DatabaseModule {
fun providePacketDao(database: MeshtasticDatabase): PacketDao {
return database.packetDao()
}
+
+ @Provides
+ fun provideQuickChatActionDao(database: MeshtasticDatabase): QuickChatActionDao {
+ return database.quickChatActionDao()
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/com/geeksville/mesh/database/MeshtasticDatabase.kt b/app/src/main/java/com/geeksville/mesh/database/MeshtasticDatabase.kt
index 6d11eb1c3..b49e628f8 100644
--- a/app/src/main/java/com/geeksville/mesh/database/MeshtasticDatabase.kt
+++ b/app/src/main/java/com/geeksville/mesh/database/MeshtasticDatabase.kt
@@ -5,11 +5,14 @@ import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import com.geeksville.mesh.database.dao.PacketDao
+import com.geeksville.mesh.database.dao.QuickChatActionDao
import com.geeksville.mesh.database.entity.Packet
+import com.geeksville.mesh.database.entity.QuickChatAction
-@Database(entities = [Packet::class], version = 1, exportSchema = false)
+@Database(entities = [Packet::class, QuickChatAction::class], version = 2, exportSchema = false)
abstract class MeshtasticDatabase : RoomDatabase() {
abstract fun packetDao(): PacketDao
+ abstract fun quickChatActionDao(): QuickChatActionDao
companion object {
fun getDatabase(context: Context): MeshtasticDatabase {
diff --git a/app/src/main/java/com/geeksville/mesh/database/QuickChatActionRepository.kt b/app/src/main/java/com/geeksville/mesh/database/QuickChatActionRepository.kt
new file mode 100644
index 000000000..0bef1c756
--- /dev/null
+++ b/app/src/main/java/com/geeksville/mesh/database/QuickChatActionRepository.kt
@@ -0,0 +1,38 @@
+package com.geeksville.mesh.database
+
+import com.geeksville.mesh.database.dao.QuickChatActionDao
+import com.geeksville.mesh.database.entity.QuickChatAction
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.withContext
+import javax.inject.Inject
+
+class QuickChatActionRepository @Inject constructor(private val quickChatDaoLazy: dagger.Lazy) {
+ private val quickChatActionDao by lazy {
+ quickChatDaoLazy.get()
+ }
+
+ suspend fun getAllActions(): Flow> = withContext(Dispatchers.IO) {
+ quickChatActionDao.getAll()
+ }
+
+ suspend fun insert(action: QuickChatAction) = withContext(Dispatchers.IO) {
+ quickChatActionDao.insert(action)
+ }
+
+ suspend fun deleteAll() = withContext(Dispatchers.IO) {
+ quickChatActionDao.deleteAll()
+ }
+
+ suspend fun delete(action: QuickChatAction) = withContext(Dispatchers.IO) {
+ quickChatActionDao.delete(action)
+ }
+
+ suspend fun update(action: QuickChatAction) = withContext(Dispatchers.IO) {
+ quickChatActionDao.update(action)
+ }
+
+ suspend fun setItemPosition(uuid: Long, newPos: Int) = withContext(Dispatchers.IO) {
+ quickChatActionDao.updateActionPosition(uuid, newPos)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/geeksville/mesh/database/dao/QuickChatActionDao.kt b/app/src/main/java/com/geeksville/mesh/database/dao/QuickChatActionDao.kt
new file mode 100644
index 000000000..8af931323
--- /dev/null
+++ b/app/src/main/java/com/geeksville/mesh/database/dao/QuickChatActionDao.kt
@@ -0,0 +1,37 @@
+package com.geeksville.mesh.database.dao
+
+import androidx.room.*
+import com.geeksville.mesh.database.entity.QuickChatAction
+import kotlinx.coroutines.flow.Flow
+
+@Dao
+interface QuickChatActionDao {
+
+ @Query("Select * from quick_chat order by position asc")
+ fun getAll(): Flow>
+
+ @Insert
+ fun insert(action: QuickChatAction)
+
+ @Query("Delete from quick_chat")
+ fun deleteAll()
+
+ @Query("Delete from quick_chat where uuid=:uuid")
+ fun _delete(uuid: Long)
+
+ @Transaction
+ fun delete(action: QuickChatAction) {
+ _delete(action.uuid)
+ decrementPositionsAfter(action.position)
+ }
+
+ @Update
+ fun update(action: QuickChatAction)
+
+ @Query("Update quick_chat set position=:position WHERE uuid=:uuid")
+ fun updateActionPosition(uuid: Long, position: Int)
+
+ @Query("Update quick_chat set position=position-1 where position>=:position")
+ fun decrementPositionsAfter(position: Int)
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/geeksville/mesh/database/entity/QuickChatAction.kt b/app/src/main/java/com/geeksville/mesh/database/entity/QuickChatAction.kt
new file mode 100644
index 000000000..f9cc237f6
--- /dev/null
+++ b/app/src/main/java/com/geeksville/mesh/database/entity/QuickChatAction.kt
@@ -0,0 +1,19 @@
+package com.geeksville.mesh.database.entity
+
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.Index
+import androidx.room.PrimaryKey
+
+@Entity(tableName = "quick_chat")
+data class QuickChatAction(
+ @PrimaryKey(autoGenerate = true) val uuid: Long,
+ @ColumnInfo(name="name") val name: String,
+ @ColumnInfo(name="message") val message: String,
+ @ColumnInfo(name="mode") val mode: Mode,
+ @ColumnInfo(name="position") val position: Int) {
+ enum class Mode {
+ Append,
+ Instant,
+ }
+}
\ No newline at end of file
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 a0c620751..daf27fa2d 100644
--- a/app/src/main/java/com/geeksville/mesh/model/UIState.kt
+++ b/app/src/main/java/com/geeksville/mesh/model/UIState.kt
@@ -14,11 +14,14 @@ import androidx.lifecycle.viewModelScope
import com.geeksville.android.Logging
import com.geeksville.mesh.*
import com.geeksville.mesh.database.PacketRepository
+import com.geeksville.mesh.database.QuickChatActionRepository
import com.geeksville.mesh.database.entity.Packet
+import com.geeksville.mesh.database.entity.QuickChatAction
import com.geeksville.mesh.repository.datastore.LocalConfigRepository
import com.geeksville.mesh.service.MeshService
import com.geeksville.mesh.util.positionToMeter
import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -61,6 +64,7 @@ class UIViewModel @Inject constructor(
private val app: Application,
private val packetRepository: PacketRepository,
private val localConfigRepository: LocalConfigRepository,
+ private val quickChatActionRepository: QuickChatActionRepository,
private val preferences: SharedPreferences
) : ViewModel(), Logging {
@@ -70,6 +74,12 @@ class UIViewModel @Inject constructor(
private val _localConfig = MutableLiveData()
val localConfig: LiveData get() = _localConfig
+ private val _quickChatActions =
+ MutableStateFlow>(
+ emptyList()
+ )
+ val quickChatActions: StateFlow> = _quickChatActions
+
init {
viewModelScope.launch {
packetRepository.getAllPackets().collect { packets ->
@@ -81,6 +91,11 @@ class UIViewModel @Inject constructor(
_localConfig.value = config
}
}
+ viewModelScope.launch {
+ quickChatActionRepository.getAllActions().collect { actions ->
+ _quickChatActions.value = actions
+ }
+ }
debug("ViewModel created")
}
@@ -445,5 +460,43 @@ class UIViewModel @Inject constructor(
}
}
+ fun addQuickChatAction(name: String, value: String, mode: QuickChatAction.Mode) {
+ viewModelScope.launch(Dispatchers.Main) {
+ val action = QuickChatAction(0, name, value, mode, _quickChatActions.value.size)
+ quickChatActionRepository.insert(action)
+ }
+ }
+
+ fun deleteQuickChatAction(action: QuickChatAction) {
+ viewModelScope.launch(Dispatchers.Main) {
+ quickChatActionRepository.delete(action)
+ }
+ }
+
+ fun updateQuickChatAction(
+ action: QuickChatAction,
+ name: String?,
+ message: String?,
+ mode: QuickChatAction.Mode?
+ ) {
+ viewModelScope.launch(Dispatchers.Main) {
+ val newAction = QuickChatAction(
+ action.uuid,
+ name ?: action.name,
+ message ?: action.message,
+ mode ?: action.mode,
+ action.position
+ )
+ quickChatActionRepository.update(newAction)
+ }
+ }
+
+ fun updateActionPositions(actions: List) {
+ viewModelScope.launch(Dispatchers.Main) {
+ for (position in actions.indices) {
+ quickChatActionRepository.setItemPosition(actions[position].uuid, position)
+ }
+ }
+ }
}
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt
index 3ff6b3f8e..bb8ff8bfc 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt
@@ -1290,7 +1290,7 @@ class MeshService : Service(), Logging {
private fun fixupChannelList(lIn: List): Array {
// When updating old firmware, we will briefly be told that there is zero channels
val maxChannels =
- max(myNodeInfo?.maxChannels ?: 10, 10) // If we don't have my node info, assume 10 channels
+ max(myNodeInfo?.maxChannels ?: 8, 8) // If we don't have my node info, assume 8 channels (source: apponly.options)
val l = lIn.toMutableList()
while (l.size < maxChannels) {
val b = ChannelProtos.Channel.newBuilder()
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 231d0b523..efebd43dd 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/ChannelFragment.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/ChannelFragment.kt
@@ -19,13 +19,13 @@ import com.geeksville.analytics.DataPair
import com.geeksville.android.GeeksvilleApplication
import com.geeksville.android.Logging
import com.geeksville.android.hideKeyboard
-import com.geeksville.android.isGooglePlayAvailable
import com.geeksville.mesh.AppOnlyProtos
import com.geeksville.mesh.ChannelProtos
import com.geeksville.mesh.ConfigProtos
import com.geeksville.mesh.R
import com.geeksville.mesh.android.getCameraPermissions
import com.geeksville.mesh.android.hasCameraPermission
+import com.geeksville.mesh.android.installSource
import com.geeksville.mesh.databinding.ChannelFragmentBinding
import com.geeksville.mesh.model.Channel
import com.geeksville.mesh.model.ChannelOption
@@ -68,17 +68,6 @@ class ChannelFragment : ScreenFragment("Channel"), Logging {
private val model: UIViewModel by activityViewModels()
- private val requestPermissionAndScanLauncher =
- registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions ->
- if (permissions.entries.all { it.value }) zxingScan()
- }
-
- private val barcodeLauncher = registerForActivityResult(ScanContract()) { result ->
- if (result.contents != null) {
- model.setRequestChannelUrl(Uri.parse(result.contents))
- }
- }
-
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
@@ -212,54 +201,61 @@ class ChannelFragment : ScreenFragment("Channel"), Logging {
}
}
- private fun zxingScan() {
- debug("Starting zxing QR code scanner")
- val zxingScan = ScanOptions()
- zxingScan.setCameraId(0)
- zxingScan.setPrompt("")
- zxingScan.setBeepEnabled(false)
- zxingScan.setDesiredBarcodeFormats(ScanOptions.QR_CODE)
- barcodeLauncher.launch(zxingScan)
- }
-
- private fun requestPermissionAndScan() {
- MaterialAlertDialogBuilder(requireContext())
- .setTitle(R.string.camera_required)
- .setMessage(R.string.why_camera_required)
- .setNeutralButton(R.string.cancel) { _, _ ->
- debug("Camera permission denied")
- }
- .setPositiveButton(getString(R.string.accept)) { _, _ ->
- requestPermissionAndScanLauncher.launch(requireContext().getCameraPermissions())
- }
- .show()
- }
-
- private fun mlkitScan() {
- debug("Starting ML Kit QR code scanner")
- val options = GmsBarcodeScannerOptions.Builder()
- .setBarcodeFormats(
- Barcode.FORMAT_QR_CODE
- )
- .build()
- val scanner = GmsBarcodeScanning.getClient(requireContext(), options)
- scanner.startScan()
- .addOnSuccessListener { barcode ->
- if (barcode.rawValue != null)
- model.setRequestChannelUrl(Uri.parse(barcode.rawValue))
- }
- .addOnFailureListener {
- Snackbar.make(
- requireView(),
- R.string.channel_invalid,
- Snackbar.LENGTH_SHORT
- ).show()
- }
- }
-
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
+ val barcodeLauncher = registerForActivityResult(ScanContract()) { result ->
+ if (result.contents != null) {
+ model.setRequestChannelUrl(Uri.parse(result.contents))
+ }
+ }
+
+ fun zxingScan() {
+ debug("Starting zxing QR code scanner")
+ val zxingScan = ScanOptions()
+ zxingScan.setCameraId(0)
+ zxingScan.setPrompt("")
+ zxingScan.setBeepEnabled(false)
+ zxingScan.setDesiredBarcodeFormats(ScanOptions.QR_CODE)
+ barcodeLauncher.launch(zxingScan)
+ }
+
+ val requestPermissionAndScanLauncher =
+ registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions ->
+ if (permissions.entries.all { it.value }) zxingScan()
+ }
+
+ fun requestPermissionAndScan() {
+ MaterialAlertDialogBuilder(requireContext())
+ .setTitle(R.string.camera_required)
+ .setMessage(R.string.why_camera_required)
+ .setNeutralButton(R.string.cancel) { _, _ ->
+ debug("Camera permission denied")
+ }
+ .setPositiveButton(getString(R.string.accept)) { _, _ ->
+ requestPermissionAndScanLauncher.launch(requireContext().getCameraPermissions())
+ }
+ .show()
+ }
+
+ fun mlkitScan() {
+ debug("Starting ML Kit code scanner")
+ val options = GmsBarcodeScannerOptions.Builder()
+ .setBarcodeFormats(Barcode.FORMAT_QR_CODE)
+ .build()
+ val scanner = GmsBarcodeScanning.getClient(requireContext(), options)
+ scanner.startScan()
+ .addOnSuccessListener { barcode ->
+ if (barcode.rawValue != null)
+ model.setRequestChannelUrl(Uri.parse(barcode.rawValue))
+ }
+ .addOnFailureListener { ex ->
+ errormsg("code scanner failed: ${ex.message}")
+ if (requireContext().hasCameraPermission()) zxingScan()
+ else requestPermissionAndScan()
+ }
+ }
+
binding.channelNameEdit.on(EditorInfo.IME_ACTION_DONE) {
requireActivity().hideKeyboard()
}
@@ -283,14 +279,12 @@ class ChannelFragment : ScreenFragment("Channel"), Logging {
}
binding.scanButton.setOnClickListener {
- if (isGooglePlayAvailable(requireContext())) {
+ // only use ML Kit for play store installs
+ if (requireContext().installSource() == "com.android.vending") {
mlkitScan()
} else {
- if (requireContext().hasCameraPermission()) {
- zxingScan()
- } else {
- requestPermissionAndScan()
- }
+ if (requireContext().hasCameraPermission()) zxingScan()
+ else requestPermissionAndScan()
}
}
diff --git a/app/src/main/java/com/geeksville/mesh/ui/DragManageAdapter.kt b/app/src/main/java/com/geeksville/mesh/ui/DragManageAdapter.kt
new file mode 100644
index 000000000..913fa8a51
--- /dev/null
+++ b/app/src/main/java/com/geeksville/mesh/ui/DragManageAdapter.kt
@@ -0,0 +1,33 @@
+package com.geeksville.mesh.ui
+
+import androidx.recyclerview.widget.ItemTouchHelper
+import androidx.recyclerview.widget.RecyclerView
+
+class DragManageAdapter(var adapter: SwapAdapter, dragDirs: Int, swipeDirs: Int) :
+ ItemTouchHelper.SimpleCallback(dragDirs, swipeDirs) {
+ interface SwapAdapter {
+ fun swapItems(fromPosition: Int, toPosition: Int)
+ fun commitSwaps()
+ }
+
+ override fun onMove(
+ recyclerView: RecyclerView,
+ viewHolder: RecyclerView.ViewHolder,
+ target: RecyclerView.ViewHolder
+ ): Boolean {
+ adapter.swapItems(viewHolder.absoluteAdapterPosition, target.absoluteAdapterPosition)
+ return true
+ }
+
+ override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) {
+ super.onSelectedChanged(viewHolder, actionState)
+
+ if (actionState == ItemTouchHelper.ACTION_STATE_IDLE) {
+ adapter.commitSwaps()
+ }
+ }
+
+ override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
+ TODO("Not yet implemented")
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/geeksville/mesh/ui/MessagesFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/MessagesFragment.kt
index e925032ff..a6566ca72 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/MessagesFragment.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/MessagesFragment.kt
@@ -3,25 +3,25 @@ package com.geeksville.mesh.ui
import android.graphics.Color
import android.graphics.drawable.GradientDrawable
import android.os.Bundle
-import android.text.InputType
import android.view.*
import android.view.inputmethod.EditorInfo
-import android.widget.EditText
-import android.widget.ImageView
-import android.widget.TextView
+import android.widget.*
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ActionMode
import androidx.cardview.widget.CardView
import androidx.core.content.ContextCompat
+import androidx.core.view.allViews
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.setFragmentResultListener
+import androidx.lifecycle.asLiveData
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.geeksville.android.Logging
import com.geeksville.mesh.DataPacket
import com.geeksville.mesh.MessageStatus
import com.geeksville.mesh.R
+import com.geeksville.mesh.database.entity.QuickChatAction
import com.geeksville.mesh.databinding.AdapterMessageLayoutBinding
import com.geeksville.mesh.databinding.MessagesFragmentBinding
import com.geeksville.mesh.model.UIViewModel
@@ -57,6 +57,8 @@ class MessagesFragment : Fragment(), Logging {
private val model: UIViewModel by activityViewModels()
+ private var isConnected = false
+
// Allows textMultiline with IME_ACTION_SEND
private fun EditText.onActionSend(func: () -> Unit) {
setOnEditorActionListener { _, actionId, _ ->
@@ -293,9 +295,45 @@ class MessagesFragment : Fragment(), Logging {
// If connection state _OR_ myID changes we have to fix our ability to edit outgoing messages
model.connectionState.observe(viewLifecycleOwner) { connectionState ->
// If we don't know our node ID and we are offline don't let user try to send
- val connected = connectionState == MeshService.ConnectionState.CONNECTED
- binding.textInputLayout.isEnabled = connected
- binding.sendButton.isEnabled = connected
+ isConnected = connectionState == MeshService.ConnectionState.CONNECTED
+ binding.textInputLayout.isEnabled = isConnected
+ binding.sendButton.isEnabled = isConnected
+ for (subView: View in binding.quickChatLayout.allViews) {
+ if (subView is Button) {
+ subView.isEnabled = isConnected
+ }
+ }
+ }
+
+ model.quickChatActions.asLiveData().observe(viewLifecycleOwner) { actions ->
+ actions?.let {
+ // This seems kinda hacky it might be better to replace with a recycler view
+ binding.quickChatLayout.removeAllViews()
+ for (action in actions) {
+ val button = Button(context)
+ button.setText(action.name)
+ button.isEnabled = isConnected
+ if (action.mode == QuickChatAction.Mode.Instant) {
+ button.backgroundTintList = ContextCompat.getColorStateList(requireActivity(), R.color.colorMyMsg)
+ }
+ button.setOnClickListener {
+ if (action.mode == QuickChatAction.Mode.Append) {
+ val originalText = binding.messageInputText.text ?: ""
+ val needsSpace = !originalText.endsWith(' ') && originalText.isNotEmpty()
+ val newText = buildString {
+ append(originalText)
+ if (needsSpace) append(' ')
+ append(action.message)
+ }
+ binding.messageInputText.setText(newText)
+ binding.messageInputText.setSelection(newText.length)
+ } else {
+ model.messagesState.sendMessage(action.message, contactId)
+ }
+ }
+ binding.quickChatLayout.addView(button)
+ }
+ }
}
}
diff --git a/app/src/main/java/com/geeksville/mesh/ui/QuickChatActionAdapter.kt b/app/src/main/java/com/geeksville/mesh/ui/QuickChatActionAdapter.kt
new file mode 100644
index 000000000..dd60d5b0f
--- /dev/null
+++ b/app/src/main/java/com/geeksville/mesh/ui/QuickChatActionAdapter.kt
@@ -0,0 +1,68 @@
+package com.geeksville.mesh.ui
+
+import android.content.Context
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.TextView
+import androidx.core.content.ContextCompat
+import androidx.recyclerview.widget.RecyclerView
+import com.geeksville.mesh.R
+import com.geeksville.mesh.database.entity.QuickChatAction
+
+class QuickChatActionAdapter internal constructor(
+ private val context: Context,
+ private val onEdit: (action: QuickChatAction) -> Unit,
+ private val repositionAction: (fromPos: Int, toPos: Int) -> Unit,
+ private val commitAction: () -> Unit,
+) : RecyclerView.Adapter(), DragManageAdapter.SwapAdapter {
+
+ private val inflater: LayoutInflater = LayoutInflater.from(context)
+ private var actions = emptyList()
+
+ inner class ActionViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
+ val container: View = itemView.findViewById(R.id.quickChatActionContainer)
+ val actionName: TextView = itemView.findViewById(R.id.quickChatActionName)
+ val actionValue: TextView = itemView.findViewById(R.id.quickChatActionValue)
+ val actionEdit: View = itemView.findViewById(R.id.quickChatActionEdit)
+ val actionInstant: View = itemView.findViewById(R.id.quickChatActionInstant)
+ }
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ActionViewHolder {
+ val itemView = inflater.inflate(R.layout.adapter_quick_chat_action_layout, parent, false)
+ return ActionViewHolder(itemView)
+ }
+
+ override fun onBindViewHolder(holder: ActionViewHolder, position: Int) {
+ val current = actions[position]
+ holder.actionName.text = current.name
+ holder.actionValue.text = current.message
+ val isInstant = current.mode == QuickChatAction.Mode.Instant
+ holder.actionInstant.visibility = if (isInstant) View.VISIBLE else View.INVISIBLE
+ if (isInstant) {
+ holder.container.backgroundTintList = ContextCompat.getColorStateList(context, R.color.colorMyMsg)
+ } else {
+ holder.container.backgroundTintList = null
+ }
+ holder.actionEdit.setOnClickListener {
+ onEdit(current)
+ }
+ }
+
+ internal fun setActions(actions: List) {
+ this.actions = actions
+ notifyDataSetChanged()
+ }
+
+ override fun getItemCount() = actions.size
+
+ override fun swapItems(fromPosition: Int, toPosition: Int) {
+ repositionAction(fromPosition, toPosition)
+ notifyItemMoved(fromPosition, toPosition)
+ }
+
+ override fun commitSwaps() {
+ commitAction()
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/geeksville/mesh/ui/QuickChatSettingsFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/QuickChatSettingsFragment.kt
new file mode 100644
index 000000000..56c53ed33
--- /dev/null
+++ b/app/src/main/java/com/geeksville/mesh/ui/QuickChatSettingsFragment.kt
@@ -0,0 +1,175 @@
+package com.geeksville.mesh.ui
+
+import android.content.Context
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.EditText
+import android.widget.ImageView
+import androidx.core.content.ContextCompat
+import androidx.core.view.isVisible
+import androidx.core.widget.addTextChangedListener
+import androidx.fragment.app.activityViewModels
+import androidx.lifecycle.asLiveData
+import androidx.recyclerview.widget.ItemTouchHelper
+import androidx.recyclerview.widget.LinearLayoutManager
+import com.geeksville.android.Logging
+import com.geeksville.mesh.R
+import com.geeksville.mesh.database.entity.QuickChatAction
+import com.geeksville.mesh.databinding.QuickChatSettingsFragmentBinding
+import com.geeksville.mesh.model.UIViewModel
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import com.google.android.material.switchmaterial.SwitchMaterial
+import dagger.hilt.android.AndroidEntryPoint
+import java.util.*
+
+@AndroidEntryPoint
+class QuickChatSettingsFragment : ScreenFragment("Quick Chat settings"), Logging {
+ private var _binding: QuickChatSettingsFragmentBinding? = null
+
+ private val binding get() = _binding!!
+
+ private val model: UIViewModel by activityViewModels()
+
+ private lateinit var actions: List
+
+ override fun onCreateView(
+ inflater: LayoutInflater, container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ _binding = QuickChatSettingsFragmentBinding.inflate(inflater, container, false)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ binding.quickChatSettingsCreateButton.setOnClickListener {
+ val builder = createEditDialog(requireContext(), "New quick chat")
+
+ builder.builder.setPositiveButton("Add") { view, x ->
+
+ val name = builder.nameInput.text.toString().trim()
+ val message = builder.messageInput.text.toString()
+ if (builder.isNotEmpty())
+ model.addQuickChatAction(
+ name, message,
+ if (builder.modeSwitch.isChecked) QuickChatAction.Mode.Instant else QuickChatAction.Mode.Append
+ )
+ }
+
+ val dialog = builder.builder.create()
+ dialog.show()
+ }
+
+ val quickChatActionAdapter =
+ QuickChatActionAdapter(requireContext(), { action: QuickChatAction ->
+ val builder = createEditDialog(requireContext(), "Edit quick chat")
+ builder.nameInput.setText(action.name)
+ builder.messageInput.setText(action.message)
+ val isInstant = action.mode == QuickChatAction.Mode.Instant
+ builder.modeSwitch.isChecked = isInstant
+ builder.instantImage.visibility = if (isInstant) View.VISIBLE else View.INVISIBLE
+
+ builder.builder.setNegativeButton(R.string.delete) { _, _ ->
+ model.deleteQuickChatAction(action)
+ }
+ builder.builder.setPositiveButton(R.string.save_btn) { _, _ ->
+ if (builder.isNotEmpty()) {
+ model.updateQuickChatAction(
+ action,
+ builder.nameInput.text.toString(),
+ builder.messageInput.text.toString(),
+ if (builder.modeSwitch.isChecked) QuickChatAction.Mode.Instant else QuickChatAction.Mode.Append
+ )
+ }
+ }
+ val dialog = builder.builder.create()
+ dialog.show()
+ }, { fromPos, toPos ->
+ Collections.swap(actions, fromPos, toPos)
+ }, {
+ model.updateActionPositions(actions)
+ })
+
+ val dragCallback =
+ DragManageAdapter(quickChatActionAdapter, ItemTouchHelper.UP or ItemTouchHelper.DOWN, 0)
+ val helper = ItemTouchHelper(dragCallback)
+
+ binding.quickChatSettingsView.apply {
+ this.layoutManager = LinearLayoutManager(requireContext())
+ this.adapter = quickChatActionAdapter
+ helper.attachToRecyclerView(this)
+ }
+
+ model.quickChatActions.asLiveData().observe(viewLifecycleOwner) { actions ->
+ actions?.let {
+ quickChatActionAdapter.setActions(actions)
+ this.actions = actions
+ }
+ }
+ }
+
+ data class DialogBuilder(
+ val builder: MaterialAlertDialogBuilder,
+ val nameInput: EditText,
+ val messageInput: EditText,
+ val modeSwitch: SwitchMaterial,
+ val instantImage: ImageView
+ ) {
+ fun isNotEmpty(): Boolean = nameInput.text.isNotEmpty() and messageInput.text.isNotEmpty()
+ }
+
+ private fun getMessageName(message: String): String {
+ return if (message.length <= 3) {
+ message.uppercase()
+ } else {
+ buildString {
+ append(message.first().uppercase())
+ append(message[message.length / 2].uppercase())
+ append(message.last().uppercase())
+ }
+ }
+ }
+
+ private fun createEditDialog(context: Context, title: String): DialogBuilder {
+ val builder = MaterialAlertDialogBuilder(context)
+ builder.setTitle(title)
+
+ val layout =
+ LayoutInflater.from(requireContext()).inflate(R.layout.dialog_add_quick_chat, null)
+
+ val nameInput: EditText = layout.findViewById(R.id.addQuickChatName)
+ val messageInput: EditText = layout.findViewById(R.id.addQuickChatMessage)
+ val modeSwitch: SwitchMaterial = layout.findViewById(R.id.addQuickChatMode)
+ val instantImage: ImageView = layout.findViewById(R.id.addQuickChatInsant)
+ instantImage.visibility = if (modeSwitch.isChecked) View.VISIBLE else View.INVISIBLE
+
+ var nameHasChanged = false
+
+ modeSwitch.setOnCheckedChangeListener { _, _ ->
+ if (modeSwitch.isChecked) {
+ modeSwitch.setText(R.string.mode_instant)
+ instantImage.visibility = View.VISIBLE
+ } else {
+ modeSwitch.setText(R.string.mode_append)
+ instantImage.visibility = View.INVISIBLE
+ }
+ }
+
+ messageInput.addTextChangedListener { text ->
+ if (!nameHasChanged) {
+ nameInput.setText(getMessageName(text.toString()))
+ }
+ }
+
+ nameInput.addTextChangedListener {
+ if (nameInput.isFocused) nameHasChanged = true
+ }
+
+ builder.setView(layout)
+
+ return DialogBuilder(builder, nameInput, messageInput, modeSwitch, instantImage)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt
index b3594e36c..3326883c6 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/SettingsFragment.kt
@@ -61,40 +61,6 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
private val myActivity get() = requireActivity() as MainActivity
- private val associationResultLauncher = registerForActivityResult(
- ActivityResultContracts.StartIntentSenderForResult()
- ) {
- it.data
- ?.getParcelableExtra(CompanionDeviceManager.EXTRA_DEVICE)
- ?.let { device ->
- scanModel.onSelected(
- myActivity,
- BTScanModel.DeviceListEntry(
- device.name,
- "x${device.address}",
- device.bondState == BluetoothDevice.BOND_BONDED
- )
- )
- }
- }
-
- private val requestLocationAndBackgroundLauncher =
- registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions ->
- if (permissions.entries.all { it.value }) {
- // Older versions of android only need Location permission
- if (myActivity.hasBackgroundPermission()) {
- binding.provideLocationCheckbox.isChecked = true
- } else requestBackgroundAndCheckLauncher.launch(myActivity.getBackgroundPermissions())
- }
- }
-
- private val requestBackgroundAndCheckLauncher =
- registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions ->
- if (permissions.entries.all { it.value }) {
- binding.provideLocationCheckbox.isChecked = true
- }
- }
-
private fun doFirmwareUpdate() {
model.meshService?.let { service ->
@@ -257,6 +223,41 @@ class SettingsFragment : ScreenFragment("Settings"), Logging {
}.sorted()
private fun initCommonUI() {
+
+ val associationResultLauncher = registerForActivityResult(
+ ActivityResultContracts.StartIntentSenderForResult()
+ ) {
+ it.data
+ ?.getParcelableExtra(CompanionDeviceManager.EXTRA_DEVICE)
+ ?.let { device ->
+ scanModel.onSelected(
+ myActivity,
+ BTScanModel.DeviceListEntry(
+ device.name,
+ "x${device.address}",
+ device.bondState == BluetoothDevice.BOND_BONDED
+ )
+ )
+ }
+ }
+
+ val requestBackgroundAndCheckLauncher =
+ registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions ->
+ if (permissions.entries.all { it.value }) {
+ binding.provideLocationCheckbox.isChecked = true
+ }
+ }
+
+ val requestLocationAndBackgroundLauncher =
+ registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions ->
+ if (permissions.entries.all { it.value }) {
+ // Older versions of android only need Location permission
+ if (myActivity.hasBackgroundPermission()) {
+ binding.provideLocationCheckbox.isChecked = true
+ } else requestBackgroundAndCheckLauncher.launch(myActivity.getBackgroundPermissions())
+ }
+ }
+
// init our region spinner
val spinner = binding.regionSpinner
val regionAdapter =
diff --git a/app/src/main/proto b/app/src/main/proto
index 11d94c9b1..cb2fb77bd 160000
--- a/app/src/main/proto
+++ b/app/src/main/proto
@@ -1 +1 @@
-Subproject commit 11d94c9b15e9085b0f2516735ad816a3a35d5680
+Subproject commit cb2fb77bd8c2751e82b1017cd7545789a037fb7d
diff --git a/app/src/main/res/drawable/ic_baseline_drag_handle_24.xml b/app/src/main/res/drawable/ic_baseline_drag_handle_24.xml
new file mode 100644
index 000000000..e13f29fd3
--- /dev/null
+++ b/app/src/main/res/drawable/ic_baseline_drag_handle_24.xml
@@ -0,0 +1,5 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_baseline_edit_24.xml b/app/src/main/res/drawable/ic_baseline_edit_24.xml
new file mode 100644
index 000000000..2cda9c112
--- /dev/null
+++ b/app/src/main/res/drawable/ic_baseline_edit_24.xml
@@ -0,0 +1,5 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_baseline_fast_forward_24.xml b/app/src/main/res/drawable/ic_baseline_fast_forward_24.xml
new file mode 100644
index 000000000..ac50e268d
--- /dev/null
+++ b/app/src/main/res/drawable/ic_baseline_fast_forward_24.xml
@@ -0,0 +1,5 @@
+
+
+
diff --git a/app/src/main/res/layout/adapter_quick_chat_action_layout.xml b/app/src/main/res/layout/adapter_quick_chat_action_layout.xml
new file mode 100644
index 000000000..d37bae544
--- /dev/null
+++ b/app/src/main/res/layout/adapter_quick_chat_action_layout.xml
@@ -0,0 +1,73 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/dialog_add_quick_chat.xml b/app/src/main/res/layout/dialog_add_quick_chat.xml
new file mode 100644
index 000000000..7a7521127
--- /dev/null
+++ b/app/src/main/res/layout/dialog_add_quick_chat.xml
@@ -0,0 +1,65 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/messages_fragment.xml b/app/src/main/res/layout/messages_fragment.xml
index 5cbbd5b92..db6c22f67 100644
--- a/app/src/main/res/layout/messages_fragment.xml
+++ b/app/src/main/res/layout/messages_fragment.xml
@@ -1,6 +1,7 @@
@@ -37,10 +38,30 @@
android:layout_height="0dp"
android:layout_margin="8dp"
android:contentDescription="@string/text_messages"
- app:layout_constraintBottom_toTopOf="@+id/textInputLayout"
+ app:layout_constraintBottom_toTopOf="@+id/quickChatView"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toBottomOf="@id/toolbar" />
+ app:layout_constraintTop_toBottomOf="@id/toolbar" >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/menu/menu_main.xml b/app/src/main/res/menu/menu_main.xml
index 10879e10e..12ba21d62 100644
--- a/app/src/main/res/menu/menu_main.xml
+++ b/app/src/main/res/menu/menu_main.xml
@@ -40,6 +40,10 @@
android:id="@+id/show_intro"
android:title="@string/show_intro"
app:showAsAction="withText" />
+
- Resend
Shutdown
Reboot
-
Hello blank fragment
Show Introduction
Welcome to Meshtastic
@@ -153,4 +152,7 @@
Connect your meshtastic device by using either Bluetooth, Serial or WiFi. \n\nYou can see which devices are compatible at www.meshtastic.org/docs/hardware
"Setting up encryption"
As standard, a default encryption key is set. To enable your own channel and enhanced encryption, go to the channel tab and change the channel name, this will set a random key for AES256 encryption. \n\nTo communicate with other devices they will need to scan your QR code or follow the shared link to configure the channel settings.
+ Message
+ Append to message
+ Instantly send