fix: qualify mobile Cloud viewer and companion downloads
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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<WebDownload?>(null) }
|
||||
var active by remember { mutableStateOf<WebDownload?>(null) }
|
||||
var progress by remember { mutableStateOf<Pair<Long, Long>>(0L to -1L) }
|
||||
var failure by remember { mutableStateOf<String?>(null) }
|
||||
var job by remember { mutableStateOf<Job?>(null) }
|
||||
val currentCall = remember { java.util.concurrent.atomic.AtomicReference<Call?>(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." }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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("<html>Sign in</html>"))
|
||||
val out = ByteArrayOutputStream()
|
||||
assertThrows(IOException::class.java) { streamWebDownload(spec(server.url("/file").toString()), out, OkHttpClient()) }
|
||||
assertEquals(0, out.size())
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user