Refactor and unify firmware update logic across platforms (#4966)

This commit is contained in:
James Rich 2026-04-01 07:14:26 -05:00 committed by GitHub
parent d8e295cafb
commit 89547afe6b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
102 changed files with 7206 additions and 3485 deletions

View file

@ -14,18 +14,30 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:Suppress("TooManyFunctions")
package org.meshtastic.core.ui.util
import android.content.ActivityNotFoundException
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.net.toUri
import co.touchlab.kermit.Logger
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.getString
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.common.util.MeshtasticUri
import java.net.URLEncoder
@Composable
@ -116,6 +128,61 @@ actual fun rememberSaveFileLauncher(
}
}
@Composable
actual fun rememberOpenFileLauncher(onUriReceived: (CommonUri?) -> Unit): (mimeType: String) -> Unit {
val launcher =
rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
onUriReceived(uri?.let { CommonUri(it) })
}
return remember(launcher) { { mimeType -> launcher.launch(mimeType) } }
}
@Suppress("Wrapping")
@Composable
actual fun rememberReadTextFromUri(): suspend (CommonUri, Int) -> String? {
val context = LocalContext.current
return remember(context) {
{ uri, maxChars ->
withContext(Dispatchers.IO) {
@Suppress("TooGenericExceptionCaught")
try {
val androidUri = Uri.parse(uri.toString())
context.contentResolver.openInputStream(androidUri)?.use { stream ->
stream.bufferedReader().use { reader ->
val buffer = CharArray(maxChars)
val read = reader.read(buffer)
if (read > 0) String(buffer, 0, read) else null
}
}
} catch (e: Exception) {
Logger.e(e) { "Failed to read text from URI: $uri" }
null
}
}
}
}
}
@Composable
actual fun KeepScreenOn(enabled: Boolean) {
val view = LocalView.current
DisposableEffect(enabled) {
if (enabled) {
view.keepScreenOn = true
}
onDispose {
if (enabled) {
view.keepScreenOn = false
}
}
}
}
@Composable
actual fun PlatformBackHandler(enabled: Boolean, onBack: () -> Unit) {
BackHandler(enabled = enabled, onBack = onBack)
}
@Composable
actual fun rememberRequestLocationPermission(onGranted: () -> Unit, onDenied: () -> Unit): () -> Unit {
val launcher =

View file

@ -14,10 +14,14 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:Suppress("TooManyFunctions")
package org.meshtastic.core.ui.util
import androidx.compose.runtime.Composable
import org.jetbrains.compose.resources.StringResource
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.common.util.MeshtasticUri
/** Returns a function to open the platform's NFC settings. */
@Composable expect fun rememberOpenNfcSettings(): () -> Unit
@ -37,9 +41,24 @@ import org.jetbrains.compose.resources.StringResource
/** Returns a launcher function to prompt the user to save a file. The callback receives the saved file URI. */
@Composable
expect fun rememberSaveFileLauncher(
onUriReceived: (org.meshtastic.core.common.util.MeshtasticUri) -> Unit,
onUriReceived: (MeshtasticUri) -> Unit,
): (defaultFilename: String, mimeType: String) -> Unit
/** Returns a launcher function to prompt the user to open/pick a file. The callback receives the selected file URI. */
@Composable expect fun rememberOpenFileLauncher(onUriReceived: (CommonUri?) -> Unit): (mimeType: String) -> Unit
/**
* Returns a suspend function that reads up to [maxChars] characters of text from a [CommonUri]. Returns `null` if the
* file is empty or cannot be read.
*/
@Composable expect fun rememberReadTextFromUri(): suspend (uri: CommonUri, maxChars: Int) -> String?
/** Keeps the screen awake while [enabled] is true. No-op on platforms that don't support it. */
@Composable expect fun KeepScreenOn(enabled: Boolean)
/** Intercepts the platform back gesture/button while [enabled] is true. No-op on platforms without a system back. */
@Composable expect fun PlatformBackHandler(enabled: Boolean, onBack: () -> Unit)
/** Returns a launcher to request location permissions. */
@Composable expect fun rememberRequestLocationPermission(onGranted: () -> Unit, onDenied: () -> Unit = {}): () -> Unit

View file

@ -21,6 +21,8 @@ import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextLinkStyles
import org.jetbrains.compose.resources.StringResource
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.common.util.MeshtasticUri
actual fun createClipEntry(text: String, label: String): ClipEntry =
throw UnsupportedOperationException("ClipEntry instantiation not supported on iOS stub")
@ -39,9 +41,18 @@ actual fun annotatedStringFromHtml(html: String, linkStyles: TextLinkStyles?): A
@Composable
actual fun rememberSaveFileLauncher(
onUriReceived: (org.meshtastic.core.common.util.MeshtasticUri) -> Unit,
onUriReceived: (MeshtasticUri) -> Unit,
): (defaultFilename: String, mimeType: String) -> Unit = { _, _ -> }
@Composable
actual fun rememberOpenFileLauncher(onUriReceived: (CommonUri?) -> Unit): (mimeType: String) -> Unit = { _ -> }
@Composable actual fun rememberReadTextFromUri(): suspend (CommonUri, Int) -> String? = { _, _ -> null }
@Composable actual fun KeepScreenOn(enabled: Boolean) {}
@Composable actual fun PlatformBackHandler(enabled: Boolean, onBack: () -> Unit) {}
@Composable actual fun rememberRequestLocationPermission(onGranted: () -> Unit, onDenied: () -> Unit): () -> Unit = {}
@Composable actual fun rememberOpenLocationSettings(): () -> Unit = {}

View file

@ -14,11 +14,22 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:Suppress("TooManyFunctions")
package org.meshtastic.core.ui.util
import androidx.compose.runtime.Composable
import co.touchlab.kermit.Logger
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jetbrains.compose.resources.StringResource
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.common.util.MeshtasticUri
import java.awt.Desktop
import java.awt.FileDialog
import java.awt.Frame
import java.io.File
import java.net.URI
/** JVM stub — NFC settings are not available on Desktop. */
@Composable
@ -47,12 +58,68 @@ actual fun rememberOpenUrl(): (url: String) -> Unit = { url ->
}
}
/** JVM stub — Save file launcher is a no-op on desktop until implemented. */
/** JVM — Opens a native file dialog to save a file. */
@Composable
actual fun rememberSaveFileLauncher(
onUriReceived: (org.meshtastic.core.common.util.MeshtasticUri) -> Unit,
): (defaultFilename: String, mimeType: String) -> Unit = { _, _ ->
Logger.w { "File saving not implemented on Desktop" }
onUriReceived: (MeshtasticUri) -> Unit,
): (defaultFilename: String, mimeType: String) -> Unit = { defaultFilename, _ ->
val dialog = FileDialog(null as Frame?, "Save File", FileDialog.SAVE)
dialog.file = defaultFilename
dialog.isVisible = true
val file = dialog.file
val dir = dialog.directory
if (file != null && dir != null) {
val path = File(dir, file)
onUriReceived(MeshtasticUri(path.toURI().toString()))
}
}
/** JVM — Opens a native file dialog to pick a file. */
@Composable
actual fun rememberOpenFileLauncher(onUriReceived: (CommonUri?) -> Unit): (mimeType: String) -> Unit = { _ ->
val dialog = FileDialog(null as? Frame, "Open File", FileDialog.LOAD)
dialog.isVisible = true
val file = dialog.file
val dir = dialog.directory
if (file != null && dir != null) {
val path = File(dir, file)
onUriReceived(CommonUri(path.toURI()))
}
}
/** JVM — Reads text from a file URI. */
@Composable
actual fun rememberReadTextFromUri(): suspend (CommonUri, Int) -> String? = { uri, maxChars ->
withContext(Dispatchers.IO) {
@Suppress("TooGenericExceptionCaught")
try {
val file = File(URI(uri.toString()))
if (file.exists()) {
file.bufferedReader().use { reader ->
val buffer = CharArray(maxChars)
val read = reader.read(buffer)
if (read > 0) String(buffer, 0, read) else null
}
} else {
null
}
} catch (e: Exception) {
Logger.e(e) { "Failed to read text from URI: $uri" }
null
}
}
}
/** JVM no-op — Keep screen on is not applicable on Desktop. */
@Composable
actual fun KeepScreenOn(enabled: Boolean) {
// No-op on JVM/Desktop
}
/** JVM no-op — Desktop has no system back gesture. */
@Composable
actual fun PlatformBackHandler(enabled: Boolean, onBack: () -> Unit) {
// No-op on JVM/Desktop — no system back button
}
@Composable