diff --git a/Android/app/build.gradle.kts b/Android/app/build.gradle.kts index 53371229..50dc1039 100644 --- a/Android/app/build.gradle.kts +++ b/Android/app/build.gradle.kts @@ -11,8 +11,8 @@ android { applicationId = "com.archipelago.app" minSdk = 26 targetSdk = 35 - versionCode = 52 - versionName = "0.5.32" + versionCode = 54 + versionName = "0.5.34" vectorDrawables { useSupportLibrary = true @@ -142,6 +142,9 @@ tasks.matching { }.configureEach { dependsOn("buildRustArm64") } dependencies { + testImplementation("junit:junit:4.13.2") + testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0") + testImplementation("org.robolectric:robolectric:4.14.1") val composeBom = platform("androidx.compose:compose-bom:2024.05.00") implementation(composeBom) diff --git a/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewDownloads.kt b/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewDownloads.kt new file mode 100644 index 00000000..e24509d4 --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewDownloads.kt @@ -0,0 +1,179 @@ +package com.archipelago.app.ui.screens + +import android.app.Activity +import android.content.Intent +import android.net.Uri +import android.provider.DocumentsContract +import android.webkit.CookieManager +import android.webkit.DownloadListener +import android.webkit.URLUtil +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.* +import androidx.compose.ui.platform.LocalContext +import kotlinx.coroutines.* +import okhttp3.Call +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.IOException +import java.io.OutputStream +import java.util.concurrent.TimeUnit + +internal data class WebDownload(val url: String, val userAgent: String, val cookies: String, val name: String, val mime: String) + +/** Only the starting origin receives its WebView cookies, even across redirects. */ +internal fun streamWebDownload( + download: WebDownload, + output: OutputStream, + client: OkHttpClient, + onCall: (Call) -> Unit = {}, + checkCancelled: () -> Unit = {}, + onProgress: (Long, Long) -> Unit = { _, _ -> }, +): Long { + val transport = client.newBuilder().followRedirects(false).followSslRedirects(false).build() + val original = download.url.toHttpUrlOrNull() ?: throw IOException("Unsupported download link") + var url = original + var redirects = 0 + while (true) { + checkCancelled() + if (url.username.isNotEmpty() || url.password.isNotEmpty()) throw IOException("Unsupported download link") + val request = Request.Builder().url(url).header("User-Agent", download.userAgent) + if (url.scheme == original.scheme && url.host == original.host && url.port == original.port && download.cookies.isNotBlank()) { + request.header("Cookie", download.cookies) + } + val call = transport.newCall(request.build()) + onCall(call) + call.execute().use { response -> + if (response.code in listOf(301, 302, 303, 307, 308)) { + if (++redirects > 5) throw IOException("Too many download redirects") + val next = response.header("Location")?.let { url.resolve(it) } ?: throw IOException("Invalid download redirect") + if (url.isHttps && !next.isHttps) throw IOException("Insecure download redirect blocked") + url = next + } else { + if (response.code == 401 || response.code == 403) throw IOException("Sign in to the node again, then retry the download") + if (!response.isSuccessful) throw IOException("Download failed (HTTP ${response.code})") + if (response.header("Content-Type")?.substringBefore(';')?.trim()?.lowercase() == "text/html" && + download.mime != "text/html" && !download.name.endsWith(".html", true) && !download.name.endsWith(".htm", true)) { + throw IOException("Sign in to the node again, then retry the download") + } + val body = response.body ?: throw IOException("The download was empty") + val total = body.contentLength() + var written = 0L + body.byteStream().use { input -> + val buffer = ByteArray(64 * 1024) + var lastUpdate = 0L + while (true) { + checkCancelled() + val count = input.read(buffer) + if (count == -1) break + output.write(buffer, 0, count) + written += count + val now = System.nanoTime() + if (now - lastUpdate > 100_000_000L) { onProgress(written, total); lastUpdate = now } + } + } + if (total >= 0 && written != total) throw IOException("Download interrupted; please retry") + onProgress(written, total) + return written + } + } + } +} + +/** Uses the system Save dialog: no broad storage permission and no external browser login. */ +@Composable +internal fun rememberWebViewDownloads(): DownloadListener { + val context = LocalContext.current + val scope = rememberCoroutineScope() + var pending by remember { mutableStateOf(null) } + var active by remember { mutableStateOf(null) } + var progress by remember { mutableStateOf>(0L to -1L) } + var failure by remember { mutableStateOf(null) } + var job by remember { mutableStateOf(null) } + val currentCall = remember { java.util.concurrent.atomic.AtomicReference(null) } + val client = remember { + OkHttpClient.Builder().followRedirects(false).followSslRedirects(false) + .connectTimeout(20, TimeUnit.SECONDS).readTimeout(60, TimeUnit.SECONDS).build() + } + fun cancel() { job?.cancel(); currentCall.getAndSet(null)?.cancel() } + DisposableEffect(Unit) { onDispose { currentCall.getAndSet(null)?.cancel() } } + val save = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + val download = pending + pending = null + val uri = result.data?.data + if (result.resultCode != Activity.RESULT_OK || uri == null || download == null) return@rememberLauncherForActivityResult + job = scope.launch { + active = download + progress = 0L to -1L + var complete = false + try { + withContext(Dispatchers.IO) { + val task = currentCoroutineContext() + context.contentResolver.openOutputStream(uri, "w")?.use { output -> + streamWebDownload(download, output, client, + onCall = { call -> currentCall.set(call); if (!task.isActive) call.cancel() }, + checkCancelled = { task.ensureActive() }, + onProgress = { done, total -> scope.launch { progress = done to total } }) + } ?: throw IOException("Unable to open the selected destination") + } + complete = true + Toast.makeText(context, "Download complete: ${download.name}", Toast.LENGTH_LONG).show() + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + if (currentCoroutineContext().isActive) { + // Do not expose authenticated URLs or request headers in UI/logs. + failure = when { + error is javax.net.ssl.SSLException -> "The server certificate could not be verified." + error is IOException && error.message?.startsWith("Sign in") == true -> error.message + else -> "Download failed. Check your connection and available storage, then try again." + } + } + } finally { + currentCall.getAndSet(null)?.cancel() + if (!complete) withContext(NonCancellable + Dispatchers.IO) { + // This URI was newly created by ACTION_CREATE_DOCUMENT; never remove an existing user file. + runCatching { DocumentsContract.deleteDocument(context.contentResolver, uri) } + } + active = null + job = null + } + } + } + if (active != null) { + AlertDialog(onDismissRequest = {}, title = { Text("Downloading") }, text = { + Column { + Text(active!!.name) + if (progress.second > 0) LinearProgressIndicator(progress = (progress.first.toFloat() / progress.second).coerceIn(0f, 1f)) + else LinearProgressIndicator() + } + }, confirmButton = {}, dismissButton = { TextButton(onClick = { cancel() }) { Text("Cancel") } }) + } + failure?.let { message -> + AlertDialog(onDismissRequest = { failure = null }, title = { Text("Download unavailable") }, + text = { Text(message) }, confirmButton = { TextButton(onClick = { failure = null }) { Text("OK") } }) + } + return DownloadListener { url, userAgent, disposition, mimeType, _ -> + if (active != null || pending != null) { + Toast.makeText(context, "Finish or cancel the current download first", Toast.LENGTH_SHORT).show() + } else if (url.toHttpUrlOrNull() == null) { + failure = "This download link is not supported. Open the file from Cloud and try again." + } else { + val mime = mimeType?.substringBefore(';')?.takeIf { it.contains('/') } ?: "application/octet-stream" + val name = URLUtil.guessFileName(url, disposition, mime).replace(Regex("[\\\\/\\p{Cntrl}]"), "_").take(180).ifBlank { "download" } + pending = WebDownload(url, userAgent ?: "Archipelago Companion", CookieManager.getInstance().getCookie(url).orEmpty(), name, mime) + try { + save.launch(Intent(Intent.ACTION_CREATE_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE); type = mime; putExtra(Intent.EXTRA_TITLE, name) + }) + } catch (_: Exception) { pending = null; failure = "No file-saving app is available on this device." } + } + } +} diff --git a/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewFullscreen.kt b/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewFullscreen.kt new file mode 100644 index 00000000..5b9c2e91 --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewFullscreen.kt @@ -0,0 +1,99 @@ +package com.archipelago.app.ui.screens + +import android.content.Context +import android.content.ContextWrapper +import android.graphics.Color +import android.view.View +import android.view.ViewGroup +import android.webkit.WebChromeClient +import android.widget.FrameLayout +import androidx.activity.ComponentActivity +import androidx.activity.OnBackPressedCallback +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 + +private fun Context.fullscreenActivity(): ComponentActivity? = when (this) { + is ComponentActivity -> this + is ContextWrapper -> baseContext.takeIf { it !== this }?.fullscreenActivity() + else -> null +} + +/** Hosts Chromium's custom fullscreen view without replacing or reloading its WebView. */ +internal class WebViewFullscreen(private val activity: ComponentActivity?) { + private var overlay: FrameLayout? = null + private var callback: WebChromeClient.CustomViewCallback? = null + private var back: OnBackPressedCallback? = null + private var visibleBars = 0 + private var originalBehavior = 0 + + fun show(view: View?, onHidden: WebChromeClient.CustomViewCallback?) { + val owner = activity + // A second enter must not detach the active video or strand its callback. + if (owner == null || owner.isFinishing || owner.isDestroyed || view == null || + view.parent != null || overlay != null + ) { + onHidden?.onCustomViewHidden() + return + } + val decor = owner.window.decorView as? ViewGroup + if (decor == null) { onHidden?.onCustomViewHidden(); return } + val controller = WindowCompat.getInsetsController(owner.window, decor) + val insets = ViewCompat.getRootWindowInsets(decor) + visibleBars = 0 + if (insets?.isVisible(WindowInsetsCompat.Type.statusBars()) != false) { + visibleBars = visibleBars or WindowInsetsCompat.Type.statusBars() + } + if (insets?.isVisible(WindowInsetsCompat.Type.navigationBars()) != false) { + visibleBars = visibleBars or WindowInsetsCompat.Type.navigationBars() + } + originalBehavior = controller.systemBarsBehavior + val host = FrameLayout(owner).apply { + setBackgroundColor(Color.BLACK) + keepScreenOn = true + addView(view, FrameLayout.LayoutParams(-1, -1)) + } + overlay = host + callback = onHidden + decor.addView(host, ViewGroup.LayoutParams(-1, -1)) + controller.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + controller.hide(WindowInsetsCompat.Type.systemBars()) + back = object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() = hide() + }.also { owner.onBackPressedDispatcher.addCallback(it) } + view.requestFocus() + } + + fun hide() { + val host = overlay ?: return + // Clear first: Chromium may synchronously call onHideCustomView again. + overlay = null + val notify = callback + callback = null + back?.remove() + back = null + host.keepScreenOn = false + host.removeAllViews() + (host.parent as? ViewGroup)?.removeView(host) + activity?.let { owner -> + val controller = WindowCompat.getInsetsController(owner.window, owner.window.decorView) + controller.systemBarsBehavior = originalBehavior + controller.hide(WindowInsetsCompat.Type.systemBars()) + if (visibleBars != 0) controller.show(visibleBars) + } + notify?.onCustomViewHidden() + } +} + +@Composable +internal fun rememberWebViewFullscreen(): WebViewFullscreen { + val context = LocalContext.current + val fullscreen = remember(context) { WebViewFullscreen(context.fullscreenActivity()) } + DisposableEffect(fullscreen) { onDispose { fullscreen.hide() } } + return fullscreen +} diff --git a/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt b/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt index 06f38099..dea0e713 100644 --- a/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt +++ b/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt @@ -611,6 +611,8 @@ fun WebViewScreen( // before surfacing the error page: the mesh tunnel works from anywhere. meshFallbackUrl: String? = null, ) { + val fullscreen = rememberWebViewFullscreen() + val downloads = rememberWebViewDownloads() var isLoading by remember { mutableStateOf(true) } // First kiosk load (often over the FIPS mesh) gets the full branded // loader; later navigations keep just the slim top progress bar. @@ -913,6 +915,7 @@ fun WebViewScreen( cookieManager.setAcceptThirdPartyCookies(this, true) applyArchipelagoSettings() + setDownloadListener(downloads) settings.apply { setSupportMultipleWindows(true) // enables onCreateWindow for window.open // Let JS open windows without a synchronous user-gesture @@ -1181,6 +1184,12 @@ fun WebViewScreen( } webChromeClient = object : WebChromeClient() { + override fun onShowCustomView(view: android.view.View?, callback: CustomViewCallback?) { + fullscreen.show(view, callback) + } + + override fun onHideCustomView() = fullscreen.hide() + override fun onProgressChanged(view: WebView?, newProgress: Int) { loadProgress = newProgress } @@ -1546,6 +1555,8 @@ private fun InAppBrowser( appName: String? = null, onClose: () -> Unit, ) { + val fullscreen = rememberWebViewFullscreen() + val downloads = rememberWebViewDownloads() val context = LocalContext.current // Same-node check across BOTH node addresses (LAN + mesh ULA) — see the // kiosk's isSameNode; a mismatch here bounced app links to the browser. @@ -1643,6 +1654,7 @@ private fun InAppBrowser( CookieManager.getInstance().setAcceptThirdPartyCookies(this, true) applyArchipelagoSettings() + setDownloadListener(downloads) // Node apps (BTCPay invoices, LND, Portainer tokens) are // served over plain HTTP too — same dead-clipboard trap. addClipboardBridge() @@ -1662,6 +1674,12 @@ private fun InAppBrowser( ) webChromeClient = object : WebChromeClient() { + override fun onShowCustomView(view: android.view.View?, callback: CustomViewCallback?) { + fullscreen.show(view, callback) + } + + override fun onHideCustomView() = fullscreen.hide() + override fun onProgressChanged(view: WebView?, newProgress: Int) { progress = newProgress } diff --git a/Android/app/src/test/java/com/archipelago/app/ui/screens/WebViewDownloadsTest.kt b/Android/app/src/test/java/com/archipelago/app/ui/screens/WebViewDownloadsTest.kt new file mode 100644 index 00000000..9efde7ee --- /dev/null +++ b/Android/app/src/test/java/com/archipelago/app/ui/screens/WebViewDownloadsTest.kt @@ -0,0 +1,88 @@ +package com.archipelago.app.ui.screens + +import okhttp3.OkHttpClient +import okhttp3.ResponseBody.Companion.toResponseBody +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okio.Buffer +import org.junit.Assert.* +import org.junit.Test +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.util.concurrent.CancellationException + +class WebViewDownloadsTest { + private fun spec(url: String) = WebDownload(url, "test-agent", "session=test-only", "file.bin", "application/octet-stream") + @Test fun authenticatedDownloadWritesExactBytesAndReportsCompletion() { + MockWebServer().use { server -> + val bytes = ByteArray(256 * 1024 + 13) { (it % 251).toByte() } + server.enqueue(MockResponse().setBody(Buffer().write(bytes))) + val out = ByteArrayOutputStream() + var progress = 0L to 0L + assertEquals(bytes.size.toLong(), streamWebDownload(spec(server.url("/file").toString()), out, OkHttpClient(), onProgress = { done, total -> progress = done to total })) + assertArrayEquals(bytes, out.toByteArray()) + assertEquals(bytes.size.toLong() to bytes.size.toLong(), progress) + assertEquals("session=test-only", server.takeRequest().getHeader("Cookie")) + } + } + @Test fun sameOriginRedirectKeepsSessionButCrossOriginNeverReceivesIt() { + MockWebServer().use { first -> MockWebServer().use { second -> + second.enqueue(MockResponse().setBody("final")) + first.enqueue(MockResponse().setResponseCode(302).addHeader("Location", "/relative")) + first.enqueue(MockResponse().setResponseCode(307).addHeader("Location", second.url("/target"))) + val out = ByteArrayOutputStream() + streamWebDownload(spec(first.url("/start").toString()), out, OkHttpClient()) + assertEquals("final", out.toString()) + assertEquals("session=test-only", first.takeRequest().getHeader("Cookie")) + assertEquals("session=test-only", first.takeRequest().getHeader("Cookie")) + assertNull(second.takeRequest().getHeader("Cookie")) + } } + } + @Test fun authenticationFailureDoesNotSaveErrorBody() { + MockWebServer().use { server -> + server.enqueue(MockResponse().setResponseCode(401).setBody("login required")) + val out = ByteArrayOutputStream() + val error = assertThrows(IOException::class.java) { streamWebDownload(spec(server.url("/").toString()), out, OkHttpClient()) } + assertTrue(error.message!!.startsWith("Sign in")) + assertEquals(0, out.size()) + } + } + @Test fun redirectsAreBoundedAndUnsafeSchemesAreRejected() { + MockWebServer().use { server -> + repeat(6) { server.enqueue(MockResponse().setResponseCode(302).addHeader("Location", "/loop")) } + assertThrows(IOException::class.java) { streamWebDownload(spec(server.url("/loop").toString()), ByteArrayOutputStream(), OkHttpClient()) } + assertEquals(6, server.requestCount) + } + for (url in listOf("file:///etc/passwd", "data:text/plain,test", "blob:test")) { + assertThrows(IOException::class.java) { streamWebDownload(spec(url), ByteArrayOutputStream(), OkHttpClient()) } + } + } + @Test fun cancellationAndDestinationFailureAreNotReportedAsComplete() { + MockWebServer().use { server -> + server.enqueue(MockResponse().setBody("bytes")) + assertThrows(CancellationException::class.java) { streamWebDownload(spec(server.url("/").toString()), ByteArrayOutputStream(), OkHttpClient(), checkCancelled = { throw CancellationException() }) } + assertEquals(0, server.requestCount) + var progressCalled = false + val out = object : java.io.OutputStream() { override fun write(b: Int) { throw IOException("disk full") } } + assertThrows(IOException::class.java) { streamWebDownload(spec(server.url("/").toString()), out, OkHttpClient(), onProgress = { _, _ -> progressCalled = true }) } + assertFalse(progressCalled) + } + } + @Test fun tlsDowngradeAndLoginHtmlAreRejected() { + var requests = 0 + val client = OkHttpClient.Builder().addInterceptor { chain -> + requests++ + okhttp3.Response.Builder().request(chain.request()).protocol(okhttp3.Protocol.HTTP_1_1) + .code(302).message("redirect").header("Location", "http://example.test/file").body("".toResponseBody(null)).build() + }.build() + assertThrows(IOException::class.java) { streamWebDownload(spec("https://example.test/file"), ByteArrayOutputStream(), client) } + assertEquals(1, requests) + MockWebServer().use { server -> + server.enqueue(MockResponse().addHeader("Content-Type", "Text/HTML; charset=utf-8").setBody("Sign in")) + val out = ByteArrayOutputStream() + assertThrows(IOException::class.java) { streamWebDownload(spec(server.url("/file").toString()), out, OkHttpClient()) } + assertEquals(0, out.size()) + } + } + +} diff --git a/Android/app/src/test/java/com/archipelago/app/ui/screens/WebViewFullscreenTest.kt b/Android/app/src/test/java/com/archipelago/app/ui/screens/WebViewFullscreenTest.kt new file mode 100644 index 00000000..59e32775 --- /dev/null +++ b/Android/app/src/test/java/com/archipelago/app/ui/screens/WebViewFullscreenTest.kt @@ -0,0 +1,71 @@ +package com.archipelago.app.ui.screens + +import android.view.View +import android.widget.FrameLayout +import androidx.activity.ComponentActivity +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, sdk = [28, 35]) +class WebViewFullscreenTest { + @Test fun backExitsFullscreenWithoutFinishingActivityAndNotifiesOnce() { + val lifecycle = Robolectric.buildActivity(ComponentActivity::class.java).setup() + try { + val activity = lifecycle.get() + val fullscreen = WebViewFullscreen(activity) + val video = View(activity) + var hidden = 0 + fullscreen.show(video) { hidden++ } + assertNotNull(video.parent) + activity.onBackPressedDispatcher.onBackPressed() + assertNull(video.parent) + assertFalse(activity.isFinishing) + assertEquals(1, hidden) + fullscreen.hide() + assertEquals(1, hidden) + } finally { lifecycle.pause().stop().destroy() } + } + + @Test fun duplicateRequestPreservesActiveViewAndCanReenterAfterExit() { + val lifecycle = Robolectric.buildActivity(ComponentActivity::class.java).setup() + try { + val activity = lifecycle.get() + val fullscreen = WebViewFullscreen(activity) + val first = View(activity) + val second = View(activity) + var firstHidden = 0 + var secondHidden = 0 + fullscreen.show(first) { firstHidden++ } + fullscreen.show(second) { secondHidden++ } + assertNotNull(first.parent) + assertNull(second.parent) + assertEquals(0, firstHidden) + assertEquals(1, secondHidden) + fullscreen.hide() + fullscreen.show(second) { secondHidden++ } + assertNotNull(second.parent) + fullscreen.hide() + assertEquals(1, firstHidden) + assertEquals(2, secondHidden) + } finally { lifecycle.pause().stop().destroy() } + } + + @Test fun rejectsOwnedViewWithoutReparentingAndHandlesUnavailableActivity() { + val lifecycle = Robolectric.buildActivity(ComponentActivity::class.java).setup() + try { + val activity = lifecycle.get() + val video = View(activity) + val owner = FrameLayout(activity).apply { addView(video) } + var hidden = 0 + WebViewFullscreen(activity).show(video) { hidden++ } + assertSame(owner, video.parent) + WebViewFullscreen(null).show(null) { hidden++ } + assertEquals(2, hidden) + } finally { lifecycle.pause().stop().destroy() } + } +} diff --git a/docs/post-1.8.22-regressions-20261001.md b/docs/post-1.8.22-regressions-20261001.md index 65203015..75ea34d7 100644 --- a/docs/post-1.8.22-regressions-20261001.md +++ b/docs/post-1.8.22-regressions-20261001.md @@ -1425,3 +1425,120 @@ Nostr additions. The checker now rejects stale descriptions/dates for an existin version; its regression passes. Latest notes UI built and deployed dev/yaya index SHA256 `97aab07e67eccc1bb3d215534b537b83e1372bf5c36b505b6127a24a2b629e23`. Phone background/reconnect acceptance question is pending, not passed. + +## Mobile Cloud media viewer — 2026-10-05 release addition + +Operator screenshot shows the filename behind the companion status bar and a +cramped video viewer. Confirmed mobile CSS positioned every toolbar button at the +same coordinates; fullscreen was only attached to a video double-click, and the +viewer ignored the companion's `--safe-area-top/bottom` values. Legacy global +lightbox maximum dimensions also constrained the component unexpectedly. + +The viewer now reserves separate safe-area-aware title, media and action rows on +phones, keeps each action at least44px without shrinking, and places previous/next +beside the action row rather than over the media. Videos retain their intrinsic +picture ratio with native playback controls. Photos and videos have a labeled +fullscreen action: standard fullscreen where supported, native Safari video +fallback, and an expandable in-page viewer when embedded browsers deny it. +Escape/exit and keyboard focus remain usable. Decode failures offer retry, and +late media requests cannot replace a newly selected file or leak their blob URL. + +Validation:11component tests pass, including existing PiP handoff, fullscreen +success/denial/Safari fallback, decode retry, fetch ordering and focus restoration. +Real Chromium checks at320x568,390x844,844x390 and1440x900 pass safe-area/control +geometry, fullscreen and exit, generated video playback and close. Screenshots +were inspected. This does not claim physical companion/Safari acceptance. +Evidence: `/tmp/archy-190-lightbox-focused.log`, +`/tmp/archy-190-lightbox-browser.log`; repeatable browser fixture +`tests/lifecycle/media-lightbox-browser.cjs`. + +The operator separately accepted both physical phone upload background/reconnect +and Framework Cloud folder/upload/open checks. Those upload manual gates are +closed; this newly reported media viewer gate is separate. The ongoing candidate +ISO and staged OTA predate this addition and must not be published as final; +regenerate final artifacts and hashes after this fix is qualified. + +### Permanent file menus and companion fullscreen follow-up + +The operator additionally reported that touch users cannot reach hover-only file +actions without opening the file. Grid and list cards now have a permanent44px +translucent ellipsis action, and owned-file lightboxes expose the same menu. +Share/download/delete are available without opening the underlying file; delete +requires a separate explicit confirmation. Unsupported actions are omitted for +non-owned callers. The menu stays within the viewport, participates in native +fullscreen, traps keyboard focus, dismisses on Escape/outside tap and restores +focus. Cloud-folder delete failures now remain visible instead of becoming an +unhandled rejected promise. + +Sixteen focused component tests pass. Real Chromium grid/list touch checks pass +at320,390and1440px, covering permanent visibility, unclipped menus, share and +cancelled delete with no preview triggered. Lightbox action access also passes +inside fullscreen at all four prior viewport sizes. The actual deployed dev +Cloud screenshot and3840x2160video decode successfully (75.633seconds, no media +error); native Chromium fullscreen/exit/close pass. No operator files changed. + +The Android companion has no existing `onShowCustomView` implementation. Added a +shared fullscreen host to both dashboard and app WebViews: retains the current +WebView, accepts Chromium's custom view, hides system bars with swipe escape, +handles Back and Chromium exit, restores prior bars, releases the view on screen +disposal and rejects duplicate/reparented requests. Kotlin compilation passes. +Companion version0.5.33/build53 is reserved for this change. Lifecycle tests, +clean signed APK build and physical companion acceptance remain required; the +web fallback is not evidence of native Android fullscreen acceptance. + +Updated qualification: all **1,222 frontend tests / 150 files** pass, production +build passes, and the permanent menus are deployed on the dev box. Live browser +checks pass for real Cloud photo/video decode, card-menu access without preview, +cancelled deletion, and the viewer's action menu during fullscreen. No files were +deleted or shared by the tests. Android's three lifecycle tests pass (zero errors +or failures). The clean APK build initially failed because the expected signing +keystore was absent. Recovered the existing local key after matching its public +certificate exactly to the currently served APK; a clean packaging retry is in +progress. No replacement signing identity was generated, and no private signing +material is included in this change. + +Companion packaging exposed an additional release-script defect: noisy successful +`apksigner` output caused `printf | grep -q` under `pipefail` to return141/SIGPIPE, +rejecting an APK whose v1/v2/v3 verification results were all true. The publisher +now uses input redirection for these checks and additionally pins the existing +companion certificate, protecting in-place update compatibility. Four executable +regressions exercise the actual verification block: large valid output, missing +signature schemes, wrong signer and verifier failure. All pass; the test is +included in `tests/release/run.sh`. A fresh canonical clean/package/sign run is +required after this script fix; no failed packaging attempt is marked published. + +### Companion download regression — operator report after build53 + +The operator accepted the improved viewer, then reported Download did nothing, +confirmed companion-only. Real Chromium downloaded the exact Cloud screenshot +bytes (202,648 bytes; matching SHA256), so this is separate from the web menu. +Both companion WebViews lack a general DownloadListener. Added a shared native +Save dialog/download handler, with bounded streaming, existing WebView cookies, +progress, cancellation and deletion of the newly created incomplete destination +on failure. Redirects retain cookies only for the starting origin, HTTPS +downgrades are rejected, and authentication/login-page failures do not save an +error page as the user's file. TLS verification stays enabled. No broad storage +permission is introduced. Blob/data URLs currently report unsupported instead of +silently doing nothing; own Cloud files use authenticated HTTP(S) raw URLs. + +Companion0.5.34/build54 is reserved for this repair. Compile, network regression +suite, canonical clean signing, dev deployment and a real phone download remain +required. Build53 must not be described as having working companion downloads. +The existing fullscreen suite also passed its Android28+35 matrix: six cases, +zero failures/errors. No release or APK fleet publication has occurred. + +Download validation update: the sequential clean Android build and all12tests +pass (six network-download cases plus six fullscreen lifecycle cases across +Android28/35). Tests cover exact authenticated bytes/progress, same-origin versus +cross-origin redirects, bounded redirects, unsupported URLs, auth failure, +cancellation, destination failure, TLS downgrade refusal and login HTML rejection. +XML evidence was preserved before packaging in +`/tmp/archy-190-companion-download-test-results/`. The canonical clean0.5.34/build54 +APK package passes v1/v2/v3 verification and the existing signing-certificate pin; +the APK contains the new download handler. Fleet publication remains held pending +physical save/open acceptance and the existing release gates. + +2026-10-05 operator acceptance: companion0.5.34/build54 phone download check +(save/open/cancel) accepted: “works, we can proceed”. Viewer and physical upload +acceptance retained. Close this manual gate; other release/security/Angor gates +remain open. No fleet OTA, ISO or public demo publication inferred. diff --git a/docs/release-1.9.0-acceptance.md b/docs/release-1.9.0-acceptance.md index 8f883cfc..aa0d7267 100644 --- a/docs/release-1.9.0-acceptance.md +++ b/docs/release-1.9.0-acceptance.md @@ -12,19 +12,18 @@ whenever human acceptance is needed. ## Current evidence -- Latest alpha backend source: isolated suite 1,681 passed, zero failed, - four explicit ignores; separate container runtime suite 82 passed. Optimized - build including the Nostr security and File Browser changes is in progress. -- Frontend: full suite 1,211 passed across 148 files; production UI and AIUI - builds passed. Real dev desktop/mobile upload fault injection passes, including - interrupted JWT refresh and exact saved-file hashes. -- Currently deployed backend on dev/yaya SHA256 - `e218e40f5c16c3d0cc4dc06c0a378c14b56b9087ded5c515b8a48351ceea3bcf`. - It includes Nostr/File Browser fixes but predates the alpha version suffix. - Private rollback backups exist. -- Latest deployed UI index SHA256 on dev/yaya - `67de835a25db59a483314eff583b809c3468c8529080bfa74c9962e44a6f54f9`. - Both served byte checks pass; unrelated production containers stayed unchanged. +- Alpha backend: isolated suite **1,681 passed**, zero failed, four explicit + ignores; separate container runtime suite **82 passed**. Optimized binary + SHA256 `a5a6cfc7dcab011963a6f18dc570446b6fa624180d206a108fab27504f4fd41d` + is deployed on dev, yaya and Framework with private rollback backups. +- Frontend: **1,222 passed across 150 files**; production build and real mobile/ + desktop media/menu checks pass. Dev serves index SHA256 + `c18b24024a78789fe65c74c5ce27efe2125ae869016ab65e33c5a2680b543f17`. + Yaya/Framework still have the preceding qualified upload UI `67de835a…`. +- Companion **0.5.34/build54**: 12 native tests, clean build, v1/v2/v3 signatures + and unchanged signer pass. Viewer and phone download are operator accepted. + Dev APK SHA256 `ceb58a7dc5f1398fe84f30255ec9ed79834f5db5f8fbc03eac52e14186fab1a1`. + Fleet publication remains part of the final release. - NPM corrected gateway/client-IP integration: 23 Python checks and complete disposable real-image integration passed. Catalog generator now requires both migration-backup and legacy-gateway capabilities; its generator/drift selection @@ -41,9 +40,10 @@ whenever human acceptance is needed. - [ ] **NPM:** corrected private signature, dev/yaya selection and yaya override retirement now PASS (2026-10-05). Remaining: full boot/OTA/ISO and - staging-CA issuance/renewal and full legacy-backend migration/rollback + full legacy-backend migration/rollback acceptance; retain the completed - fresh/nested disposable and actual yaya state-preservation checks. + fresh/nested disposable, public staging issuance/forced renewal and actual + yaya state-preservation checks. - [ ] **Shorty NPM:** shop certificate12/Force SSL are operator accepted and independently verified; qualify and apply the manual-route migration. Preserve live management containment and current public app routing. @@ -56,14 +56,14 @@ whenever human acceptance is needed. No production spending or channel closure is authorized by this checklist. - [ ] **Paid files:** finish buyer restart/outage and updated-seller persistence acceptance; preserve atomic ownership and safe retries without repayment. -- [ ] **Uploads:** prior physical companion flow is operator accepted. New - resumable-transfer requirement needs interrupted-network/background - recovery acceptance; viewport checks alone are not physical-phone proof. +- [x] **Uploads:** real dev/yaya interrupted-network, offset recovery, lost + replies, hashes, cancellation and compact origin-screen display pass. + Physical companion background/reconnect and Framework Cloud flow are + operator accepted. Final packaged-artifact checks remain below. - [ ] **File Browser credentials:** unique managed login, default-password removal, account/file preservation and rollback pass real Podman fixtures - including actual Quadlet restart. Deploy/verify dev and yaya, rerun yaya - upload tests, qualify Framework's reported auth issue, and verify packaged - OTA/ISO startup. Docker behavior is not inferred from Podman fixtures. + including actual Quadlet restart. Dev/yaya/Framework migration and Cloud + acceptance pass. Remaining: packaged OTA/ISO startup. Docker behavior is not inferred from Podman fixtures. - [ ] **Apps:** complete upgrade inventory matrix for installed/stopped/removed/ restarting/legacy aliases; Immich/retired-app removal and unexpected-service identification; Portainer/Gitea migration from actual request namespace. @@ -517,3 +517,45 @@ reported1.8.8. Existing Docker Compose demo deployment located read-only; do not confuse it with Yaya's v4v stack. Update after release, preserving rollback and qualifying mock backend compatibility with new Cloud uploads. No demo deployed yet. No OTA/catalog/ISO has been published. + +## 2026-10-05 additional mobile media and file-action qualification + +Operator accepted both physical phone upload recovery and Framework Cloud +folder/upload/open checks. These manual gates are closed. + +A subsequent Cloud screenshot and touch-action report introduced new release +requirements: safe-area-aware photo/video viewing, separated touch controls, +fullscreen/exit, permanent translucent file-card actions, and the same actions +inside the viewer. Source and component tests are in the regression ledger. +The complete updated frontend suite passes: **1,222 tests in 150 files**. +Production frontend build passes. Chromium checks cover phone portrait, +landscape and desktop geometry, native fullscreen, action-menu access while +fullscreen, video decoding/playback, no accidental preview from a menu tap, +and cancellation before deletion. The deployed dev box reads the operator's +actual screenshot and 4K video without changing either file. + +The new Android fullscreen callback implementation compiles and its three +Robolectric lifecycle tests pass with zero failures/errors. It still requires a +clean APK, signature verification and physical companion acceptance; compilation +and browser fullscreen do not establish that acceptance. Version 0.5.33/build53 +is reserved for the companion update. No native fullscreen APK published yet. + +Final OTA/frontend/ISO checksums and source attribution must be regenerated after +these additions. Candidate ISO build215 is superseded for publication purposes. +Previously recorded NPM fleet migration, exact signed OTA/rollback and ISO install +qualification, full-chain/Angor scope, mirror parity and public-demo requirements +remain open unless separately closed by direct evidence. No prior publication +hold is waived by the mobile test results. + +### Companion download qualification update + +Operator accepted the viewer but reported companion-only download failure. +Browser download of the same file matches its exact bytes. Added native saving +through Android's system file picker with authentication, streamed progress, +cancellation and error cleanup. The clean download/fullscreen suite passes all +12cases. Canonical clean companion0.5.34/build54 packaging passes v1/v2/v3 and +existing-signer verification. Dev serving is verified byte-for-byte with SHA256 +`ceb58a7dc5f1398fe84f30255ec9ed79834f5db5f8fbc03eac52e14186fab1a1`. +The operator reports “works, we can proceed” after the phone save/open/cancel +check. This physical companion download gate is **accepted (2026-10-05)**. The APK is staged for fleet OTA/ISO and official/demo downloads; +only the dev-box test download is updated now. No fleet/public release is claimed. diff --git a/neode-ui/public/packages/archipelago-companion.apk b/neode-ui/public/packages/archipelago-companion.apk index feb2d876..6bcb859e 100644 Binary files a/neode-ui/public/packages/archipelago-companion.apk and b/neode-ui/public/packages/archipelago-companion.apk differ diff --git a/neode-ui/public/packages/archipelago-companion.json b/neode-ui/public/packages/archipelago-companion.json index 976f846a..3af39937 100644 --- a/neode-ui/public/packages/archipelago-companion.json +++ b/neode-ui/public/packages/archipelago-companion.json @@ -1,4 +1,4 @@ { - "versionName": "0.5.32", - "versionCode": 52 + "versionName": "0.5.34", + "versionCode": 54 } diff --git a/neode-ui/src/components/__tests__/MediaLightboxMobile.test.ts b/neode-ui/src/components/__tests__/MediaLightboxMobile.test.ts new file mode 100644 index 00000000..9fb46510 --- /dev/null +++ b/neode-ui/src/components/__tests__/MediaLightboxMobile.test.ts @@ -0,0 +1,74 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import MediaLightbox from '../cloud/MediaLightbox.vue' +import type { FileBrowserItem } from '../../api/filebrowser-client' +const items = ['first.jpg', 'second.jpg', 'clip.mp4'].map(name => ({ name, path: '/' + name, isDir: false }) as FileBrowserItem) +const mounted: ReturnType[] = [] +async function viewer(extra = {}) { + const w = mount(MediaLightbox, { props: { items, startIndex: 0, show: true, fetchBlobUrl: vi.fn(async (p: string) => 'blob:' + p), ...extra }, attachTo: document.body }) + mounted.push(w); await flushPromises(); return w +} +const button = (name: string) => document.querySelector(`[aria-label="${name}"]`) as HTMLButtonElement +beforeEach(() => { URL.revokeObjectURL = vi.fn() }) +afterEach(() => { mounted.forEach(w => w.unmount()); mounted.length = 0; document.body.innerHTML = ''; vi.restoreAllMocks() }) +describe('mobile media viewer', () => { + it('requests fullscreen on the whole viewer for photos and exits it', async () => { + await viewer() + const backdrop = document.querySelector('.lightbox-backdrop') as HTMLElement + const request = vi.fn(async () => {}) + backdrop.requestFullscreen = request + button('Fullscreen').click(); await flushPromises() + expect(request).toHaveBeenCalledOnce() + expect(button('Exit fullscreen')).toBeTruthy() + Object.defineProperty(document, 'fullscreenElement', { configurable: true, value: backdrop }) + document.exitFullscreen = vi.fn(async () => {}) + button('Exit fullscreen').click(); await flushPromises() + expect(document.exitFullscreen).toHaveBeenCalledOnce() + Object.defineProperty(document, 'fullscreenElement', { configurable: true, value: null }) + }) + it('offers an exit when an embedded browser denies native fullscreen', async () => { + await viewer() + const backdrop = document.querySelector('.lightbox-backdrop') as HTMLElement + backdrop.requestFullscreen = vi.fn(async () => { throw new Error('Denied') }) + button('Fullscreen').click(); await flushPromises() + expect(backdrop.classList.contains('lightbox-expanded')).toBe(true) + button('Exit fullscreen').click(); await flushPromises() + expect(backdrop.classList.contains('lightbox-expanded')).toBe(false) + }) + it('uses the Safari video fullscreen fallback', async () => { + await viewer({ startIndex: 2 }) + const video = document.querySelector('video')! as HTMLVideoElement & { webkitEnterFullscreen: () => void } + video.webkitEnterFullscreen = vi.fn() + button('Fullscreen').click(); await flushPromises() + expect(video.webkitEnterFullscreen).toHaveBeenCalledOnce() + expect(video.hasAttribute('playsinline')).toBe(true) + }) + it('shows decode errors instead of leaving a broken video and can retry', async () => { + const streamUrl = vi.fn(async () => 'https://example.test/clip.mp4') + await viewer({ startIndex: 2, streamUrl }) + document.querySelector('video')!.dispatchEvent(new Event('error')); await flushPromises() + expect(document.querySelector('video')).toBeNull() + expect(document.querySelector('[role="alert"]')?.textContent).toContain('Unable to load') + ;(document.querySelector('.lightbox-retry') as HTMLButtonElement).click(); await flushPromises() + expect(streamUrl).toHaveBeenCalledTimes(2) + expect(document.querySelector('video')).toBeTruthy() + }) + it('ignores a late fetch from the previous photo and revokes its blob', async () => { + let resolveFirst!: (url: string) => void + const fetchBlobUrl = vi.fn((path: string) => path === '/first.jpg' ? new Promise(resolve => { resolveFirst = resolve }) : Promise.resolve('blob:second')) + await viewer({ fetchBlobUrl }) + button('Next file').click(); await flushPromises() + resolveFirst('blob:stale'); await flushPromises() + expect(document.querySelector('img')?.getAttribute('src')).toBe('blob:second') + expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:stale') + }) + it('restores focus and page scrolling after closing', async () => { + const origin = document.createElement('button'); document.body.append(origin); origin.focus() + document.body.style.overflow = 'auto' + const w = await viewer() + expect(document.body.style.overflow).toBe('hidden') + w.unmount(); mounted.splice(mounted.indexOf(w), 1) + expect(document.activeElement).toBe(origin) + expect(document.body.style.overflow).toBe('auto') + }) +}) diff --git a/neode-ui/src/components/cloud/FileActionsMenu.vue b/neode-ui/src/components/cloud/FileActionsMenu.vue new file mode 100644 index 00000000..58b795b1 --- /dev/null +++ b/neode-ui/src/components/cloud/FileActionsMenu.vue @@ -0,0 +1,92 @@ + + + diff --git a/neode-ui/src/components/cloud/FileCard.vue b/neode-ui/src/components/cloud/FileCard.vue index 0c449656..6328d146 100644 --- a/neode-ui/src/components/cloud/FileCard.vue +++ b/neode-ui/src/components/cloud/FileCard.vue @@ -1,5 +1,8 @@ + + diff --git a/neode-ui/src/components/cloud/FileCardGrid.vue b/neode-ui/src/components/cloud/FileCardGrid.vue index 352a9176..c4da5400 100644 --- a/neode-ui/src/components/cloud/FileCardGrid.vue +++ b/neode-ui/src/components/cloud/FileCardGrid.vue @@ -1,5 +1,8 @@ + + diff --git a/neode-ui/src/components/cloud/MediaLightbox.vue b/neode-ui/src/components/cloud/MediaLightbox.vue index 9adf112b..3221804e 100644 --- a/neode-ui/src/components/cloud/MediaLightbox.vue +++ b/neode-ui/src/components/cloud/MediaLightbox.vue @@ -4,7 +4,8 @@ @@ -314,6 +320,7 @@ watch([useNativeUI, section, routeFolderPath], async ([native, sec, path]) => { } }, { immediate: true }) +const actionError = ref(null) const shareTarget = ref<{ path: string; name: string; isDir: boolean } | null>(null) const lightboxIndex = ref(null) @@ -370,7 +377,9 @@ async function handleUpload(files: File[]) { } async function handleDelete(path: string) { - await cloudStore.deleteItem(path) + actionError.value = null + try { await cloudStore.deleteItem(path) } + catch (error) { actionError.value = error instanceof Error ? error.message : 'Unable to delete this file. Please try again.' } } async function handlePlay(path: string, name: string) { diff --git a/scripts/publish-companion-apk.sh b/scripts/publish-companion-apk.sh index 838c40a5..096a98e2 100755 --- a/scripts/publish-companion-apk.sh +++ b/scripts/publish-companion-apk.sh @@ -71,15 +71,22 @@ echo "publish-companion-apk: zipalign + sign (v1+v2+v3)…" >&2 "$SIGNED" # 4. Verify all three schemes (min-sdk 21 forces the v1 path to be exercised). -VERIFY="$("$APKSIGNER" verify -v --min-sdk-version 21 "$SIGNED" 2>&1)" +VERIFY="$("$APKSIGNER" verify -v --min-sdk-version 21 --print-certs "$SIGNED" 2>&1)" for scheme in "v1 scheme" "v2 scheme" "v3 scheme"; do - if ! printf '%s\n' "$VERIFY" | grep -iq "$scheme.*: true"; then + if ! grep -iq "$scheme.*: true" <<< "$VERIFY"; then echo "publish-companion-apk: ERROR — $scheme NOT present after signing. Aborting." >&2 printf '%s\n' "$VERIFY" | grep -iE "scheme" >&2 exit 1 fi done -echo "publish-companion-apk: verified v1 + v2 + v3 signatures." >&2 +# The existing shared companion identity must survive updates. Verifying a new +# certificate's mathematical validity alone does not establish upgrade compatibility. +EXPECTED_SIGNER_SHA256="d622e07e7f474246e8e2bcee828ca9bf6c6cdf9434c065c58fe89bb4eec2664d" +if ! grep -iq "Signer #1 certificate SHA-256 digest: $EXPECTED_SIGNER_SHA256" <<< "$VERIFY"; then + echo "publish-companion-apk: ERROR — signing identity differs from the existing companion. Aborting." >&2 + exit 1 +fi +echo "publish-companion-apk: verified v1 + v2 + v3 signatures and existing signer." >&2 # 5. Publish. mkdir -p "$(dirname "$DEST")" diff --git a/scripts/tests/test_companion_apk_verification.py b/scripts/tests/test_companion_apk_verification.py new file mode 100644 index 00000000..4578fb4d --- /dev/null +++ b/scripts/tests/test_companion_apk_verification.py @@ -0,0 +1,41 @@ +"""Run the publisher's actual verifier block, including noisy apksigner output.""" +import os +import pathlib +import subprocess +import tempfile +import unittest +ROOT = pathlib.Path(__file__).resolve().parents[2] +SCRIPT = (ROOT / 'scripts/publish-companion-apk.sh').read_text() +BLOCK = SCRIPT[SCRIPT.index('# 4. Verify'):SCRIPT.index('# 5. Publish')] +CERT = 'd622e07e7f474246e8e2bcee828ca9bf6c6cdf9434c065c58fe89bb4eec2664d' +GOOD = '\n'.join(f'Verified using {v} scheme (test): true' for v in ['v1', 'v2', 'v3']) + '\nSigner #1 certificate SHA-256 digest: ' + CERT + +class VerificationTests(unittest.TestCase): + def check(self, output, code=0): + with tempfile.TemporaryDirectory() as tmp: + root = pathlib.Path(tmp) + verifier = root / 'apksigner' + verifier.write_text('#!/bin/sh\ncat "$TEST_VERIFY_OUTPUT"\nexit "$TEST_VERIFY_CODE"\n') + verifier.chmod(0o700) + (root / 'output').write_text(output) + env = dict(os.environ, APKSIGNER=str(verifier), SIGNED=str(root / 'test.apk'), + TEST_VERIFY_OUTPUT=str(root / 'output'), TEST_VERIFY_CODE=str(code)) + return subprocess.run(['bash', '-c', 'set -euo pipefail\n' + BLOCK], env=env, capture_output=True, text=True).returncode + + def test_noisy_valid_apk_does_not_fail_from_grep_sigpipe(self): + # Reproduces pipefail+grep -q exit141 after an otherwise valid APK. + self.assertEqual(self.check(GOOD + '\n' + 'WARNING: compatible signature algorithm\n' * 4000), 0) + + def test_all_three_schemes_are_required(self): + for version in ['v1', 'v2', 'v3']: + with self.subTest(version=version): + self.assertNotEqual(self.check(GOOD.replace(f'{version} scheme (test): true', f'{version} scheme (test): false')), 0) + + def test_wrong_signing_identity_fails(self): + self.assertNotEqual(self.check(GOOD.replace(CERT, '0' * 64)), 0) + + def test_verifier_failure_is_never_accepted(self): + self.assertNotEqual(self.check(GOOD, 1), 0) + +if __name__ == '__main__': + unittest.main() diff --git a/tests/lifecycle/cloud-file-actions-browser.cjs b/tests/lifecycle/cloud-file-actions-browser.cjs new file mode 100644 index 00000000..42ccafeb --- /dev/null +++ b/tests/lifecycle/cloud-file-actions-browser.cjs @@ -0,0 +1,11 @@ +// Local Vite browser regression; creates no real files and performs no network deletions. +const { chromium, expect } = require('../../neode-ui/node_modules/@playwright/test'); +(async()=>{const source=await fetch((process.env.LIGHTBOX_TEST_URL||'http://127.0.0.1:5187')+'/src/stores/cloud.ts').then(r=>r.text());const piniaUrl=source.match(/from "([^"]*pinia\.js[^"]*)"/)[1];const b=await chromium.launch({headless:true});try{for(const width of [320,390,1440]){const p=await b.newPage({viewport:{width,height:844},hasTouch:true}); +p.on('pageerror',e=>console.error('fixture error',e.message)); +await p.route('**/actions-fixture',r=>r.fulfill({contentType:'text/html',body:`
`})); +await p.goto((process.env.LIGHTBOX_TEST_URL||'http://127.0.0.1:5187')+'/actions-fixture');const buttons=p.locator('.file-actions-trigger');await expect(buttons).toHaveCount(3,{timeout:30000}); +for(let i=0;i<3;i++){const trigger=buttons.nth(i);await expect(trigger).toBeVisible();const rect=await trigger.boundingBox();if(rect.width<44||rect.height<44||rect.x<0||rect.x+rect.width>width)throw Error('Invalid permanent action geometry');await trigger.tap();await expect(p.getByRole('dialog')).toBeVisible();const menu=await p.getByRole('dialog').boundingBox();if(menu.x<0||menu.x+menu.width>width||menu.y<0||menu.y+menu.height>844)throw Error('Menu clipped');await p.getByRole('button',{name:'Share with peers',exact:true}).tap();await expect.poll(()=>p.evaluate(()=>window.previews)).toBe(0);} +await buttons.first().tap();await p.getByRole('button',{name:'Delete…',exact:true}).tap();await expect.poll(()=>p.evaluate(()=>window.deletes)).toBe(0);await p.getByRole('button',{name:'Keep file',exact:true}).tap();await p.getByRole('button',{name:'Close menu',exact:true}).tap(); +await p.screenshot({path:'/tmp/archy-file-actions-'+width+'.png'});console.log('PASS',width,'permanent touch menu in grid/list, no accidental open, visible unclipped menu, share, cancel delete');await p.close();}}finally{await b.close()}})().catch(e=>{console.error(e);process.exit(1)}); diff --git a/tests/lifecycle/media-lightbox-browser.cjs b/tests/lifecycle/media-lightbox-browser.cjs new file mode 100644 index 00000000..5f617c90 --- /dev/null +++ b/tests/lifecycle/media-lightbox-browser.cjs @@ -0,0 +1,48 @@ +// Run against a local Vite dev server; no node credentials or user media involved. +const { chromium, expect } = require('../../neode-ui/node_modules/@playwright/test'); +const base = process.env.LIGHTBOX_TEST_URL || 'http://127.0.0.1:5187'; +(async () => { + const browser = await chromium.launch({ headless: true }); + try { + for (const [width,height] of [[320,568],[390,844],[844,390],[1440,900]]) { + const context = await browser.newContext({viewport:{width,height},hasTouch:width<900}); + const page = await context.newPage(); + const errors=[];page.on('pageerror',e=>errors.push(e.message)); + await page.route('**/lightbox-fixture',r=>r.fulfill({contentType:'text/html',body:`
`})); + await page.goto(base+'/lightbox-fixture'); + await expect(page.locator('.lightbox-media-img')).toBeVisible(); + await page.waitForTimeout(200); + const boxes=await page.locator('.lightbox-btn,.lightbox-nav,.file-actions-trigger').evaluateAll(es=>es.map(e=>{const r=e.getBoundingClientRect();return {name:e.getAttribute('aria-label'),x:r.x,y:r.y,w:r.width,h:r.height}})); + for(const a of boxes){if(a.w<44||a.h<44||a.x<0||a.x+a.w>width+.1||a.y<28||a.y+a.h>height-24+.1)throw Error('Unsafe control '+JSON.stringify(a));} + for(let i=0;ib.x&&a.yb.y)throw Error('Overlapping controls');} + const title=await page.locator('.lightbox-title').boundingBox();if(title.y<28)throw Error('Title overlaps system bar'); + await page.getByRole('button',{name:'Fullscreen',exact:true}).click(); + await expect.poll(()=>page.evaluate(()=>!!document.fullscreenElement)).toBe(true); + await page.locator('.file-actions-trigger').click();await expect(page.getByRole('link',{name:'Download',exact:true})).toBeVisible();await page.getByRole('button',{name:'Close menu',exact:true}).click(); + await page.getByRole('button',{name:'Exit fullscreen',exact:true}).click(); + await expect.poll(()=>page.evaluate(()=>!!document.fullscreenElement)).toBe(false); + // A short local landscape video exercises native controls without fetching a user file. + await page.evaluate(async()=>{const c=document.createElement('canvas');c.width=640;c.height=360;const g=c.getContext('2d');g.fillStyle='#234';g.fillRect(0,0,640,360);const stream=c.captureStream(10);const r=new MediaRecorder(stream,{mimeType:'video/webm'});const chunks=[];r.ondataavailable=e=>chunks.push(e.data);const done=new Promise(resolve=>r.onstop=resolve);r.start();for(let i=0;i<12;i++){g.fillStyle=i%2?'#234':'#345';g.fillRect(0,0,640,360);await new Promise(resolve=>setTimeout(resolve,50));}r.stop();await done;stream.getTracks().forEach(t=>t.stop());window.testVideoUrl=URL.createObjectURL(new Blob(chunks,{type:'video/webm'}));}); + await page.getByRole('button',{name:'Previous file',exact:true}).click(); + const video=page.locator('video');await expect(video).toBeVisible(); + await expect.poll(()=>video.evaluate(v=>v.readyState)).toBeGreaterThanOrEqual(2);await video.evaluate(v=>{v.loop=true;return v.play()});await expect.poll(()=>video.evaluate(v=>v.currentTime)).toBeGreaterThan(0); + const media=await video.boundingBox();if(media.height>height-110||media.width>width||await video.evaluate(v=>getComputedStyle(v).objectFit)!=='contain')throw Error('Video stretched or controls clipped '+JSON.stringify(media)); + await page.screenshot({path:'/tmp/archy-lightbox-'+width+'x'+height+'.png'}); + await page.getByRole('button',{name:'Close viewer',exact:true}).click();await expect(page.getByRole('dialog')).toHaveCount(0); + if(errors.length)throw Error(errors.join('\n')); + console.log('PASS',width,height,'safe areas, 44px nonoverlapping controls, image fullscreen/exit, real landscape video, close'); + await context.close(); + } + } finally {await browser.close()} +})().catch(e=>{console.error(e);process.exit(1)});