diff --git a/.github/workflows/reusable-check.yml b/.github/workflows/reusable-check.yml
index ab590e227..efbdb4f39 100644
--- a/.github/workflows/reusable-check.yml
+++ b/.github/workflows/reusable-check.yml
@@ -94,8 +94,9 @@ jobs:
IS_FIRST_API=$(echo '${{ inputs.api_levels }}' | jq -r '.[0] == ${{ matrix.api_level }}')
IS_FIRST_FLAVOR=$(echo '${{ inputs.flavors }}' | jq -r '.[0] == "${{ matrix.flavor }}"')
- if [ "${{ inputs.run_lint }}" = "true" ] && [ "$IS_FIRST_API" = "true" ] && [ "$IS_FIRST_FLAVOR" = "true" ]; then
- TASKS="$TASKS spotlessCheck detekt "
+ if [ "$IS_FIRST_API" = "true" ] && [ "$IS_FIRST_FLAVOR" = "true" ]; then
+ [ "${{ inputs.run_lint }}" = "true" ] && TASKS="$TASKS spotlessCheck detekt "
+ [ "${{ inputs.run_unit_tests }}" = "true" ] && TASKS="$TASKS testDebugUnitTest "
fi
FLAVOR="${{ matrix.flavor }}"
@@ -111,6 +112,7 @@ jobs:
# Instrumented Test Tasks
if [ "${{ inputs.run_instrumented_tests }}" = "true" ]; then
+ [ "$IS_FIRST_FLAVOR" = "true" ] && TASKS="$TASKS connectedDebugAndroidTest "
if [ "$FLAVOR" = "google" ]; then
TASKS="$TASKS connectedGoogleDebugAndroidTest "
elif [ "$FLAVOR" = "fdroid" ]; then
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index e9e91e776..55d495571 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -180,13 +180,16 @@
-
+
@@ -199,18 +202,6 @@
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/java/com/geeksville/mesh/MainActivity.kt b/app/src/main/java/com/geeksville/mesh/MainActivity.kt
index 77205ea28..f6d1f073c 100644
--- a/app/src/main/java/com/geeksville/mesh/MainActivity.kt
+++ b/app/src/main/java/com/geeksville/mesh/MainActivity.kt
@@ -104,7 +104,9 @@ class MainActivity : AppCompatActivity() {
}
}
}
- handleIntent(intent)
+ if (savedInstanceState == null) {
+ handleIntent(intent)
+ }
}
override fun onNewIntent(intent: Intent) {
@@ -156,6 +158,11 @@ class MainActivity : AppCompatActivity() {
private fun handleMeshtasticUri(uri: Uri) {
Logger.d { "Handling Meshtastic URI: $uri" }
+ if (uri.toString().startsWith(DEEP_LINK_BASE_URI)) {
+ model.handleNavigationDeepLink(uri)
+ return
+ }
+
uri.dispatchMeshtasticUri(
onChannel = { model.setRequestChannelSet(it) },
onContact = { model.setSharedContactRequested(it) },
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 89bc838ab..de6740bd8 100644
--- a/app/src/main/java/com/geeksville/mesh/model/UIState.kt
+++ b/app/src/main/java/com/geeksville/mesh/model/UIState.kt
@@ -198,6 +198,13 @@ constructor(
val unreadMessageCount =
packetRepository.getUnreadCountTotal().map { it.coerceAtLeast(0) }.stateInWhileSubscribed(initialValue = 0)
+ private val _navigationDeepLink = MutableSharedFlow(replay = 1)
+ val navigationDeepLink = _navigationDeepLink.asSharedFlow()
+
+ fun handleNavigationDeepLink(uri: Uri) {
+ _navigationDeepLink.tryEmit(uri)
+ }
+
// hardware info about our local device (can be null)
val myNodeInfo: StateFlow
get() = nodeDB.myNodeInfo
diff --git a/app/src/main/java/com/geeksville/mesh/ui/Main.kt b/app/src/main/java/com/geeksville/mesh/ui/Main.kt
index 3fbcfb9a1..8f98bcbf2 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/Main.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/Main.kt
@@ -166,6 +166,7 @@ enum class TopLevelDestination(val label: StringResource, val icon: ImageVector,
@Composable
fun MainScreen(uIViewModel: UIViewModel = hiltViewModel(), scanModel: BTScanModel = hiltViewModel()) {
val navController = rememberNavController()
+ LaunchedEffect(uIViewModel) { uIViewModel.navigationDeepLink.collectLatest { uri -> navController.navigate(uri) } }
val connectionState by uIViewModel.connectionState.collectAsStateWithLifecycle()
val requestChannelSet by uIViewModel.requestChannelSet.collectAsStateWithLifecycle()
val sharedContactRequested by uIViewModel.sharedContactRequested.collectAsStateWithLifecycle()
diff --git a/core/model/src/androidTest/kotlin/org/meshtastic/core/model/ChannelTest.kt b/core/model/src/androidTest/kotlin/org/meshtastic/core/model/ChannelTest.kt
index 94e2262c6..b4852a749 100644
--- a/core/model/src/androidTest/kotlin/org/meshtastic/core/model/ChannelTest.kt
+++ b/core/model/src/androidTest/kotlin/org/meshtastic/core/model/ChannelTest.kt
@@ -20,7 +20,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert
import org.junit.Test
import org.junit.runner.RunWith
-import org.meshtastic.core.model.util.URL_PREFIX
+import org.meshtastic.core.model.util.CHANNEL_URL_PREFIX
import org.meshtastic.core.model.util.getChannelUrl
import org.meshtastic.core.model.util.toChannelSet
import org.meshtastic.proto.ChannelSet
@@ -33,7 +33,7 @@ class ChannelTest {
val ch = ChannelSet(settings = listOf(Channel.default.settings), lora_config = Channel.default.loraConfig)
val channelUrl = ch.getChannelUrl()
- Assert.assertTrue(channelUrl.toString().startsWith(URL_PREFIX))
+ Assert.assertTrue(channelUrl.toString().startsWith(CHANNEL_URL_PREFIX))
Assert.assertEquals(channelUrl.toChannelSet(), ch)
}
diff --git a/core/model/src/androidTest/kotlin/org/meshtastic/core/model/util/SharedContactTest.kt b/core/model/src/androidTest/kotlin/org/meshtastic/core/model/util/SharedContactTest.kt
index 2707436d8..8f346ed2f 100644
--- a/core/model/src/androidTest/kotlin/org/meshtastic/core/model/util/SharedContactTest.kt
+++ b/core/model/src/androidTest/kotlin/org/meshtastic/core/model/util/SharedContactTest.kt
@@ -40,21 +40,27 @@ class SharedContactTest {
@Test
fun testWwwHostIsAccepted() {
- val url = Uri.parse("https://www.meshtastic.org/v/#CggKBVN1enVtZRICU1oaBTEyMzQ1")
+ val original = SharedContact(user = User(long_name = "Suzume"), node_num = 12345)
+ val urlStr = original.getSharedContactUrl().toString().replace("meshtastic.org", "www.meshtastic.org")
+ val url = Uri.parse(urlStr)
val contact = url.toSharedContact()
assertEquals("Suzume", contact.user?.long_name)
}
@Test
fun testLongPathIsAccepted() {
- val url = Uri.parse("https://meshtastic.org/contact/v/#CggKBVN1enVtZRICU1oaBTEyMzQ1")
+ val original = SharedContact(user = User(long_name = "Suzume"), node_num = 12345)
+ val urlStr = original.getSharedContactUrl().toString().replace("/v/", "/contact/v/")
+ val url = Uri.parse(urlStr)
val contact = url.toSharedContact()
assertEquals("Suzume", contact.user?.long_name)
}
@Test(expected = java.net.MalformedURLException::class)
fun testInvalidHostThrows() {
- val url = Uri.parse("https://example.com/v/#CggKBVN1enVtZRICU1oaBTEyMzQ1")
+ val original = SharedContact(user = User(long_name = "Suzume"), node_num = 12345)
+ val urlStr = original.getSharedContactUrl().toString().replace("meshtastic.org", "example.com")
+ val url = Uri.parse(urlStr)
url.toSharedContact()
}
}
diff --git a/core/model/src/main/kotlin/org/meshtastic/core/model/util/SharedContact.kt b/core/model/src/main/kotlin/org/meshtastic/core/model/util/SharedContact.kt
index 76b5c311e..70aea71c9 100644
--- a/core/model/src/main/kotlin/org/meshtastic/core/model/util/SharedContact.kt
+++ b/core/model/src/main/kotlin/org/meshtastic/core/model/util/SharedContact.kt
@@ -24,7 +24,7 @@ import org.meshtastic.proto.SharedContact
import org.meshtastic.proto.User
import java.net.MalformedURLException
-private const val BASE64FLAGS = Base64.URL_SAFE + Base64.NO_WRAP + Base64.NO_PADDING
+private const val BASE64FLAGS = Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING
/**
* Return a [SharedContact] that represents the contact encoded by the URL.
@@ -33,16 +33,48 @@ private const val BASE64FLAGS = Base64.URL_SAFE + Base64.NO_WRAP + Base64.NO_PAD
*/
@Throws(MalformedURLException::class)
fun Uri.toSharedContact(): SharedContact {
- val h = host ?: ""
- val isCorrectHost =
- h.equals(MESHTASTIC_HOST, ignoreCase = true) || h.equals("www.$MESHTASTIC_HOST", ignoreCase = true)
+ checkSharedContactUrl()
+ val data = fragment!!.substringBefore('?')
+ return decodeSharedContactData(data)
+}
+
+@Throws(MalformedURLException::class)
+private fun Uri.checkSharedContactUrl() {
+ val h = host?.lowercase() ?: ""
+ val isCorrectHost = h == MESHTASTIC_HOST || h == "www.$MESHTASTIC_HOST"
val segments = pathSegments
val isCorrectPath = segments.any { it.equals("v", ignoreCase = true) }
- if (fragment.isNullOrBlank() || !isCorrectHost || !isCorrectPath) {
- throw MalformedURLException("Not a valid Meshtastic URL")
+ val frag = fragment
+ if (frag.isNullOrBlank() || !isCorrectHost || !isCorrectPath) {
+ throw MalformedURLException(
+ "Not a valid Meshtastic URL: host=$h, segments=$segments, hasFragment=${!frag.isNullOrBlank()}",
+ )
+ }
+}
+
+@Throws(MalformedURLException::class)
+private fun decodeSharedContactData(data: String): SharedContact {
+ val decodedBytes =
+ try {
+ // We use a more lenient decoding for the input to handle variations from different clients
+ Base64.decode(data, Base64.DEFAULT or Base64.URL_SAFE)
+ } catch (e: IllegalArgumentException) {
+ val ex =
+ MalformedURLException(
+ "Failed to Base64 decode SharedContact data ($data): ${e.javaClass.simpleName}: ${e.message}",
+ )
+ ex.initCause(e)
+ throw ex
+ }
+
+ return try {
+ SharedContact.ADAPTER.decode(decodedBytes.toByteString())
+ } catch (e: java.io.IOException) {
+ val ex = MalformedURLException("Failed to proto decode SharedContact: ${e.javaClass.simpleName}: ${e.message}")
+ ex.initCause(e)
+ throw ex
}
- return SharedContact.ADAPTER.decode(Base64.decode(fragment!!, BASE64FLAGS).toByteString())
}
/** Converts a [SharedContact] to its corresponding URI representation. */
diff --git a/core/model/src/test/kotlin/org/meshtastic/core/model/util/SharedContactTest.kt b/core/model/src/test/kotlin/org/meshtastic/core/model/util/SharedContactTest.kt
index af51c2eb8..c73a65853 100644
--- a/core/model/src/test/kotlin/org/meshtastic/core/model/util/SharedContactTest.kt
+++ b/core/model/src/test/kotlin/org/meshtastic/core/model/util/SharedContactTest.kt
@@ -65,4 +65,36 @@ class SharedContactTest {
val url = Uri.parse(urlStr)
url.toSharedContact()
}
+
+ @Test(expected = java.net.MalformedURLException::class)
+ fun testInvalidPathThrows() {
+ val original = SharedContact(user = User(long_name = "Suzume"), node_num = 12345)
+ val urlStr = original.getSharedContactUrl().toString().replace("/v/", "/wrong/")
+ val url = Uri.parse(urlStr)
+ url.toSharedContact()
+ }
+
+ @Test(expected = java.net.MalformedURLException::class)
+ fun testMissingFragmentThrows() {
+ val urlStr = "https://meshtastic.org/v/"
+ val url = Uri.parse(urlStr)
+ url.toSharedContact()
+ }
+
+ @Test(expected = java.net.MalformedURLException::class)
+ fun testInvalidBase64Throws() {
+ val urlStr = "https://meshtastic.org/v/#InvalidBase64!!!!"
+ val url = Uri.parse(urlStr)
+ url.toSharedContact()
+ }
+
+ @Test(expected = java.net.MalformedURLException::class)
+ fun testInvalidProtoThrows() {
+ // Tag 0 is invalid in Protobuf
+ // 0x00 -> Tag 0, Type 0.
+ // Base64 for 0x00 is "AA=="
+ val urlStr = "https://meshtastic.org/v/#AA=="
+ val url = Uri.parse(urlStr)
+ url.toSharedContact()
+ }
}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 2e2e9ebfa..c8dd3599d 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -47,6 +47,7 @@ aboutlibraries = "13.2.1"
coil = "3.3.0"
dd-sdk-android = "3.6.0"
detekt = "1.23.8"
+dokka = "2.2.0-Beta"
devtools-ksp = "2.3.5"
markdownRenderer = "0.39.2"
osmdroid-android = "6.1.20"
@@ -78,6 +79,7 @@ androidx-emoji2-emojipicker = { module = "androidx.emoji2:emoji2-emojipicker", v
androidx-hilt-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = "androidxHilt" }
androidx-hilt-lifecycle-viewmodel-compose = { module = "androidx.hilt:hilt-lifecycle-viewmodel-compose", version.ref = "androidxHilt" }
androidx-hilt-work = { module = "androidx.hilt:hilt-work", version.ref = "androidxHilt" }
+androidx-hilt-common = { module = "androidx.hilt:hilt-common", version.ref = "androidxHilt" }
androidx-lifecycle-livedata-ktx = { module = "androidx.lifecycle:lifecycle-livedata-ktx", version.ref = "lifecycle" }
androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycle" }
androidx-lifecycle-runtime = { module = "androidx.lifecycle:lifecycle-runtime", version.ref = "lifecycle" }
@@ -142,10 +144,11 @@ truth = { module = "com.google.truth:truth", version = "1.4.5" }
# Jetbrains
kotlin-gradlePlugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
-dokka-gradlePlugin = { module = "org.jetbrains.dokka:dokka-gradle-plugin", version = "2.1.0" }
+dokka-gradlePlugin = { module = "org.jetbrains.dokka:dokka-gradle-plugin", version.ref = "dokka" }
kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version = "0.4.0" }
+kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines-android" }
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines-android" }
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines-android" }
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" }
@@ -183,7 +186,7 @@ dd-sdk-android-session-replay-compose = { module = "com.datadoghq:dd-sdk-android
dd-sdk-android-timber = { module = "com.datadoghq:dd-sdk-android-timber", version.ref = "dd-sdk-android" }
dd-sdk-android-trace = { module = "com.datadoghq:dd-sdk-android-trace", version.ref = "dd-sdk-android" }
dd-sdk-android-trace-otel = { module = "com.datadoghq:dd-sdk-android-trace-otel", version.ref = "dd-sdk-android" }
-dokka-android-documentation-plugin = { module = "org.jetbrains.dokka:android-documentation-plugin", version = "2.1.0" }
+dokka-android-documentation-plugin = { module = "org.jetbrains.dokka:android-documentation-plugin", version.ref = "dokka" }
javax-inject = { module = "javax.inject:javax.inject", version = "1" }
markdown-renderer = { module = "com.mikepenz:multiplatform-markdown-renderer", version.ref = "markdownRenderer" }
markdown-renderer-m3 = { module = "com.mikepenz:multiplatform-markdown-renderer-m3", version.ref = "markdownRenderer" }
@@ -256,7 +259,7 @@ aboutlibraries = { id = "com.mikepenz.aboutlibraries.plugin.android", version.re
datadog = { id = "com.datadoghq.dd-sdk-android-gradle-plugin", version = "1.23.0" }
# Removed dependency-analysis = { id = "com.autonomousapps.dependency-analysis", version = "3.5.1" }
detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" }
-dokka = { id = "org.jetbrains.dokka", version = "2.1.0" }
+dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" }
wire = { id = "com.squareup.wire", version.ref = "wire" }
room = { id = "androidx.room", version.ref = "room" }
spotless = { id = "com.diffplug.spotless", version.ref = "spotless" }