refactor: migrate to Compose navigation (#1835)

Co-authored-by: andrekir <andrekir@pm.me>
This commit is contained in:
James Rich 2025-05-15 08:05:30 -05:00 committed by GitHub
parent 79c77ab1d5
commit 8cde47bdf9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
74 changed files with 2576 additions and 3427 deletions

View file

@ -17,9 +17,9 @@
package com.geeksville.mesh
import android.app.Activity
import android.app.PendingIntent
import android.app.TaskStackBuilder
import android.bluetooth.BluetoothAdapter
import android.content.Context
import android.content.Intent
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
@ -27,34 +27,26 @@ import android.hardware.usb.UsbManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.RemoteException
import android.provider.Settings
import android.text.Html
import android.text.method.LinkMovementMethod
import android.view.Menu
import android.view.MenuItem
import android.view.MotionEvent
import android.widget.TextView
import android.widget.Toast
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.annotation.StringRes
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import androidx.appcompat.widget.Toolbar
import androidx.compose.runtime.getValue
import androidx.core.content.ContextCompat
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.ui.Modifier
import androidx.core.content.edit
import androidx.core.net.toUri
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.setPadding
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.FragmentTransaction
import androidx.lifecycle.asLiveData
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.viewpager2.adapter.FragmentStateAdapter
import com.geeksville.mesh.android.BindFailedException
import com.geeksville.mesh.android.GeeksvilleApplication
import com.geeksville.mesh.android.Logging
@ -68,96 +60,30 @@ import com.geeksville.mesh.android.permissionMissing
import com.geeksville.mesh.android.rationaleDialog
import com.geeksville.mesh.android.shouldShowRequestPermissionRationale
import com.geeksville.mesh.concurrent.handledLaunch
import com.geeksville.mesh.databinding.ActivityMainBinding
import com.geeksville.mesh.model.BluetoothViewModel
import com.geeksville.mesh.model.DeviceVersion
import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.navigation.navigateToNavGraph
import com.geeksville.mesh.navigation.DEEP_LINK_BASE_URI
import com.geeksville.mesh.service.MeshService
import com.geeksville.mesh.service.MeshServiceNotifications
import com.geeksville.mesh.service.ServiceRepository
import com.geeksville.mesh.service.startService
import com.geeksville.mesh.ui.ChannelFragment
import com.geeksville.mesh.ui.ContactsFragment
import com.geeksville.mesh.ui.DebugFragment
import com.geeksville.mesh.ui.QuickChatSettingsFragment
import com.geeksville.mesh.ui.SettingsFragment
import com.geeksville.mesh.ui.UsersFragment
import com.geeksville.mesh.ui.components.ScannedQrCodeDialog
import com.geeksville.mesh.ui.map.MapFragment
import com.geeksville.mesh.ui.message.navigateToMessages
import com.geeksville.mesh.ui.navigateToShareMessage
import com.geeksville.mesh.ui.MainMenuAction
import com.geeksville.mesh.ui.MainScreen
import com.geeksville.mesh.ui.theme.AppTheme
import com.geeksville.mesh.util.Exceptions
import com.geeksville.mesh.util.LanguageUtils
import com.geeksville.mesh.util.getPackageInfoCompat
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import java.text.DateFormat
import java.util.Date
import javax.inject.Inject
/*
UI design
material setup instructions: https://material.io/develop/android/docs/getting-started/
dark theme (or use system eventually) https://material.io/develop/android/theming/dark/
NavDrawer is a standard draw which can be dragged in from the left or the menu icon inside the app
title.
Fragments:
SettingsFragment shows "Settings"
username
shortname
bluetooth pairing list
(eventually misc device settings that are not channel related)
Channel fragment "Channel"
qr code, copy link button
ch number
misc other settings
(eventually a way of choosing between past channels)
ChatFragment "Messages"
a text box to enter new texts
a scrolling list of rows. each row is a text and a sender info layout
NodeListFragment "Users"
a node info row for every node
ViewModels:
BTScanModel starts/stops bt scan and provides list of devices (manages entire scan lifecycle)
MeshModel contains: (manages entire service relationship)
current received texts
current radio macaddr
current node infos (updated dynamically)
eventually use bottom navigation bar to switch between, Members, Chat, Channel, Settings. https://material.io/develop/android/components/bottom-navigation-view/
use numbers of # chat messages and # of members in the badges.
(per this recommendation to not use top tabs: https://ux.stackexchange.com/questions/102439/android-ux-when-to-use-bottom-navigation-and-when-to-use-tabs )
eventually:
make a custom theme: https://github.com/material-components/material-components-android/tree/master/material-theme-builder
*/
@AndroidEntryPoint
class MainActivity : AppCompatActivity(), Logging {
private lateinit var binding: ActivityMainBinding
// Used to schedule a coroutine in the GUI thread
private val mainScope = CoroutineScope(Dispatchers.Main + Job())
@ -173,7 +99,7 @@ class MainActivity : AppCompatActivity(), Logging {
info("Bluetooth permissions granted")
} else {
warn("Bluetooth permissions denied")
showSnackbar(permissionMissing)
model.showSnackbar(permissionMissing)
}
requestedEnable = false
bluetoothViewModel.permissionsUpdated()
@ -186,46 +112,12 @@ class MainActivity : AppCompatActivity(), Logging {
checkAlertDnD()
} else {
warn("Notification permissions denied")
showSnackbar(getString(R.string.notification_denied), Snackbar.LENGTH_SHORT)
model.showSnackbar(getString(R.string.notification_denied))
}
}
data class TabInfo(@StringRes val textResId: Int, val icon: Int, val content: Fragment)
private val tabInfos = arrayOf(
TabInfo(
R.string.main_tab_messages,
R.drawable.ic_twotone_message_24,
ContactsFragment()
),
TabInfo(
R.string.main_tab_users,
R.drawable.ic_twotone_people_24,
UsersFragment()
),
TabInfo(
R.string.main_tab_map,
R.drawable.ic_twotone_map_24,
MapFragment()
),
TabInfo(
R.string.main_tab_channel,
R.drawable.ic_twotone_contactless_24,
ChannelFragment()
),
TabInfo(
R.string.main_tab_settings,
R.drawable.ic_twotone_settings_applications_24,
SettingsFragment()
)
)
private val tabsAdapter = object : FragmentStateAdapter(supportFragmentManager, lifecycle) {
override fun getItemCount(): Int = tabInfos.size
override fun createFragment(position: Int): Fragment = tabInfos[position].content
}
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
installSplashScreen()
super.onCreate(savedInstanceState)
@ -247,44 +139,10 @@ class MainActivity : AppCompatActivity(), Logging {
(application as GeeksvilleApplication).askToRate(this)
}
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
initToolbar()
binding.pager.adapter = tabsAdapter
binding.pager.isUserInputEnabled =
false // Gestures for screen switching doesn't work so good with the map view
// pager.offscreenPageLimit = 0 // Don't keep any offscreen pages around, because we want to make sure our bluetooth scanning stops
TabLayoutMediator(binding.tabLayout, binding.pager, false, false) { tab, position ->
tab.icon = ContextCompat.getDrawable(this, tabInfos[position].icon)
tab.contentDescription = ContextCompat.getString(this, tabInfos[position].textResId)
}.attach()
binding.tabLayout.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener {
override fun onTabSelected(tab: TabLayout.Tab?) {
val mainTab = tab?.position ?: 0
model.setCurrentTab(mainTab)
}
override fun onTabUnselected(tab: TabLayout.Tab?) { }
override fun onTabReselected(tab: TabLayout.Tab?) { }
})
binding.composeView.setContent {
val connState by model.connectionState.collectAsStateWithLifecycle()
val channels by model.channels.collectAsStateWithLifecycle()
val requestChannelSet by model.requestChannelSet.collectAsStateWithLifecycle()
AppTheme {
if (connState.isConnected()) {
if (requestChannelSet != null) {
ScannedQrCodeDialog(
channels = channels,
incoming = requestChannelSet!!,
onDismiss = model::clearRequestChannelUrl,
onConfirm = model::setChannels,
)
}
setContent {
Box(Modifier.safeDrawingPadding()) {
AppTheme {
MainScreen(viewModel = model, onAction = ::onMainMenuAction)
}
}
}
@ -293,28 +151,6 @@ class MainActivity : AppCompatActivity(), Logging {
handleIntent(intent)
}
private fun initToolbar() {
val toolbar = binding.toolbar as Toolbar
setSupportActionBar(toolbar)
supportActionBar?.setDisplayShowTitleEnabled(false)
}
private fun updateConnectionStatusImage(connected: MeshService.ConnectionState) {
if (model.actionBarMenu == null) return
val (image, tooltip) = when (connected) {
MeshService.ConnectionState.CONNECTED -> R.drawable.cloud_on to R.string.connected
MeshService.ConnectionState.DEVICE_SLEEP -> R.drawable.ic_twotone_cloud_upload_24 to R.string.device_sleeping
MeshService.ConnectionState.DISCONNECTED -> R.drawable.cloud_off to R.string.disconnected
}
val item = model.actionBarMenu?.findItem(R.id.connectStatusImage)
if (item != null) {
item.setIcon(image)
item.setTitle(tooltip)
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleIntent(intent)
@ -329,17 +165,11 @@ class MainActivity : AppCompatActivity(), Logging {
Intent.ACTION_VIEW -> {
debug("Asked to open a channel URL - ask user if they want to switch to that channel. If so send the config to the radio")
appLinkData?.let(model::requestChannelUrl)
// We now wait for the device to connect, once connected, we ask the user if they want to switch to the new channel
}
MeshServiceNotifications.OPEN_MESSAGE_ACTION -> {
val contactKey =
intent.getStringExtra(MeshServiceNotifications.OPEN_MESSAGE_EXTRA_CONTACT_KEY)
showMessages(contactKey)
}
UsbManager.ACTION_USB_DEVICE_ATTACHED -> {
debug("USB device attached")
showSettingsPage()
}
@ -349,7 +179,7 @@ class MainActivity : AppCompatActivity(), Logging {
Intent.ACTION_SEND -> {
val text = intent.getStringExtra(Intent.EXTRA_TEXT)
if (text != null) {
shareMessages(text)
createShareIntent(text).send()
}
}
@ -359,6 +189,34 @@ class MainActivity : AppCompatActivity(), Logging {
}
}
private fun createShareIntent(message: String): PendingIntent {
val deepLink = "$DEEP_LINK_BASE_URI/share?message=$message"
val startActivityIntent = Intent(
Intent.ACTION_VIEW, deepLink.toUri(),
this, MainActivity::class.java
)
val resultPendingIntent: PendingIntent? = TaskStackBuilder.create(this).run {
addNextIntentWithParentStack(startActivityIntent)
getPendingIntent(0, PendingIntent.FLAG_IMMUTABLE)
}
return resultPendingIntent!!
}
private fun createSettingsIntent(): PendingIntent {
val deepLink = "$DEEP_LINK_BASE_URI/settings"
val startActivityIntent = Intent(
Intent.ACTION_VIEW, deepLink.toUri(),
this, MainActivity::class.java
)
val resultPendingIntent: PendingIntent? = TaskStackBuilder.create(this).run {
addNextIntentWithParentStack(startActivityIntent)
getPendingIntent(0, PendingIntent.FLAG_IMMUTABLE)
}
return resultPendingIntent!!
}
private var requestedEnable = false
private val bleRequestEnable = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
@ -369,7 +227,7 @@ class MainActivity : AppCompatActivity(), Logging {
private val createDocumentLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) {
if (it.resultCode == Activity.RESULT_OK) {
if (it.resultCode == RESULT_OK) {
it.data?.data?.let { file_uri -> model.saveMessagesCSV(file_uri) }
}
}
@ -400,6 +258,7 @@ class MainActivity : AppCompatActivity(), Logging {
// Linkify.addLinks(view, Linkify.ALL) // not needed with this method
view.movementMethod = LinkMovementMethod.getInstance()
debug("showAlert: $titleText")
showSettingsPage() // Default to the settings page in this case
}
@ -465,6 +324,7 @@ class MainActivity : AppCompatActivity(), Logging {
intent.putExtra(Settings.EXTRA_CHANNEL_ID, "my_alerts")
startActivity(intent)
}
val message = Html.fromHtml(
getString(R.string.alerts_dnd_request_text),
Html.FROM_HTML_MODE_COMPACT
@ -491,27 +351,6 @@ class MainActivity : AppCompatActivity(), Logging {
}
}
private fun showSnackbar(msgId: Int) {
try {
Snackbar.make(binding.root, msgId, Snackbar.LENGTH_LONG).show()
} catch (ex: IllegalStateException) {
errormsg("Snackbar couldn't find view for msgId $msgId")
}
}
private fun showSnackbar(msg: String, duration: Int = Snackbar.LENGTH_INDEFINITE) {
try {
Snackbar.make(binding.root, msg, duration)
.apply { view.findViewById<TextView>(R.id.snackbar_text).isSingleLine = false }
.setAction(R.string.okay) {
// dismiss
}
.show()
} catch (ex: IllegalStateException) {
errormsg("Snackbar couldn't find view for msgString $msg")
}
}
override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
return try {
super.dispatchTouchEvent(ev)
@ -526,10 +365,7 @@ class MainActivity : AppCompatActivity(), Logging {
private var connectionJob: Job? = null
private val mesh = object :
ServiceClient<IMeshService>({
IMeshService.Stub.asInterface(it)
}) {
private val mesh = object : ServiceClient<IMeshService>(IMeshService.Stub::asInterface) {
override fun onConnected(service: IMeshService) {
connectionJob = mainScope.handledLaunch {
serviceRepository.setMeshService(service)
@ -576,7 +412,7 @@ class MainActivity : AppCompatActivity(), Logging {
mesh.connect(
this,
MeshService.createIntent(),
Context.BIND_AUTO_CREATE + Context.BIND_ABOVE_CLIENT
BIND_AUTO_CREATE + BIND_ABOVE_CLIENT
)
}
@ -602,11 +438,6 @@ class MainActivity : AppCompatActivity(), Logging {
override fun onStart() {
super.onStart()
model.connectionState.asLiveData().observe(this) { state ->
onMeshConnectionChanged(state)
updateConnectionStatusImage(state)
}
bluetoothViewModel.enabled.observe(this) { enabled ->
if (!enabled && !requestedEnable && model.selectedBluetooth) {
requestedEnable = true
@ -622,17 +453,6 @@ class MainActivity : AppCompatActivity(), Logging {
}
}
// Call showSnackbar() whenever [snackbarText] updates with a non-null value
model.snackbarText.observe(this) { text ->
if (text is Int) showSnackbar(text)
if (text is String) showSnackbar(text)
if (text != null) model.clearSnackbarText()
}
model.currentTab.observe(this) {
binding.tabLayout.getTabAt(it)?.select()
}
model.tracerouteResponse.observe(this) { response ->
MaterialAlertDialogBuilder(this)
.setCancelable(false)
@ -648,126 +468,42 @@ class MainActivity : AppCompatActivity(), Logging {
bindMeshService()
} catch (ex: BindFailedException) {
// App is probably shutting down, ignore
errormsg("Bind of MeshService failed")
errormsg("Bind of MeshService failed${ex.message}")
}
val bonded = model.bondedAddress != null
if (!bonded) showSettingsPage()
}
private fun showSettingsPage() {
binding.pager.currentItem = 5
createSettingsIntent().send()
}
private fun showMessages(contactKey: String?) {
model.setCurrentTab(0)
if (contactKey != null) {
supportFragmentManager.navigateToMessages(contactKey)
}
}
private fun shareMessages(message: String?) {
model.setCurrentTab(0)
if (message != null) {
supportFragmentManager.navigateToShareMessage(message)
}
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
// Inflate the menu; this adds items to the action bar if it is present.
menuInflater.inflate(R.menu.menu_main, menu)
model.actionBarMenu = menu
updateConnectionStatusImage(model.connectionState.value)
return true
}
private val handler: Handler by lazy {
Handler(Looper.getMainLooper())
}
override fun onPrepareOptionsMenu(menu: Menu): Boolean {
menu.findItem(R.id.stress_test).isVisible =
BuildConfig.DEBUG // only show stress test for debug builds (for now)
menu.findItem(R.id.radio_config).isEnabled = !model.isManaged
return super.onPrepareOptionsMenu(menu)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
// Handle action bar item clicks here. The action bar will
// automatically handle clicks on the Home/Up button, so long
// as you specify a parent activity in AndroidManifest.xml.
return when (item.itemId) {
R.id.about -> {
private fun onMainMenuAction(action: MainMenuAction) {
when (action) {
MainMenuAction.ABOUT -> {
getVersionInfo()
return true
}
R.id.connectStatusImage -> {
Toast.makeText(applicationContext, item.title, Toast.LENGTH_SHORT).show()
return true
}
R.id.debug -> {
val fragmentManager: FragmentManager = supportFragmentManager
val fragmentTransaction: FragmentTransaction = fragmentManager.beginTransaction()
val nameFragment = DebugFragment()
fragmentTransaction.add(R.id.mainActivityLayout, nameFragment)
fragmentTransaction.addToBackStack(null)
fragmentTransaction.commit()
return true
}
R.id.stress_test -> {
fun postPing() {
// Send ping message and arrange delayed recursion.
debug("Sending ping")
val str = "Ping " + DateFormat.getTimeInstance(DateFormat.MEDIUM)
.format(Date(System.currentTimeMillis()))
model.sendMessage(str)
handler.postDelayed({ postPing() }, 30000)
}
item.isChecked = !item.isChecked // toggle ping test
if (item.isChecked) {
postPing()
} else {
handler.removeCallbacksAndMessages(null)
}
return true
}
R.id.radio_config -> {
supportFragmentManager.navigateToNavGraph()
return true
}
R.id.save_messages_csv -> {
MainMenuAction.EXPORT_MESSAGES -> {
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "application/csv"
putExtra(Intent.EXTRA_TITLE, "rangetest.csv")
}
createDocumentLauncher.launch(intent)
return true
}
R.id.theme -> {
MainMenuAction.THEME -> {
chooseThemeDialog()
return true
}
R.id.preferences_language -> {
MainMenuAction.LANGUAGE -> {
chooseLangDialog()
return true
}
R.id.show_intro -> {
MainMenuAction.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)
else -> {}
}
}