diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml
index 640e4e339f..bd6af86443 100644
--- a/apps/flipcash/core/src/main/res/values/strings.xml
+++ b/apps/flipcash/core/src/main/res/values/strings.xml
@@ -662,6 +662,7 @@
Features
Developer
+ Contracts
User Flags
Account
User Flags
diff --git a/apps/flipcash/features/lab/build.gradle.kts b/apps/flipcash/features/lab/build.gradle.kts
index a712d78c8b..4187a26e8b 100644
--- a/apps/flipcash/features/lab/build.gradle.kts
+++ b/apps/flipcash/features/lab/build.gradle.kts
@@ -10,4 +10,11 @@ dependencies {
implementation(project(":apps:flipcash:shared:featureflags"))
implementation(project(":apps:flipcash:shared:userflags"))
implementation(project(":libs:messaging"))
+
+ implementation(libs.ocp.client.protocol)
+ implementation(libs.flipcash2.client.protocol)
+
+ testImplementation(kotlin("test"))
+ testImplementation(libs.bundles.unit.testing)
+ testImplementation(libs.bundles.compose.ui.testing)
}
diff --git a/apps/flipcash/features/lab/src/main/kotlin/com/flipcash/app/lab/internal/ContractInfoRow.kt b/apps/flipcash/features/lab/src/main/kotlin/com/flipcash/app/lab/internal/ContractInfoRow.kt
new file mode 100644
index 0000000000..bd25d4f4ca
--- /dev/null
+++ b/apps/flipcash/features/lab/src/main/kotlin/com/flipcash/app/lab/internal/ContractInfoRow.kt
@@ -0,0 +1,70 @@
+package com.flipcash.app.lab.internal
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import com.codeinc.flipcash.gen.Flipcash2ContractInfo
+import com.codeinc.opencode.gen.OcpContractInfo
+import com.getcode.theme.CodeTheme
+
+/**
+ * One backend contract package, as the build carries it.
+ *
+ * [commit] is the upstream proto commit the package was generated from, already shortened by the
+ * package itself so both platforms truncate the same way. A package built from a local proto sync
+ * reports `LOCAL` instead of a SHA, which is what [isLocal] flags -- that build's contract is not
+ * reproducible from anything published, so it is worth seeing at a glance.
+ */
+internal data class ContractInfo(
+ val name: String,
+ val version: String,
+ val commit: String,
+ val isLocal: Boolean,
+) {
+ val detail: String get() = "$version · $commit"
+}
+
+internal fun contractInfo(): List = listOf(
+ ContractInfo(
+ name = "ocp",
+ version = OcpContractInfo.VERSION,
+ commit = OcpContractInfo.shortProtoCommit,
+ isLocal = OcpContractInfo.isLocal,
+ ),
+ ContractInfo(
+ name = "flipcash2",
+ version = Flipcash2ContractInfo.VERSION,
+ commit = Flipcash2ContractInfo.shortProtoCommit,
+ isLocal = Flipcash2ContractInfo.isLocal,
+ ),
+)
+
+@Composable
+internal fun ContractInfoRow(
+ info: ContractInfo,
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(horizontal = CodeTheme.dimens.inset, vertical = CodeTheme.dimens.grid.x2),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = info.name,
+ style = CodeTheme.typography.textMedium,
+ color = CodeTheme.colors.textMain,
+ )
+ Text(
+ text = info.detail,
+ style = CodeTheme.typography.textSmall,
+ color = if (info.isLocal) CodeTheme.colors.warning else CodeTheme.colors.textSecondary,
+ )
+ }
+}
diff --git a/apps/flipcash/features/lab/src/main/kotlin/com/flipcash/app/lab/internal/LabsScreenContent.kt b/apps/flipcash/features/lab/src/main/kotlin/com/flipcash/app/lab/internal/LabsScreenContent.kt
index a2188bf67f..1956a365af 100644
--- a/apps/flipcash/features/lab/src/main/kotlin/com/flipcash/app/lab/internal/LabsScreenContent.kt
+++ b/apps/flipcash/features/lab/src/main/kotlin/com/flipcash/app/lab/internal/LabsScreenContent.kt
@@ -189,6 +189,17 @@ internal fun LabsScreenContent(viewModel: LabsScreenViewModel, onboarding: Boole
navigator.navigate(AppRoute.UserFlags)
}
}
+
+ item(contentType = "section_header") {
+ SectionHeader(
+ modifier = Modifier.padding(horizontal = CodeTheme.dimens.inset),
+ title = stringResource(R.string.title_settingsSectionContracts)
+ )
+ }
+
+ items(contractInfo(), key = { it.name }, contentType = { "contract_info" }) { info ->
+ ContractInfoRow(info)
+ }
}
}
}
diff --git a/apps/flipcash/features/lab/src/test/kotlin/com/flipcash/app/lab/internal/ContractInfoRowTest.kt b/apps/flipcash/features/lab/src/test/kotlin/com/flipcash/app/lab/internal/ContractInfoRowTest.kt
new file mode 100644
index 0000000000..01ffda4969
--- /dev/null
+++ b/apps/flipcash/features/lab/src/test/kotlin/com/flipcash/app/lab/internal/ContractInfoRowTest.kt
@@ -0,0 +1,44 @@
+package com.flipcash.app.lab.internal
+
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithText
+import com.getcode.theme.DesignSystem
+import org.junit.Rule
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import kotlin.test.Test
+
+@RunWith(RobolectricTestRunner::class)
+class ContractInfoRowTest {
+
+ @get:Rule
+ val composeTestRule = createComposeRule()
+
+ @Test
+ fun `shows the package name and the version-commit detail`() {
+ composeTestRule.setContent {
+ DesignSystem {
+ ContractInfoRow(
+ ContractInfo(name = "ocp", version = "0.5.0", commit = "82202912", isLocal = false)
+ )
+ }
+ }
+
+ composeTestRule.onNodeWithText("ocp").assertIsDisplayed()
+ composeTestRule.onNodeWithText("0.5.0 · 82202912").assertIsDisplayed()
+ }
+
+ @Test
+ fun `a local contract shows LOCAL in place of a commit`() {
+ composeTestRule.setContent {
+ DesignSystem {
+ ContractInfoRow(
+ ContractInfo(name = "flipcash2", version = "0.9.0", commit = "LOCAL", isLocal = true)
+ )
+ }
+ }
+
+ composeTestRule.onNodeWithText("0.9.0 · LOCAL").assertIsDisplayed()
+ }
+}
diff --git a/apps/flipcash/features/lab/src/test/kotlin/com/flipcash/app/lab/internal/ContractInfoTest.kt b/apps/flipcash/features/lab/src/test/kotlin/com/flipcash/app/lab/internal/ContractInfoTest.kt
new file mode 100644
index 0000000000..93a5c0803e
--- /dev/null
+++ b/apps/flipcash/features/lab/src/test/kotlin/com/flipcash/app/lab/internal/ContractInfoTest.kt
@@ -0,0 +1,37 @@
+package com.flipcash.app.lab.internal
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+class ContractInfoTest {
+
+ @Test
+ fun `both contracts are listed, in a stable order`() {
+ val names = contractInfo().map { it.name }
+ assertEquals(listOf("ocp", "flipcash2"), names)
+ }
+
+ @Test
+ fun `detail joins the version and the short commit`() {
+ val row = ContractInfo(name = "ocp", version = "0.5.0", commit = "82202912", isLocal = false)
+ assertEquals("0.5.0 · 82202912", row.detail)
+ assertFalse(row.isLocal)
+ }
+
+ @Test
+ fun `a local contract keeps LOCAL in the detail and flags itself`() {
+ val row = ContractInfo(name = "ocp", version = "0.5.0", commit = "LOCAL", isLocal = true)
+ assertEquals("0.5.0 · LOCAL", row.detail)
+ assertTrue(row.isLocal)
+ }
+
+ @Test
+ fun `the real packages report a non-empty version and commit`() {
+ contractInfo().forEach { row ->
+ assertTrue(row.version.isNotEmpty(), "${row.name} has no version")
+ assertTrue(row.commit.isNotEmpty(), "${row.name} has no commit")
+ }
+ }
+}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index f753cb8e99..385fcc4aaa 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -68,8 +68,8 @@ protovalidate-kt = "0.1.2"
# cadence and there is nothing to keep aligned.
# 0.3.0 is the first release of either package to ship R8 keep rules for its generated
# messages, which is what lets proguard-rules.pro drop its own.
-ocp-client-protocol = "0.4.0"
-flipcash2-client-protocol = "0.8.0"
+ocp-client-protocol = "0.5.0"
+flipcash2-client-protocol = "0.9.0"
# The Android port is the ONLY libphonenumber this app depends on, deliberately. Google's
# `com.googlecode` artifact used to sit alongside it; the two ship separate copies of the metadata,
diff --git a/libs/logging/build.gradle.kts b/libs/logging/build.gradle.kts
index ea03686de0..6baf81d2b1 100644
--- a/libs/logging/build.gradle.kts
+++ b/libs/logging/build.gradle.kts
@@ -31,4 +31,7 @@ dependencies {
implementation(libs.androidx.annotation)
implementation(libs.grpc.kotlin)
implementation(project(":libs:messaging"))
+
+ implementation(libs.ocp.client.protocol)
+ implementation(libs.flipcash2.client.protocol)
}
diff --git a/libs/logging/src/main/kotlin/com/getcode/utils/FileTree.kt b/libs/logging/src/main/kotlin/com/getcode/utils/FileTree.kt
index 7cb3415db2..19f9437eec 100644
--- a/libs/logging/src/main/kotlin/com/getcode/utils/FileTree.kt
+++ b/libs/logging/src/main/kotlin/com/getcode/utils/FileTree.kt
@@ -4,6 +4,8 @@ import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.util.Log
+import com.codeinc.flipcash.gen.Flipcash2ContractInfo
+import com.codeinc.opencode.gen.OcpContractInfo
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
@@ -146,6 +148,8 @@ private fun buildDeviceHeader(context: Context): String {
appendLine("=".repeat(60))
appendLine("App Version: $versionName ($versionCode)")
appendLine("Package: ${context.packageName}")
+ appendLine("OCP Contract: ${OcpContractInfo.VERSION} (${OcpContractInfo.shortProtoCommit})")
+ appendLine("FC2 Contract: ${Flipcash2ContractInfo.VERSION} (${Flipcash2ContractInfo.shortProtoCommit})")
appendLine("User ID: ${userId ?: "not set"}")
appendLine("Device: ${Build.MANUFACTURER} ${Build.MODEL}")
appendLine("Android: ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})")
diff --git a/libs/logging/src/test/kotlin/com/getcode/utils/FileTreeTest.kt b/libs/logging/src/test/kotlin/com/getcode/utils/FileTreeTest.kt
index 1cfe67876b..d97b9e6675 100644
--- a/libs/logging/src/test/kotlin/com/getcode/utils/FileTreeTest.kt
+++ b/libs/logging/src/test/kotlin/com/getcode/utils/FileTreeTest.kt
@@ -263,4 +263,12 @@ class FileTreeTest {
assertTrue(content.contains("Android:"))
assertTrue(content.contains("Exported:"))
}
+
+ @Test
+ fun `exported log file names both contract packages`() {
+ Timber.d("contract check")
+ val content = fileTree.getLogFile()!!.readText()
+ assertTrue(content.contains("OCP Contract:"), "header has no OCP contract line")
+ assertTrue(content.contains("FC2 Contract:"), "header has no flipcash2 contract line")
+ }
}