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" }