Skip to content

Commit f80ac59

Browse files
committed
chore: prepare for 0.13.1 release
1 parent f95804d commit f80ac59

12 files changed

Lines changed: 202 additions & 21 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,17 @@
11
# Changelog
22

3-
## 0.13.1-SNAPSHOT — 20260108
43

5-
- Based on Kiwa SDK 0.13.1-SNAPSHOT and Identity Development Kit 0.13.1-SNAPSHOT
6-
- Version bump to 0.13.1-SNAPSHOT of the Kiwa SDK to align with IDK 0.13.1-SNAPSHOT
4+
## 0.13.1 — 20260113
5+
6+
- Based on Kiwa SDK 0.13.1 and Identity Development Kit 0.13.1
7+
- Version bump to 0.13.1 of the Kiwa SDK to align with IDK 0.13.1
8+
- Improvements on NFC engagement handling
9+
10+
11+
## 0.13.0 — 20260108
12+
13+
- Based on Kiwa SDK 0.13.0 and Identity Development Kit 0.13.0
14+
- Version bump to 0.13.0 of the Kiwa SDK to align with IDK 0.13.0
715
- minSdk to 30, because of external library dependencies
816
- Add sample Swift app, next to the ios and Android compose app
917
- XCFramework building added
@@ -12,7 +20,7 @@
1220

1321
### Breaking Changes
1422

15-
#### IdkResult API Overhaul (from IDK 0.13.1-SNAPSHOT)
23+
#### IdkResult API Overhaul (from IDK 0.13.0)
1624
The `IdkResult` type has been completely redesigned for better iOS/Swift/ObjC interoperability:
1725
- **Changed from typealias to wrapper class**: `IdkResult` is now a proper class wrapping kotlin-result's `Result`, instead of a simple typealias
1826
- **New `Ok` and `Err` subclasses**: Use `Ok(value)` and `Err(error)` constructors instead of kotlin-result's functions directly

build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020

2121
allprojects {
2222
group = "com.sphereon.kiwa.sample"
23-
version = "0.13.1-SNAPSHOT"
23+
version = "0.13.1"
2424

2525
plugins.withType<MavenPublishPlugin> {
2626
configure<PublishingExtension> {

example/holder/compose-sample-app/composeApp/build.gradle.kts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,10 +148,11 @@ kotlin {
148148
implementation(sphereonlib.org.jetbrains.kotlinx.coroutines.core)
149149
// Kiwa SDK interfaces and common code
150150
api(libs.kiwa.holder.sdk.public)
151-
api(libs.sphereon.mdoc.core.public)
152151
api(libs.sphereon.mdoc.core.impl)
153152
api(libs.sphereon.mdoc.datatransfer.public)
154-
153+
api(libs.sphereon.oauth2.common.impl)
154+
api(libs.sphereon.oauth2.client.impl)
155+
api(libs.sphereon.openid.oid4vp.holder.impl)
155156

156157
api(libs.amz.app.platform.presenter.molecule.public)
157158
api(libs.amz.app.platform.renderer.compose.public)

example/holder/compose-sample-app/composeApp/src/androidMain/kotlin/com/sphereon/kiwa/sample/app/MdocNfcService.kt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,6 @@ class MdocNfcService : AbstractMdocNfcService() {
5656

5757

5858
companion object {
59-
6059
lateinit var app: KiwaSampleApplication
6160

6261
/**

example/holder/ui/core/impl/src/commonMain/kotlin/com/sphereon/kiwa/sample/ui/core/logs/LogViewerRenderer.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,7 @@ class LogViewerRenderer : ComposeRenderer<LogViewerPresenter.Model>() {
208208
private fun ExportFeedbackTip() {
209209
Spacer(modifier = Modifier.height(8.dp))
210210
Text(
211-
text = "💡 Tip: Open any text editor app, paste (Ctrl+V), and save the file.",
211+
text = "Tip: Open any text editor app, paste (Ctrl+V), and save the file.",
212212
color = AppColors.Screen.foreground.copy(alpha = 0.8f),
213213
fontSize = 10.sp,
214214
fontStyle = FontStyle.Italic

example/holder/ui/elicense/impl/src/commonMain/kotlin/com/sphereon/kiwa/sample/ui/elicense/engagement/qr/MdocEngagementPresenterImpl.kt

Lines changed: 86 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import com.sphereon.mdoc.data.device.DeviceRequest
3636
import com.sphereon.mdoc.engagement.MdocEngagementManager
3737
import com.sphereon.mdoc.engagement.QrMode
3838
import com.sphereon.mdoc.engagement.TerminalOutcome
39+
import com.sphereon.mdoc.engagement.MdocEngagementState
3940
import com.sphereon.mdoc.engagement.UiPhase
4041
import com.sphereon.mdoc.transfer.MapDrivenDocRequestSelector
4142
import com.sphereon.mdoc.transfer.TransferManager
@@ -80,6 +81,10 @@ class MdocEngagementPresenterImpl(
8081
// State storage for session and engagement
8182
var sessionState by remember { mutableStateOf(engagementManager.eventHub.sessionState.value) }
8283
var activeEngagement by remember { mutableStateOf(engagementManager.activeEngagement.value) }
84+
var nfcEngagement by remember { mutableStateOf(engagementManager.nfcEngagement.value) }
85+
86+
// NFC timeout error state
87+
var nfcTimeoutError by remember { mutableStateOf(false) }
8388

8489
// Collect session state from the engagement manager
8590
// StateFlow already guarantees distinct values by design
@@ -99,6 +104,57 @@ class MdocEngagementPresenterImpl(
99104
}
100105
}
101106

107+
// Collect NFC engagement separately
108+
LaunchedEffect(engagementManager) {
109+
engagementManager.nfcEngagement
110+
.collect { eng ->
111+
nfcEngagement = eng
112+
// Reset timeout error when NFC engagement changes
113+
if (eng != null) {
114+
nfcTimeoutError = false
115+
}
116+
}
117+
}
118+
119+
// NFC-specific timeout: 3 seconds for reader to establish BLE connection
120+
// Only applies when we have an NFC engagement in ENGAGEMENT phase (waiting for BLE)
121+
// Also applies when a NEW NFC engagement comes in while still in TERMINAL phase from previous timeout
122+
val isNewNfcEngagementInTerminal = nfcEngagement?.let { eng ->
123+
sessionState.phase == UiPhase.TERMINAL && eng.getCurrentState().order <= MdocEngagementState.START.order
124+
} == true
125+
val isNfcWaitingForConnection = nfcEngagement != null &&
126+
(sessionState.phase == UiPhase.ENGAGEMENT || isNewNfcEngagementInTerminal) &&
127+
!nfcTimeoutError
128+
129+
LaunchedEffect(isNfcWaitingForConnection, nfcEngagement?.id) {
130+
if (isNfcWaitingForConnection && nfcEngagement != null) {
131+
log.info("NFC engagement detected - starting 3 second connection timeout")
132+
kotlinx.coroutines.delay(NFC_CONNECTION_TIMEOUT_MS)
133+
134+
// Check if connection hasn't been established yet
135+
// This can be either:
136+
// 1. Phase is still ENGAGEMENT (normal case)
137+
// 2. Phase is still TERMINAL and engagement state is still early (new tap after previous timeout)
138+
val currentPhase = engagementManager.eventHub.sessionState.value.phase
139+
val currentNfcEngagement = engagementManager.nfcEngagement.value
140+
val isStillWaiting = currentNfcEngagement?.id == nfcEngagement?.id && when {
141+
currentPhase == UiPhase.ENGAGEMENT -> true
142+
currentPhase == UiPhase.TERMINAL -> {
143+
// Check if engagement state is still early (hasn't progressed to transfer)
144+
currentNfcEngagement?.getCurrentState()?.order?.let { it <= MdocEngagementState.START.order } == true
145+
}
146+
else -> false // TRANSFER phase means connection was established
147+
}
148+
149+
if (isStillWaiting) {
150+
log.warn("NFC connection timeout - tap was too short")
151+
nfcTimeoutError = true
152+
// Close the engagement
153+
engagementManager.closeAll()
154+
}
155+
}
156+
}
157+
102158
// Log the current values on every recomposition for debugging
103159
log.debug("!!! RECOMPOSITION - sessionState: phase=${sessionState.phase}, qrMode=${sessionState.qrMode}, activeEngagement: ${activeEngagement?.id}")
104160

@@ -223,7 +279,7 @@ class MdocEngagementPresenterImpl(
223279

224280
// Handle QR code scanned for reverse engagement (no remember - same pattern as CredentialListPresenterImpl)
225281
val onQrScanned: (String) -> Unit = { scannedData ->
226-
log.info("📷 QR SCANNED! Length: ${scannedData.length}, First 30 chars: '${scannedData.take(30)}'")
282+
log.info("QR SCANNED! Length: ${scannedData.length}, First 30 chars: '${scannedData.take(30)}'")
227283
log.debug("Full scanned data: $scannedData")
228284
when {
229285
// 18013-7 website via deeplink/QR
@@ -299,7 +355,7 @@ class MdocEngagementPresenterImpl(
299355
}
300356

301357
else -> {
302-
log.warn("⚠️ Invalid QR code: Expected 'mdoc://', 'mdoc:', or 'mdoc-openid4vp://' - got prefix: ${scannedData.take(20)}")
358+
log.warn("Invalid QR code: Expected 'mdoc://', 'mdoc:', or 'mdoc-openid4vp://' - got prefix: ${scannedData.take(20)}")
303359
}
304360
}
305361
}
@@ -363,6 +419,16 @@ class MdocEngagementPresenterImpl(
363419
// Special case: If TERMINAL state but no active engagement AND we never had an engagement,
364420
// this is stale state from a previous session - treat as INITIAL with toggle UI
365421
val model = when {
422+
// NFC timeout error takes precedence - show error screen
423+
nfcTimeoutError -> {
424+
log.debug(">>> Returning Model: ERROR (NFC tap too short)")
425+
MdocEngagementPresenter.Model.Error(
426+
errorType = MdocEngagementPresenter.ErrorType.NFC_TAP_TOO_SHORT,
427+
errorMessage = "NFC tap was too short. Please hold your phone to the reader a bit longer.",
428+
onStateEvent = onEvent
429+
)
430+
}
431+
366432
sessionState.phase == UiPhase.TERMINAL && activeEngagement == null && !hasHadEngagement.value -> {
367433
log.debug(">>> Stale TERMINAL state detected (never had engagement in this session) - returning Engagement with toggle")
368434
MdocEngagementPresenter.Model.Engagement(
@@ -375,6 +441,22 @@ class MdocEngagementPresenterImpl(
375441
)
376442
}
377443

444+
// New NFC engagement came in but session state hasn't caught up yet (still TERMINAL from previous)
445+
// This happens when a new NFC tap occurs right after a timeout - treat as new engagement
446+
sessionState.phase == UiPhase.TERMINAL && nfcEngagement?.let { eng ->
447+
eng.getCurrentState().order <= MdocEngagementState.START.order
448+
} == true -> {
449+
log.debug(">>> New NFC engagement detected while still in TERMINAL phase - returning Engagement")
450+
MdocEngagementPresenter.Model.Engagement(
451+
qrImage = null,
452+
engagementEvent = null,
453+
showQr = false,
454+
showQrScanner = false,
455+
onQrScanned = onQrScanned,
456+
onStateEvent = onEvent
457+
)
458+
}
459+
378460
// If phase is ENGAGEMENT but there's no active engagement, it's stale state - treat as ready for engagement
379461
sessionState.phase == UiPhase.ENGAGEMENT && activeEngagement == null -> {
380462
log.debug(">>> Stale ENGAGEMENT state detected (no active engagement) - returning Engagement with toggle")
@@ -505,5 +587,7 @@ class MdocEngagementPresenterImpl(
505587

506588
private companion object {
507589
const val MIN_DOC_REQUESTS = 1
590+
/** NFC connection timeout in milliseconds - short timeout since NFC handover is quick */
591+
const val NFC_CONNECTION_TIMEOUT_MS = 3000L
508592
}
509593
}

example/holder/ui/elicense/impl/src/commonMain/kotlin/com/sphereon/kiwa/sample/ui/elicense/engagement/qr/MdocEngagementRenderer.kt

Lines changed: 69 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
4242
import androidx.compose.foundation.verticalScroll
4343
import androidx.compose.material.icons.Icons
4444
import androidx.compose.material.icons.filled.CheckCircle
45+
import androidx.compose.material.icons.filled.Error
4546
import androidx.compose.material.icons.outlined.Nfc
4647
import androidx.compose.material3.Button
4748
import androidx.compose.material3.ButtonDefaults
@@ -115,6 +116,7 @@ class MdocEngagementRenderer(
115116
is MdocEngagementPresenter.Model.Selecting -> RenderSelecting(model, fg)
116117
is MdocEngagementPresenter.Model.Sharing -> RenderSharing(model, fg)
117118
is MdocEngagementPresenter.Model.Success -> RenderSuccess(model)
119+
is MdocEngagementPresenter.Model.Error -> RenderError(model, fg)
118120
is MdocEngagementPresenter.Model.Stopped -> Spacer(modifier = Modifier.height(1.dp))
119121
}
120122
}
@@ -368,6 +370,68 @@ class MdocEngagementRenderer(
368370
}
369371
}
370372

373+
@Composable
374+
private fun RenderError(model: MdocEngagementPresenter.Model.Error, fg: Color) {
375+
Column(modifier = Modifier.fillMaxSize()) {
376+
// Auto-navigate back after delay
377+
LaunchedEffect(Unit) {
378+
delay(DELAY_ERROR_MS)
379+
model.onStateEvent(MdocEngagementPresenter.UiStateEvent.Stopped)
380+
}
381+
Box(
382+
modifier = Modifier
383+
.fillMaxWidth()
384+
.weight(1f, fill = true),
385+
contentAlignment = Alignment.Center
386+
) {
387+
Card(
388+
colors = CardDefaults.cardColors(containerColor = Color(COLOR_CARD_BG)),
389+
modifier = Modifier.padding(SPACING_STANDARD.dp)
390+
) {
391+
Column(
392+
modifier = Modifier
393+
.padding(PADDING_CARD.dp)
394+
.fillMaxWidth(),
395+
horizontalAlignment = Alignment.CenterHorizontally
396+
) {
397+
Icon(
398+
imageVector = Icons.Filled.Error,
399+
contentDescription = "Error",
400+
tint = Color(COLOR_ERROR),
401+
modifier = Modifier.size(ICON_SIZE_SUCCESS.dp)
402+
)
403+
Spacer(Modifier.height(SPACING_STANDARD.dp))
404+
Text(
405+
text = when (model.errorType) {
406+
MdocEngagementPresenter.ErrorType.NFC_TAP_TOO_SHORT -> "NFC Tap Too Short"
407+
MdocEngagementPresenter.ErrorType.GENERAL -> "Error"
408+
},
409+
color = fg,
410+
fontWeight = FontWeight.Bold,
411+
fontSize = FONT_SIZE_TITLE.sp
412+
)
413+
Spacer(Modifier.height(SPACING_TINY.dp))
414+
Text(
415+
text = model.errorMessage,
416+
color = fg.copy(alpha = ALPHA_HIGH),
417+
fontSize = FONT_SIZE_BODY.sp,
418+
textAlign = TextAlign.Center
419+
)
420+
}
421+
}
422+
}
423+
Spacer(modifier = Modifier.height(SPACING_STANDARD.dp))
424+
Button(
425+
onClick = { model.onStateEvent(MdocEngagementPresenter.UiStateEvent.Stopped) },
426+
colors = ButtonDefaults.buttonColors(
427+
containerColor = fg.copy(alpha = BUTTON_ALPHA_LOW),
428+
contentColor = fg
429+
),
430+
modifier = Modifier.fillMaxWidth().height(BUTTON_HEIGHT.dp)
431+
) { Text("Close") }
432+
}
433+
}
434+
371435
@Composable
372436
@Suppress("UnusedParameter") // model parameter required by interface but not used in this implementation
373437
private fun RenderSharing(model: MdocEngagementPresenter.Model.Sharing, fg: Color) {
@@ -408,13 +472,13 @@ class MdocEngagementRenderer(
408472
// Use ScannerWithPermissions and debounce scan events
409473
ScannerWithPermissions(
410474
onScanned = { result: String ->
411-
println("🔍 Scanner detected QR: '${result.take(50)}...' (length: ${result.length})")
475+
println("Scanner detected QR: '${result.take(50)}...' (length: ${result.length})")
412476
if (!hasScanned && result.isNotBlank()) {
413-
println("Passing to callback...")
477+
println("Passing to callback...")
414478
hasScanned = true
415479
onQrScanned(result)
416480
} else {
417-
println("Rejected (hasScanned=$hasScanned, isBlank=${result.isBlank()})")
481+
println("Rejected (hasScanned=$hasScanned, isBlank=${result.isBlank()})")
418482
}
419483
true // Return true to stop scanning
420484
},
@@ -679,6 +743,7 @@ private const val COLOR_PURPLE = 0xFF7C40E8
679743
private const val COLOR_PURPLE_LIGHT = 0xFF7276F7
680744
private const val COLOR_ACCENT_BLUE = 0xFF0B81FF
681745
private const val COLOR_SUCCESS = 0xFF31C26E
746+
private const val COLOR_ERROR = 0xFFE84040
682747
private const val COLOR_CARD_BG = 0xFF2F364C
683748
private const val COLOR_BORDER_GRAY = 0xFF5D6990
684749
private const val PADDING_HORIZONTAL = 16
@@ -703,6 +768,7 @@ private const val ICON_SIZE_SUCCESS = 64
703768
private const val FONT_SIZE_TITLE = 24
704769
private const val FONT_SIZE_BODY = 16
705770
private const val DELAY_SUCCESS_MS = 1500L
771+
private const val DELAY_ERROR_MS = 3000L
706772
private const val SELECTION_PROMPT_HEIGHT = 80
707773
private const val CORNER_RADIUS = 6
708774
private const val MINI_CARD_WIDTH = 83

example/holder/ui/elicense/public/src/commonMain/kotlin/com/sphereon/kiwa/sample/ui/elicense/engagement/qr/MdocEngagementPresenter.kt

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,26 @@ interface MdocEngagementPresenter : MoleculePresenter<Unit, MdocEngagementPresen
9898

9999
@Immutable
100100
data class Success(override val onStateEvent: (event: UiStateEvent) -> Unit) : Model
101+
102+
/**
103+
* Error state displayed when engagement fails (e.g., NFC tap too short).
104+
*/
105+
@Immutable
106+
data class Error(
107+
val errorType: ErrorType,
108+
val errorMessage: String,
109+
override val onStateEvent: (event: UiStateEvent) -> Unit
110+
) : Model
111+
}
112+
113+
/**
114+
* Types of errors that can occur during engagement.
115+
*/
116+
enum class ErrorType {
117+
/** NFC tap was too short - reader couldn't complete handover */
118+
NFC_TAP_TOO_SHORT,
119+
/** General engagement error */
120+
GENERAL
101121
}
102122

103123
sealed interface UiStateEvent {

example/holder/xcframework/COCOAPODS.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ pod 'KiwaSdk', :podspec => '/path/to/kiwa-sample/example/holder/xcframework/buil
5959

6060
# Option C: Use a private spec repo
6161
source 'https://github.com/your-org/private-podspecs.git'
62-
pod 'KiwaSdk', '~> 0.13.1-SNAPSHOT'
62+
pod 'KiwaSdk', '~> 0.13.1'
6363
```
6464

6565
### 3. Install Dependencies
@@ -78,9 +78,9 @@ If you prefer to integrate the XCFramework directly without CocoaPods:
7878
```bash
7979
# For snapshots
8080
curl -u "$KIWA_REPO_USER:$KIWA_REPO_PASSWORD" \
81-
-O "https://nexus.sphereon.com/repository/kiwa-snapshots/com/sphereon/kiwa/kiwa-sdk-ios/0.13.1-SNAPSHOT/kiwa-sdk-ios-0.13.1-SNAPSHOT.zip"
81+
-O "https://nexus.sphereon.com/repository/kiwa-snapshots/com/sphereon/kiwa/kiwa-sdk-ios/0.13.1/kiwa-sdk-ios-0.13.1.zip"
8282

83-
unzip kiwa-sdk-ios-0.13.1-SNAPSHOT.zip
83+
unzip kiwa-sdk-ios-0.13.1.zip
8484
```
8585

8686
### 2. Add to Xcode Project

example/holder/xcframework/gradle.properties

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ kotlin.mpp.androidSourceSetLayoutVersion=2
3636
android.useAndroidX=true
3737
android.nonTransitiveRClass=true
3838
ksp.useKSP2=false
39-
version=0.13.1-SNAPSHOT
39+
version=0.13.1
4040
kotlin.native.enableKlibsCrossCompilation=false
4141
org.jetbrains.dokka.experimental.gradle.pluginMode=V2Enabled
4242
org.jetbrains.dokka.experimental.gradle.pluginMode.noWarn=true

0 commit comments

Comments
 (0)