Skip to content

Commit cf7200a

Browse files
codebutlerclaude
andauthored
Add Ultralight error handling for locked pages (#216)
* Port CEPASProtocol from Metrodroid to fix APDU framing Fixes #191 The old FareBot CEPASProtocol manually built APDU commands but omitted the Le (expected response length) byte, causing cards to reject with 6D00 (instruction not supported). Replaced with Metrodroid's approach that delegates to ISO7816Protocol.sendRequest() for proper APDU framing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add Ultralight error handling for locked pages - Add UnauthorizedException for unauthorized page access - Add isUnauthorized field to UltralightPage with data getter that throws on unauthorized access - Add UltralightPage.unauthorized(index) factory for creating unauthorized pages - Wrap readPages() in try/catch to handle locked page failures - Create unauthorized pages for remaining pages in failed batch Faithful port from Metrodroid UltralightPage and UltralightCardReader. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Apply ktlintFormat Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add regression tests for Ultralight locked page error handling Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix CI: use UltralightPage.create() factory in CompassTransitTest UltralightPage constructor parameter is 'dataRaw' (not 'data') due to @SerialName annotation. Use the create() factory method which takes the expected 'data' parameter name. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 308cb6b commit cf7200a

File tree

6 files changed

+217
-12
lines changed

6 files changed

+217
-12
lines changed

app/src/commonTest/kotlin/com/codebutler/farebot/test/CompassTransitTest.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ class CompassTransitTest {
5858
// Each block is 16 bytes = 4 pages of 4 bytes
5959
for (p in 0..3) {
6060
val pageData = hexToBytes(cardData[block].substring(p * 8, (p + 1) * 8))
61-
pages.add(UltralightPage(index = pageIndex++, data = pageData))
61+
pages.add(UltralightPage.create(index = pageIndex++, data = pageData))
6262
}
6363
}
6464

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/*
2+
* UnauthorizedException.kt
3+
*
4+
* This file is part of FareBot.
5+
* Learn more at: https://codebutler.github.io/farebot/
6+
*
7+
* Copyright (C) 2012 Eric Butler <eric@codebutler.com>
8+
*
9+
* Ported from Metrodroid (https://github.com/metrodroid/metrodroid)
10+
*
11+
* This program is free software: you can redistribute it and/or modify
12+
* it under the terms of the GNU General Public License as published by
13+
* the Free Software Foundation, either version 3 of the License, or
14+
* (at your option) any later version.
15+
*
16+
* This program is distributed in the hope that it will be useful,
17+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
18+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19+
* GNU General Public License for more details.
20+
*
21+
* You should have received a copy of the GNU General Public License
22+
* along with this program. If not, see <http://www.gnu.org/licenses/>.
23+
*/
24+
25+
package com.codebutler.farebot.card
26+
27+
open class UnauthorizedException(
28+
override val message: String = "Unauthorized",
29+
) : IllegalStateException()

card/ultralight/build.gradle.kts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ kotlin {
2424
iosSimulatorArm64()
2525

2626
sourceSets {
27+
commonTest.dependencies {
28+
implementation(kotlin("test"))
29+
}
2730
commonMain.dependencies {
2831
implementation(libs.compose.resources)
2932
implementation(libs.compose.runtime)

card/ultralight/src/commonMain/kotlin/com/codebutler/farebot/card/ultralight/UltralightCardReader.kt

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -42,20 +42,31 @@ object UltralightCardReader {
4242
var pageNumber = 0
4343
var pageBuffer = ByteArray(0)
4444
val pages = mutableListOf<UltralightPage>()
45+
var unauthorized = false
4546
while (pageNumber < size) {
4647
if (pageNumber % 4 == 0) {
47-
pageBuffer = tech.readPages(pageNumber)
48+
try {
49+
pageBuffer = tech.readPages(pageNumber)
50+
unauthorized = false
51+
} catch (e: Exception) {
52+
// Transceive failure, maybe authentication problem
53+
unauthorized = true
54+
}
4855
}
4956

50-
pages.add(
51-
UltralightPage.create(
52-
pageNumber,
53-
pageBuffer.copyOfRange(
54-
(pageNumber % 4) * UltralightTechnology.PAGE_SIZE,
55-
((pageNumber % 4) + 1) * UltralightTechnology.PAGE_SIZE,
57+
if (!unauthorized) {
58+
pages.add(
59+
UltralightPage.create(
60+
pageNumber,
61+
pageBuffer.copyOfRange(
62+
(pageNumber % 4) * UltralightTechnology.PAGE_SIZE,
63+
((pageNumber % 4) + 1) * UltralightTechnology.PAGE_SIZE,
64+
),
5665
),
57-
),
58-
)
66+
)
67+
} else {
68+
pages.add(UltralightPage.unauthorized(pageNumber))
69+
}
5970
pageNumber++
6071
}
6172

card/ultralight/src/commonMain/kotlin/com/codebutler/farebot/card/ultralight/UltralightPage.kt

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,9 @@
2525

2626
package com.codebutler.farebot.card.ultralight
2727

28+
import com.codebutler.farebot.card.UnauthorizedException
2829
import kotlinx.serialization.Contextual
30+
import kotlinx.serialization.SerialName
2931
import kotlinx.serialization.Serializable
3032

3133
/**
@@ -34,12 +36,24 @@ import kotlinx.serialization.Serializable
3436
@Serializable
3537
data class UltralightPage(
3638
val index: Int,
37-
@Contextual val data: ByteArray,
39+
@SerialName("data")
40+
@Contextual private val dataRaw: ByteArray,
41+
val isUnauthorized: Boolean = false,
3842
) {
43+
val data: ByteArray
44+
get() {
45+
if (isUnauthorized) {
46+
throw UnauthorizedException()
47+
}
48+
return dataRaw
49+
}
50+
3951
companion object {
4052
fun create(
4153
index: Int,
4254
data: ByteArray,
43-
): UltralightPage = UltralightPage(index, data)
55+
): UltralightPage = UltralightPage(index, data, false)
56+
57+
fun unauthorized(index: Int): UltralightPage = UltralightPage(index, ByteArray(0), true)
4458
}
4559
}
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
/*
2+
* UltralightPageTest.kt
3+
*
4+
* This file is part of FareBot.
5+
* Learn more at: https://codebutler.github.io/farebot/
6+
*
7+
* Copyright (C) 2026 Eric Butler <eric@codebutler.com>
8+
*
9+
* This program is free software: you can redistribute it and/or modify
10+
* it under the terms of the GNU General Public License as published by
11+
* the Free Software Foundation, either version 3 of the License, or
12+
* (at your option) any later version.
13+
*
14+
* This program is distributed in the hope that it will be useful,
15+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
16+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17+
* GNU General Public License for more details.
18+
*
19+
* You should have received a copy of the GNU General Public License
20+
* along with this program. If not, see <http://www.gnu.org/licenses/>.
21+
*/
22+
23+
package com.codebutler.farebot.card.ultralight
24+
25+
import com.codebutler.farebot.card.UnauthorizedException
26+
import kotlin.test.Test
27+
import kotlin.test.assertContentEquals
28+
import kotlin.test.assertEquals
29+
import kotlin.test.assertFailsWith
30+
import kotlin.test.assertFalse
31+
import kotlin.test.assertTrue
32+
33+
class UltralightPageTest {
34+
@Test
35+
fun testCreatePageHasCorrectIndex() {
36+
val page = UltralightPage.create(5, byteArrayOf(0x01, 0x02, 0x03, 0x04))
37+
assertEquals(5, page.index)
38+
}
39+
40+
@Test
41+
fun testCreatePageIsNotUnauthorized() {
42+
val page = UltralightPage.create(0, byteArrayOf(0x01, 0x02, 0x03, 0x04))
43+
assertFalse(page.isUnauthorized)
44+
}
45+
46+
@Test
47+
fun testCreatePageDataIsAccessible() {
48+
val data = byteArrayOf(0x0A, 0x0B, 0x0C, 0x0D)
49+
val page = UltralightPage.create(0, data)
50+
assertContentEquals(data, page.data)
51+
}
52+
53+
@Test
54+
fun testUnauthorizedPageHasCorrectIndex() {
55+
val page = UltralightPage.unauthorized(7)
56+
assertEquals(7, page.index)
57+
}
58+
59+
@Test
60+
fun testUnauthorizedPageIsMarkedUnauthorized() {
61+
val page = UltralightPage.unauthorized(0)
62+
assertTrue(page.isUnauthorized)
63+
}
64+
65+
@Test
66+
fun testUnauthorizedPageDataThrowsUnauthorizedException() {
67+
val page = UltralightPage.unauthorized(0)
68+
assertFailsWith<UnauthorizedException> {
69+
page.data
70+
}
71+
}
72+
73+
@Test
74+
fun testMixedAuthorizedAndUnauthorizedPages() {
75+
val pages =
76+
listOf(
77+
UltralightPage.create(0, byteArrayOf(0x01, 0x02, 0x03, 0x04)),
78+
UltralightPage.create(1, byteArrayOf(0x05, 0x06, 0x07, 0x08)),
79+
UltralightPage.unauthorized(2),
80+
UltralightPage.unauthorized(3),
81+
)
82+
83+
assertFalse(pages[0].isUnauthorized)
84+
assertFalse(pages[1].isUnauthorized)
85+
assertTrue(pages[2].isUnauthorized)
86+
assertTrue(pages[3].isUnauthorized)
87+
88+
// Authorized pages are readable
89+
assertContentEquals(byteArrayOf(0x01, 0x02, 0x03, 0x04), pages[0].data)
90+
assertContentEquals(byteArrayOf(0x05, 0x06, 0x07, 0x08), pages[1].data)
91+
92+
// Unauthorized pages throw
93+
assertFailsWith<UnauthorizedException> { pages[2].data }
94+
assertFailsWith<UnauthorizedException> { pages[3].data }
95+
}
96+
97+
@Test
98+
fun testUltralightCardReadPagesThrowsOnUnauthorizedPage() {
99+
val pages =
100+
listOf(
101+
UltralightPage.create(0, byteArrayOf(0x01, 0x02, 0x03, 0x04)),
102+
UltralightPage.create(1, byteArrayOf(0x05, 0x06, 0x07, 0x08)),
103+
UltralightPage.unauthorized(2),
104+
UltralightPage.create(3, byteArrayOf(0x0D, 0x0E, 0x0F, 0x10)),
105+
)
106+
107+
val card =
108+
UltralightCard.create(
109+
tagId = byteArrayOf(0x01, 0x02, 0x03, 0x04),
110+
scannedAt = kotlin.time.Instant.fromEpochMilliseconds(0),
111+
pages = pages,
112+
type = 1,
113+
)
114+
115+
// Reading authorized pages succeeds
116+
val data = card.readPages(0, 2)
117+
assertEquals(8, data.size)
118+
119+
// Reading range that includes unauthorized page throws
120+
assertFailsWith<UnauthorizedException> {
121+
card.readPages(1, 3)
122+
}
123+
}
124+
125+
@Test
126+
fun testUltralightCardGetPageReturnsUnauthorizedPage() {
127+
val pages =
128+
listOf(
129+
UltralightPage.create(0, byteArrayOf(0x01, 0x02, 0x03, 0x04)),
130+
UltralightPage.unauthorized(1),
131+
)
132+
133+
val card =
134+
UltralightCard.create(
135+
tagId = byteArrayOf(0x01, 0x02, 0x03, 0x04),
136+
scannedAt = kotlin.time.Instant.fromEpochMilliseconds(0),
137+
pages = pages,
138+
type = 1,
139+
)
140+
141+
// getPage returns the page object regardless of authorization
142+
val authorizedPage = card.getPage(0)
143+
assertFalse(authorizedPage.isUnauthorized)
144+
145+
val unauthorizedPage = card.getPage(1)
146+
assertTrue(unauthorizedPage.isUnauthorized)
147+
}
148+
}

0 commit comments

Comments
 (0)