mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
fix: uri handling, ci test setup (#4556)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
parent
5061dc8262
commit
0f03492ac6
10 changed files with 115 additions and 34 deletions
6
.github/workflows/reusable-check.yml
vendored
6
.github/workflows/reusable-check.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -180,13 +180,16 @@
|
|||
</intent-filter>
|
||||
|
||||
<intent-filter android:autoVerify="true">
|
||||
<!-- The QR codes to share channel settings and contacts are shared as meshtastic URLS
|
||||
<!--
|
||||
The QR codes to share channel settings and contacts are shared as meshtastic URLS.
|
||||
We also support NFC NDEF Discovery for the same URLs.
|
||||
|
||||
an approximate example:
|
||||
https://meshtastic.org/e/YXNkZnF3ZXJhc2RmcXdlcmFzZGZxd2Vy
|
||||
https://meshtastic.org/v/#CNar9vwDEi0KCSEzZjgyOTVkNhIPV2F0ZXJmYWxsIDIg4piGGgPimaciBu2eP4KV1igJOAI
|
||||
An approximate example:
|
||||
https://meshtastic.org/e/YXNkZnF3ZXJhc2RmcXdlcmFzZGZxd2Vy
|
||||
https://meshtastic.org/v/#CNar9vwDEi0KCSEzZjgyOTVkNhIPV2F0ZXJmYWxsIDIg4piGGgPimaciBu2eP4KV1igJOAI
|
||||
-->
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<action android:name="android.nfc.action.NDEF_DISCOVERED" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
|
@ -199,18 +202,6 @@
|
|||
<data android:pathPrefix="/V/" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Support NFC NDEF Discovery for the same URLs -->
|
||||
<intent-filter>
|
||||
<action android:name="android.nfc.action.NDEF_DISCOVERED" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:scheme="https" />
|
||||
<data android:host="meshtastic.org" />
|
||||
<data android:pathPrefix="/e/" />
|
||||
<data android:pathPrefix="/E/" />
|
||||
<data android:pathPrefix="/v/" />
|
||||
<data android:pathPrefix="/V/" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
|
||||
</intent-filter>
|
||||
|
|
|
|||
|
|
@ -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) },
|
||||
|
|
|
|||
|
|
@ -198,6 +198,13 @@ constructor(
|
|||
val unreadMessageCount =
|
||||
packetRepository.getUnreadCountTotal().map { it.coerceAtLeast(0) }.stateInWhileSubscribed(initialValue = 0)
|
||||
|
||||
private val _navigationDeepLink = MutableSharedFlow<Uri>(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<MyNodeEntity?>
|
||||
get() = nodeDB.myNodeInfo
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue