git.iko.soy/opencode-android
on 61cdc75 help sign in
540 lines 27 kB history raw
1package soy.iko.opencode.ui.components
3import android.util.Base64
4import androidx.compose.foundation.background
5import androidx.compose.foundation.clickable
6import androidx.compose.foundation.gestures.detectTapGestures
7import androidx.compose.foundation.gestures.detectTransformGestures
8import androidx.compose.foundation.gestures.detectVerticalDragGestures
9import androidx.compose.foundation.layout.Box
10import androidx.compose.foundation.layout.Column
11import androidx.compose.foundation.layout.WindowInsets
12import androidx.compose.foundation.layout.fillMaxSize
13import androidx.compose.foundation.layout.fillMaxWidth
14import androidx.compose.foundation.layout.heightIn
15import androidx.compose.foundation.layout.navigationBars
16import androidx.compose.foundation.layout.padding
17import androidx.compose.foundation.layout.statusBars
18import androidx.compose.foundation.layout.windowInsetsPadding
19import androidx.compose.material3.CircularProgressIndicator
20import androidx.compose.material3.Icon
21import androidx.compose.material3.MaterialTheme
22import androidx.compose.material3.Text
23import androidx.compose.runtime.Composable
24import androidx.compose.runtime.Immutable
25import androidx.compose.runtime.getValue
26import androidx.compose.runtime.mutableFloatStateOf
27import androidx.compose.runtime.mutableIntStateOf
28import androidx.compose.runtime.mutableStateOf
29import androidx.compose.runtime.produceState
30import androidx.compose.runtime.remember
31import androidx.compose.runtime.rememberCoroutineScope
32import androidx.compose.runtime.setValue
33import androidx.compose.ui.Alignment
34import androidx.compose.ui.Modifier
35import androidx.compose.ui.draw.clip
36import androidx.compose.ui.graphics.graphicsLayer
37import androidx.compose.ui.input.pointer.pointerInput
38import androidx.compose.ui.layout.ContentScale
39import androidx.compose.ui.platform.LocalContext
40import androidx.compose.ui.res.stringResource
41import androidx.compose.ui.semantics.Role
42import androidx.compose.ui.semantics.contentDescription
43import androidx.compose.ui.semantics.invisibleToUser
44import androidx.compose.ui.semantics.semantics
45import androidx.compose.ui.text.style.TextOverflow
46import androidx.compose.ui.unit.dp
47import androidx.compose.ui.window.Dialog
48import androidx.compose.ui.window.DialogProperties
49import coil.compose.SubcomposeAsyncImage
50import coil.request.ImageRequest
51import kotlinx.coroutines.Dispatchers
52import kotlinx.coroutines.launch
53import kotlin.math.absoluteValue
54import androidx.compose.animation.core.Animatable
55import kotlinx.coroutines.withContext
56import androidx.compose.material.icons.Icons
57import androidx.compose.material.icons.filled.BrokenImage
58import androidx.compose.material.icons.filled.Close
59import androidx.compose.material.icons.filled.MoreVert
60import soy.iko.opencode.R
61import soy.iko.opencode.data.model.FilePart
62import soy.iko.opencode.data.model.ServerProfile
63import soy.iko.opencode.data.model.sourcePath
64import soy.iko.opencode.data.network.CertPinUtil
65import soy.iko.opencode.data.network.HttpClientFactory
66import soy.iko.opencode.data.network.NetworkConfig
67import soy.iko.opencode.util.runCatchingCancellable
69/**
70 * Carries the bits needed to load an image off the opencode server: the base URL (for
71 * resolving relative `url`s) and an optional HTTP Basic header for protected servers.
72 */
73@Immutable
74data class ImageLoadContext(
75 val baseUrl: String,
76 val basicAuthHeader: String?,
79/** Build an [ImageLoadContext] from a resolved connection profile (password included). */
80fun ServerProfile.toImageContext(): ImageLoadContext {
81 val auth = if (hasAuth && !password.isNullOrEmpty()) {
82 val raw = "$username:$password".toByteArray()
83 "Basic " + Base64.encodeToString(raw, Base64.NO_WRAP)
84 } else {
85 null
86 }
87 // Resolve image URLs against the HTTPS-upgraded base URL, not the raw stored one, so a
88 // requireHttps (or certificate-pinned) profile never fetches an image — with the Basic auth
89 // header attached — over cleartext http even when the stored URL is http://. This mirrors
90 // how HttpClientFactory computes the effective base URL for the REST/SSE channel.
91 return ImageLoadContext(baseUrl = CertPinUtil.effectiveBaseUrl(this), basicAuthHeader = auth)
94/** True if the part references a raster/vector image we can render. */
95val FilePart.isImage: Boolean
96 get() = mime?.startsWith("image/") == true
98private fun decodeDataUri(data: String): ByteArray? {
99 val comma = data.indexOf(',')
100 if (comma < 0) return null
101 val header = data.substring(0, comma)
102 val payload = data.substring(comma + 1)
103 // Only ";base64" payloads are Base64; a plain/percent-encoded data URI (e.g.
104 // "data:image/svg+xml,%3Csvg…") must be percent-decoded to bytes instead of Base64-decoded,
105 // which would throw and render the broken-image icon.
106 return runCatching {
107 if (header.contains(";base64", ignoreCase = true)) {
108 Base64.decode(payload, Base64.DEFAULT)
109 } else {
110 android.net.Uri.decode(payload).toByteArray()
111 }
112 }.getOrNull()
115/**
116 * Resolve a [FilePart] to a Coil-loadable model: a decoded [ByteArray] for inline
117 * data URIs, an absolute URL string otherwise. Returns null when nothing loadable.
118 */
119private fun FilePart.resolveModel(ctx: ImageLoadContext): Any? {
120 val src = sourcePath
121 if (!src.isNullOrBlank() && src.startsWith("data:")) return decodeDataUri(src)
122 val url = url ?: return null
123 // Attachments are persisted as a data URL in `part.url`, so a data URI can arrive here too.
124 if (url.startsWith("data:")) return decodeDataUri(url)
125 // Resolve the URL (absolute, relative, or server-absolute like "/media/x.png") against
126 // the base, collapsing any ../ segments. Both absolute and relative forms must clear the
127 // same-origin check below — an absolute foreign URL (e.g. "https://evil.com/x.png") would
128 // otherwise get the server's Basic auth attached and leak the credentials to that host.
129 val base = HttpClientFactory.normalizeBaseUrl(ctx.baseUrl)
130 val baseUri = runCatching { java.net.URI(base) }.getOrElse { return null }
131 val resolved = runCatching {
132 java.net.URI(base).resolve(url).normalize()
133 }.getOrElse { return null }
134 // Guard on same origin (scheme + host + port), not base-path prefix: the real risk is
135 // sending the request (with its Basic auth) to another host. A server-absolute path
136 // resolves to a different base *path* but the same origin, so it must still be allowed.
137 // Compare *effective* ports (substituting the scheme default when unspecified) so an
138 // absolute image URL that spells out the default port — "https://host:443/x.png" while
139 // the base is "https://host/" — isn't wrongly rejected as cross-origin (URI.port is -1
140 // when omitted, so a raw `==` would treat 443 and -1 as different).
141 val sameOrigin = resolved.host != null &&
142 resolved.scheme.equals(baseUri.scheme, ignoreCase = true) &&
143 resolved.host.equals(baseUri.host, ignoreCase = true) &&
144 effectivePort(resolved) == effectivePort(baseUri)
145 return if (sameOrigin) resolved.toString() else null
148/** The URI's port, or the scheme's default (80/443) when unspecified (`port == -1`). */
149private fun effectivePort(uri: java.net.URI): Int {
150 if (uri.port != -1) return uri.port
151 return when (uri.scheme?.lowercase()) {
152 "https" -> 443
153 "http" -> 80
154 else -> -1
155 }
158/**
159 * Renders an image attachment from a [FilePart]. Handles inline data URIs (no network)
160 * and server-relative URLs (fetched with Basic auth when the server requires it).
161 * Falls back to nothing when the part has no loadable source.
162 */
163@Composable
164fun RemoteImage(part: FilePart, ctx: ImageLoadContext, modifier: Modifier = Modifier) {
165 val context = LocalContext.current
166 // Distinguish "still resolving off-thread" from "resolved to nothing loadable": produceState
167 // seeds a sentinel so the first (pre-resolve) frame renders nothing (as before), but once
168 // resolveModel returns null — a cross-origin URL (blocked so the server's Basic auth can't
169 // leak off-origin), a malformed data URI, or a source with no path — we show the broken-image
170 // fallback instead of an empty gap (PartView already committed to RemoteImage, so there's no
171 // FileChip to fall through to).
172 val model = produceState<Any?>(initialValue = ImageResolving, part.source, part.url, ctx.baseUrl) {
173 value = withContext(Dispatchers.Default) { part.resolveModel(ctx) }
174 }.value
175 if (model === ImageResolving) return
176 if (model == null) {
177 ImageStatusBox { BrokenImageIcon() }
178 return
179 }
180 // Bumping retryKey re-builds the ImageRequest, so a failed load can be re-issued by tapping
181 // the error state instead of being a permanent broken-image icon.
182 var retryKey by remember(part.source, part.url, ctx.baseUrl) { mutableIntStateOf(0) }
183 // Fullscreen zoomable viewer state: opened by tapping the inline image.
184 var showFullscreen by remember { mutableStateOf(false) }
185 val request = remember(part.source, part.url, ctx.baseUrl, ctx.basicAuthHeader, model, retryKey) {
186 ImageRequest.Builder(context)
187 .data(model)
188 .apply {
189 // resolveModel already verified same-origin, so a String model can only point at
190 // the user's own server — attaching Basic auth over http (not just https) is safe.
191 // A data-URI model is a ByteArray, so gate on the model being a URL String.
192 if (model is String) ctx.basicAuthHeader?.let { addHeader("Authorization", it) }
193 }
194 .crossfade(true)
195 .build()
196 }
197 SubcomposeAsyncImage(
198 model = request,
199 contentDescription = part.filename ?: stringResource(R.string.image),
200 contentScale = ContentScale.FillWidth,
201 modifier = modifier
202 .heightIn(max = NetworkConfig.inlineImageMaxHeightDp.dp)
203 .clip(MaterialTheme.shapes.small)
204 // Tap to open a fullscreen zoomable viewer so the user can inspect details
205 // without the inline 320dp height cap.
206 .clickable(role = Role.Button) { showFullscreen = true },
207 loading = {
208 Box(
209 modifier = Modifier.fillMaxWidth().heightIn(min = NetworkConfig.inlineImageMinHeightDp.dp),
210 contentAlignment = Alignment.Center,
211 ) {
212 val loadingLabel = stringResource(R.string.loading)
213 CircularProgressIndicator(
214 modifier = Modifier.semantics { contentDescription = loadingLabel },
215 )
216 }
217 },
218 error = { ImageRetry(onRetry = { retryKey++ }) },
219 )
220 if (showFullscreen) {
221 FullscreenImageViewer(request = request, contentDescription = part.filename ?: stringResource(R.string.image)) {
222 showFullscreen = false
223 }
224 }
227/**
228 * Fullscreen zoomable image viewer opened by tapping an inline [RemoteImage]. Supports
229 * pinch-to-zoom and pan via [detectTransformGestures], double-tap to toggle zoom, and
230 * re-centers when zoom returns to 1×. Uses a non-interactive Dialog window so it overlays
231 * the whole screen. Offers Save-to-gallery and Share actions via the top bar.
232 */
233@Composable
234private fun FullscreenImageViewer(
235 request: ImageRequest,
236 contentDescription: String,
237 onDismiss: () -> Unit,
238) {
239 // Scale is an Animatable so a double-tap smoothly tweens between 1× and the zoom target,
240 // while pinch-to-zoom drives it via snapTo (no animation) for immediate 1:1 finger tracking.
241 val scale = remember { Animatable(1f) }
242 var offsetX by remember { mutableFloatStateOf(0f) }
243 var offsetY by remember { mutableFloatStateOf(0f) }
244 val scope = rememberCoroutineScope()
245 val context = LocalContext.current
246 // Double-tap zoom tween, collapsed to an instant snap under reduced motion. Resolved in
247 // the composable body (not inside the pointerInput coroutine) since it reads the
248 // composition's LocalReducedMotion.
249 val zoomSpec = rememberMotionTween<Float>(NetworkConfig.imageViewerZoomAnimMs)
250 // Swipe-to-dismiss: track the vertical drag offset. When the image is at 1× (not zoomed
251 // or panned) and the user drags down beyond a threshold, dismiss the viewer — a common
252 // gesture for photo viewers. The drag also fades the background so the dismiss reads as
253 // a continuous motion rather than a snap.
254 var dragOffsetY by remember { mutableFloatStateOf(0f) }
255 val dismissThreshold = NetworkConfig.imageViewerSwipeDismissThreshold
256 var showOverflow by remember { mutableStateOf(false) }
257 val saveLabel = stringResource(R.string.save_image)
258 val shareLabel = stringResource(R.string.share_image)
259 val savedLabel = stringResource(R.string.image_saved)
260 val saveFailedLabel = stringResource(R.string.image_save_failed)
261 Dialog(
262 onDismissRequest = onDismiss,
263 properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false),
264 ) {
265 Box(
266 modifier = Modifier
267 .fillMaxSize()
268 .background(androidx.compose.ui.graphics.Color.Black.copy(alpha = (1f - (dragOffsetY.absoluteValue / dismissThreshold)).coerceIn(0f, 1f)))
269 .pointerInput(Unit) {
270 detectTransformGestures { _, pan, zoom, _ ->
271 // Clamp the zoom so the image can't be scaled down below 1× (which would
272 // leave empty letterbox) or blown up so far it becomes a handful of pixels.
273 val newScale = (scale.value * zoom).coerceIn(1f, NetworkConfig.imageViewerMaxZoom)
274 scope.launch { scale.snapTo(newScale) }
275 if (newScale <= 1f) {
276 // Returning to 1× recenters so a prior pan doesn't leave the image
277 // stuck off-center at its rest size.
278 offsetX = 0f
279 offsetY = 0f
280 } else {
281 offsetX += pan.x
282 offsetY += pan.y
283 }
284 }
285 }
286 // Swipe-to-dismiss: only when the image is at 1× (not zoomed/panned), a
287 // vertical drag accumulates an offset; crossing the threshold dismisses.
288 .pointerInput(scale.value) {
289 if (scale.value <= 1.001f) {
290 detectVerticalDragGestures(
291 onDragEnd = {
292 if (dragOffsetY.absoluteValue >= dismissThreshold) {
293 onDismiss()
294 } else {
295 // Animate the snap-back to rest. A simple reset; the user's
296 // finger is already lifting so a full spring isn't needed.
297 dragOffsetY = 0f
298 }
299 },
300 onVerticalDrag = { _, dragAmount ->
301 // Only accumulate downward drags (positive Y) for dismiss, so an
302 // upward swipe doesn't accidentally trigger it.
303 if (dragAmount > 0) dragOffsetY += dragAmount
304 },
305 )
306 }
307 }
308 // Tap handling on the same surface: a double-tap toggles zoom (animated, with a
309 // recenter on the way back to 1×); a single tap dismisses the viewer. detectTapGestures
310 // defers the single-tap callback until it knows the touch isn't the first of a pair.
311 .pointerInput(Unit) {
312 detectTapGestures(
313 onDoubleTap = {
314 scope.launch {
315 if (scale.value > 1.001f) {
316 offsetX = 0f
317 offsetY = 0f
318 scale.animateTo(1f, zoomSpec)
319 } else {
320 scale.animateTo(NetworkConfig.imageViewerDoubleTapZoom, zoomSpec)
321 }
322 }
323 },
324 onTap = { onDismiss() },
325 )
326 },
327 contentAlignment = Alignment.Center,
328 ) {
329 SubcomposeAsyncImage(
330 model = request,
331 contentDescription = contentDescription,
332 contentScale = ContentScale.Fit,
333 modifier = Modifier
334 .fillMaxSize()
335 .graphicsLayer(
336 scaleX = scale.value,
337 scaleY = scale.value,
338 translationX = offsetX,
339 // Apply the swipe-drag offset on top of any pan offset so the image
340 // follows the finger during a dismiss swipe.
341 translationY = offsetY + dragOffsetY,
342 ),
343 )
344 // Close button in the top corner, inset below the status bar so it doesn't sit
345 // under the notch / status bar overlay on edge-to-edge devices.
346 androidx.compose.material3.IconButton(
347 onClick = onDismiss,
348 modifier = Modifier
349 .align(Alignment.TopEnd)
350 .windowInsetsPadding(WindowInsets.statusBars)
351 .padding(end = 8.dp),
352 ) {
353 Icon(
354 Icons.Filled.Close,
355 contentDescription = stringResource(R.string.close),
356 tint = androidx.compose.ui.graphics.Color.White,
357 )
358 }
359 // Overflow with Save-to-gallery and Share. Save decodes the request's bytes via
360 // Coil and writes them to MediaStore (Pictures/opencode); Share fires ACTION_SEND
361 // with a content Uri. Both run off the main thread on the coroutine scope.
362 Box(
363 modifier = Modifier
364 .align(Alignment.TopStart)
365 .windowInsetsPadding(WindowInsets.statusBars)
366 .padding(start = 8.dp),
367 ) {
368 androidx.compose.material3.IconButton(onClick = { showOverflow = true }) {
369 Icon(
370 Icons.Filled.MoreVert,
371 contentDescription = stringResource(R.string.more_options),
372 tint = androidx.compose.ui.graphics.Color.White,
373 )
374 }
375 androidx.compose.material3.DropdownMenu(
376 expanded = showOverflow,
377 onDismissRequest = { showOverflow = false },
378 ) {
379 androidx.compose.material3.DropdownMenuItem(
380 text = { Text(saveLabel) },
381 onClick = {
382 showOverflow = false
383 scope.launch {
384 val ok = runCatchingCancellable { saveImageToGallery(context, request, contentDescription) }.isSuccess
385 showToast(context, if (ok) savedLabel else saveFailedLabel)
386 }
387 },
388 )
389 androidx.compose.material3.DropdownMenuItem(
390 text = { Text(shareLabel) },
391 onClick = {
392 showOverflow = false
393 scope.launch {
394 runCatchingCancellable { shareImage(context, request, contentDescription) }
395 .onFailure { showToast(context, saveFailedLabel) }
396 }
397 },
398 )
399 }
400 }
401 // Show the alt text / filename as a caption at the bottom of the viewer so a user
402 // can tell what the image is (e.g. an agent-named diagram) without leaving fullscreen.
403 // The contentDescription is already used by the image itself for TalkBack; the caption
404 // is hidden from a11y here to avoid double-announcing.
405 if (contentDescription.isNotBlank() && contentDescription != stringResource(R.string.image)) {
406 Box(
407 modifier = Modifier
408 .align(Alignment.BottomCenter)
409 .fillMaxWidth()
410 .background(androidx.compose.ui.graphics.Color.Black.copy(alpha = 0.5f))
411 .windowInsetsPadding(WindowInsets.navigationBars)
412 .padding(horizontal = 16.dp, vertical = 8.dp),
413 ) {
414 Text(
415 contentDescription,
416 color = androidx.compose.ui.graphics.Color.White,
417 style = MaterialTheme.typography.bodySmall,
418 maxLines = 2,
419 overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
420 modifier = Modifier.semantics { invisibleToUser() },
421 )
422 }
423 }
424 }
425 }
428/** Error slot for [RemoteImage]: a broken-image icon plus a "Tap to retry" caption, the whole box
429 * clickable so a failed load can be re-issued instead of stranding a permanent broken icon. */
430@Composable
431private fun ImageRetry(onRetry: () -> Unit) {
432 val label = stringResource(R.string.tap_to_retry)
433 Box(
434 modifier = Modifier
435 .fillMaxWidth()
436 .heightIn(min = NetworkConfig.inlineImageMinHeightDp.dp)
437 .clickable(role = Role.Button, onClick = onRetry),
438 contentAlignment = Alignment.Center,
439 ) {
440 Column(horizontalAlignment = Alignment.CenterHorizontally) {
441 BrokenImageIcon()
442 Text(
443 label,
444 style = MaterialTheme.typography.labelSmall,
445 color = MaterialTheme.colorScheme.onSurfaceVariant,
446 modifier = Modifier.padding(top = 4.dp),
447 )
448 }
449 }
452/** Sentinel for [RemoteImage]'s produceState while resolveModel runs off-thread, so the initial
453 * (still-resolving) frame is distinguishable from a resolved-to-null (unloadable) result. */
454private val ImageResolving = Any()
456/** A centered, fixed-min-height box matching the loading/error slots so an image placeholder
457 * reserves the same space whether it's loading, failed, or unresolvable. */
458@Composable
459private fun ImageStatusBox(content: @Composable () -> Unit) {
460 Box(
461 modifier = Modifier.fillMaxWidth().heightIn(min = NetworkConfig.inlineImageMinHeightDp.dp),
462 contentAlignment = Alignment.Center,
463 ) { content() }
466@Composable
467private fun BrokenImageIcon() {
468 Icon(
469 Icons.Filled.BrokenImage,
470 contentDescription = stringResource(R.string.image_failed),
471 tint = MaterialTheme.colorScheme.onSurfaceVariant,
472 )
475/** Decode the image behind [request] via Coil and write it to the system MediaStore
476 * (Pictures/opencode) so it appears in the gallery. Returns true on success. Runs on a
477 * background dispatcher; callers should use runCatchingCancellable. */
478private suspend fun saveImageToGallery(
479 context: android.content.Context,
480 request: ImageRequest,
481 displayName: String,
482): Boolean {
483 val bitmap = decodeRequestBitmap(context, request) ?: return false
484 val resolver = context.contentResolver
485 val name = (displayName.ifBlank { "opencode-image" }).take(100)
486 val timestamp = System.currentTimeMillis() / 1000
487 val values = android.content.ContentValues().apply {
488 put(android.provider.MediaStore.Images.Media.DISPLAY_NAME, "$name-${timestamp}.png")
489 put(android.provider.MediaStore.Images.Media.MIME_TYPE, "image/png")
490 if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
491 put(android.provider.MediaStore.Images.Media.RELATIVE_PATH, "Pictures/opencode")
492 put(android.provider.MediaStore.Images.Media.IS_PENDING, 1)
493 }
494 }
495 val uri = resolver.insert(android.provider.MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
496 ?: return false
497 return runCatching {
498 resolver.openOutputStream(uri)?.use { out ->
499 bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, out)
500 } ?: error("no output stream")
501 if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
502 values.clear()
503 values.put(android.provider.MediaStore.Images.Media.IS_PENDING, 0)
504 resolver.update(uri, values, null, null)
505 }
506 true
507 }.getOrDefault(false)
510/** Decode the image behind [request] to a Bitmap via Coil's ImageLoader. Returns null on
511 * failure. Suspending so callers run it on a background dispatcher. */
512private suspend fun decodeRequestBitmap(context: android.content.Context, request: ImageRequest): android.graphics.Bitmap? {
513 return soy.iko.opencode.util.runCatchingCancellableSuspend {
514 val loader = coil.Coil.imageLoader(context)
515 val result = loader.execute(request)
516 (result.drawable as? android.graphics.drawable.BitmapDrawable)?.bitmap
517 }.getOrNull()
520/** Share the image behind [request] via ACTION_SEND. Writes a temp file to the cache dir and
521 * grants the chooser read access via FileProvider. */
522private suspend fun shareImage(
523 context: android.content.Context,
524 request: ImageRequest,
525 displayName: String,
526) {
527 val bitmap = decodeRequestBitmap(context, request) ?: error("decode failed")
528 val name = (displayName.ifBlank { "opencode-image" }).take(80)
529 val file = java.io.File(context.cacheDir, "share/$name-${System.currentTimeMillis()}.png")
530 file.parentFile?.mkdirs()
531 file.outputStream().use { bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, it) }
532 val authority = "${context.packageName}.fileprovider"
533 val uri = androidx.core.content.FileProvider.getUriForFile(context, authority, file)
534 val intent = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
535 type = "image/png"
536 putExtra(android.content.Intent.EXTRA_STREAM, uri)
537 addFlags(android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION)
538 }
539 context.startActivity(android.content.Intent.createChooser(intent, displayName))