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: [![Download at https://play.google.com/store/apps/details?id=com.geeksville.mesh](https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png)](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: [![Amazon appstore link](https://raw.githubusercontent.com/meshtastic/Meshtastic-device/master/images/amazon-fire-button.png)](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