From efe421b162384c2863f905764823607d65c843cf Mon Sep 17 00:00:00 2001 From: evgeny Date: Thu, 29 Oct 2026 02:55:14 +0100 Subject: [PATCH] Restore viewer window state and handle photo decoding errors --- .../utun/chat/ui/screens/PhotoViewerScreen.kt | 33 ++++------- .../chat/ui/screens/ViewerFullscreenWindow.kt | 59 +++++++++++++++++++ 2 files changed, 69 insertions(+), 23 deletions(-) create mode 100644 tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ViewerFullscreenWindow.kt diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/PhotoViewerScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/PhotoViewerScreen.kt index 66835e07..2ce9ba86 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/PhotoViewerScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/PhotoViewerScreen.kt @@ -2,9 +2,6 @@ package com.utun.chat.ui.screens import com.utun.chat.R import androidx.compose.ui.res.stringResource -import android.app.Activity -import android.content.Context -import android.content.ContextWrapper import android.graphics.Bitmap import androidx.activity.compose.BackHandler import androidx.compose.foundation.background @@ -19,43 +16,33 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp -import androidx.core.view.WindowCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.WindowInsetsControllerCompat import com.utun.chat.data.LogManager import com.utun.chat.data.PhotoUtil import com.utun.chat.ui.components.ZoomableImage import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.withContext private const val MAX_DECODE_DIM = 4096 -private tailrec fun Context.findActivity(): Activity? = when (this) { - is Activity -> this - is ContextWrapper -> baseContext.findActivity() - else -> null -} - +/** Локальное изображение с EXIF и Fit/zoom; декодирование в IO ограничено стороной 4096 пикселей. + * Экран владеет Bitmap через Compose; смена пути отменяет доставку старого результата. Ошибка оставляет доступным выход. */ @Composable fun PhotoViewerScreen(filePath: String, onClose: () -> Unit) { - val context = LocalContext.current var bmp by remember(filePath) { mutableStateOf(null) } var loaded by remember(filePath) { mutableStateOf(false) } BackHandler(onBack = onClose) - /* ── immersive: hide system bars ── */ - DisposableEffect(Unit) { - val act = context.findActivity() - val controller = act?.let { WindowCompat.getInsetsController(it.window, it.window.decorView) } - controller?.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE - controller?.hide(WindowInsetsCompat.Type.systemBars()) - onDispose { controller?.show(WindowInsetsCompat.Type.systemBars()) } - } + ViewerFullscreenWindow("photo") LaunchedEffect(filePath) { - bmp = withContext(Dispatchers.IO) { PhotoUtil.decodeFitted(filePath, MAX_DECODE_DIM) } + bmp = try { withContext(Dispatchers.IO) { PhotoUtil.decodeFitted(filePath, MAX_DECODE_DIM) } } + catch (e: CancellationException) { throw e } + catch (e: Exception) { + LogManager.addLog("ERROR", "PhotoViewer", "decode exception path=$filePath: $e") + null + } loaded = true if (bmp == null) LogManager.addLog("ERROR", "PhotoViewer", "decode failed: $filePath") else LogManager.addLog("DEBUG", "PhotoViewer", "Opened image ${bmp?.width}x${bmp?.height} path=$filePath") diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ViewerFullscreenWindow.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ViewerFullscreenWindow.kt new file mode 100644 index 00000000..e9e910a9 --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ViewerFullscreenWindow.kt @@ -0,0 +1,59 @@ +package com.utun.chat.ui.screens + +import android.app.Activity +import android.content.ContextWrapper +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.core.view.ViewCompat +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat +import com.utun.chat.data.LogManager + +/** Immersive-режим viewer без смены ориентации; возвращает прежнюю видимость обеих панелей и поведение swipe. + * Вызывающий экран должен быть единственным владельцем fullscreen окна. identity — диагностическое имя без секретов. */ +@Composable +internal fun ViewerFullscreenWindow(identity: String) { + val context = LocalContext.current + val activity = remember(context) { + generateSequence(context) { (it as? ContextWrapper)?.baseContext }.filterIsInstance().firstOrNull() + } + DisposableEffect(activity, identity) { + val window = activity?.window + val controller = window?.let { WindowCompat.getInsetsController(it, it.decorView) } + val insets = window?.let { ViewCompat.getRootWindowInsets(it.decorView) } + val statusVisible = insets?.isVisible(WindowInsetsCompat.Type.statusBars()) ?: true + val navigationVisible = insets?.isVisible(WindowInsetsCompat.Type.navigationBars()) ?: true + val behavior = controller?.systemBarsBehavior + if (controller != null) try { + controller.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + controller.hide(WindowInsetsCompat.Type.systemBars()) + LogManager.addLog("INFO", "Viewer", "Fullscreen entered viewer=$identity") + } catch (e: Exception) { + LogManager.addLog("ERROR", "Viewer", "Cannot enter fullscreen viewer=$identity: $e") + } + onDispose { + if (controller != null && behavior != null) { + val restore: List Unit>> = listOf( + "behavior" to { controller.systemBarsBehavior = behavior }, + "status" to { + if (statusVisible) controller.show(WindowInsetsCompat.Type.statusBars()) + else controller.hide(WindowInsetsCompat.Type.statusBars()) + }, + "navigation" to { + if (navigationVisible) controller.show(WindowInsetsCompat.Type.navigationBars()) + else controller.hide(WindowInsetsCompat.Type.navigationBars()) + } + ) + var failures = 0 + for ((part, action) in restore) try { action() } catch (e: Exception) { + failures++ + LogManager.addLog("ERROR", "Viewer", "Window restore failed viewer=$identity part=$part: $e") + } + if (failures == 0) LogManager.addLog("INFO", "Viewer", "Window restored viewer=$identity") + } + } + } +}