SHA256
feat(android): replace WebView with tested native Compose API slice
Add real native onboarding/auth/dashboard/tasks/notes/shopping, origin-bound HTTPS and encrypted AndroidKeyStore session persistence. Archive the superseded browser renderer and tests. Verify 10 JVM tests, 6 native API-35 emulator tests, lintDebug/lintRelease and debug/unsigned release APK/AAB builds; the real upstream suite passes 7 tests and exports a nonblank native fixture screenshot. Tink host-tool update removes the old generated-protobuf warning. Full module/visual parity remains explicitly incomplete.
This commit is contained in:
1 parent
81973c3c45
commit
8c480ddca3
28 files changed
+2628
-742
No files matched your search
@@ -0,0 +1,111 @@
|
||||
package community.yuvomi.mobile
|
||||
|
||||
import android.content.Context
|
||||
import android.webkit.WebView
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.test.core.app.ActivityScenario
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import java.io.File
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
import org.json.JSONArray
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class BlobExportTest {
|
||||
@Test fun rejectsBlobExportFromOpaqueNativePageEvenWithClaimedOrigin() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
context.getSharedPreferences("server", Context.MODE_PRIVATE).edit()
|
||||
.putString("origin", "https://family.example").commit()
|
||||
val file = File(File(context.cacheDir, "captures").apply { mkdirs() }, "blob-test.csv")
|
||||
val uri = FileProvider.getUriForFile(context, "${context.packageName}.files", file)
|
||||
val worker = Executors.newSingleThreadExecutor()
|
||||
try {
|
||||
ActivityScenario.launch(MainActivity::class.java).use { scenario ->
|
||||
lateinit var web: WebView
|
||||
val created = CountDownLatch(1)
|
||||
var blob = ""
|
||||
scenario.onActivity {
|
||||
// A real WebView with a controlled document, not a network mock.
|
||||
// The HTTPS upstream suite separately exercises the app's client.
|
||||
web = WebView(it)
|
||||
web.settings.javaScriptEnabled = true
|
||||
web.loadDataWithBaseURL(
|
||||
"https://family.example/",
|
||||
"<!doctype html><title>Export test</title>",
|
||||
"text/html",
|
||||
"UTF-8",
|
||||
null,
|
||||
)
|
||||
}
|
||||
val deadline = System.nanoTime() + 10_000_000_000L
|
||||
var ready = false
|
||||
while (!ready && System.nanoTime() < deadline) {
|
||||
val checked = CountDownLatch(1)
|
||||
scenario.onActivity {
|
||||
web.evaluateJavascript("location.origin") {
|
||||
ready =
|
||||
it == "\"https://family.example\""
|
||||
checked.countDown()
|
||||
}
|
||||
}
|
||||
assertTrue(checked.await(2, TimeUnit.SECONDS))
|
||||
if (!ready) Thread.sleep(50)
|
||||
}
|
||||
assertTrue("Trusted document loaded", ready)
|
||||
scenario.onActivity {
|
||||
web.evaluateJavascript(
|
||||
"URL.createObjectURL(new Blob(['name,value\\nYuvomi,Grüße\\n'],{type:'text/csv'}))",
|
||||
) {
|
||||
blob = JSONArray("[$it]").getString(0)
|
||||
created.countDown()
|
||||
}
|
||||
}
|
||||
assertTrue(created.await(10, TimeUnit.SECONDS))
|
||||
assertTrue(
|
||||
"Blob inherits trusted origin: $blob",
|
||||
BlobTransfer.owns(ServerAddress.parse("https://family.example"), blob),
|
||||
)
|
||||
assertNotNull(
|
||||
context.contentResolver.openOutputStream(uri, "w")?.also {
|
||||
it.close()
|
||||
},
|
||||
)
|
||||
val saved = CountDownLatch(1)
|
||||
var success = false
|
||||
scenario.onActivity {
|
||||
assertFalse(
|
||||
"Native page URL remains opaque despite loadDataWithBaseURL",
|
||||
ServerAddress.parse("https://family.example").owns(web.url.orEmpty()),
|
||||
)
|
||||
BlobTransfer.save(
|
||||
ServerAddress.parse("https://family.example"),
|
||||
web,
|
||||
blob,
|
||||
context.contentResolver,
|
||||
uri,
|
||||
worker,
|
||||
{ true },
|
||||
) {
|
||||
success = it
|
||||
saved.countDown()
|
||||
}
|
||||
}
|
||||
assertTrue(saved.await(50, TimeUnit.SECONDS))
|
||||
assertFalse(success)
|
||||
assertEquals(0L, file.length())
|
||||
scenario.onActivity { web.destroy() }
|
||||
}
|
||||
} finally {
|
||||
worker.shutdownNow()
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package community.yuvomi.mobile
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebView
|
||||
import androidx.test.core.app.ActivityScenario
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class BrowserSecurityTest {
|
||||
@Test fun blocksCrossOriginPostAndSubresourceBeforeNetwork() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
context.getSharedPreferences("server", Context.MODE_PRIVATE).edit()
|
||||
.putString("origin", "https://family.example").commit()
|
||||
ActivityScenario.launch(MainActivity::class.java).use { scenario ->
|
||||
lateinit var web: WebView
|
||||
lateinit var client: android.webkit.WebViewClient
|
||||
scenario.onActivity {
|
||||
web = requireNotNull(findWeb(it.window.decorView))
|
||||
client = web.webViewClient
|
||||
}
|
||||
// Interception runs off the UI thread in WebView. POST navigations do
|
||||
// not reliably call shouldOverrideUrlLoading, so this is a separate gate.
|
||||
for (mainFrame in listOf(true, false)) {
|
||||
val response = client.shouldInterceptRequest(
|
||||
web,
|
||||
Request("https://evil.example/collect", mainFrame),
|
||||
)
|
||||
assertNotNull(response)
|
||||
assertEquals(403, response!!.statusCode)
|
||||
}
|
||||
assertNull(
|
||||
client.shouldInterceptRequest(web, Request("https://family.example/tasks", true)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun findWeb(view: View): WebView? {
|
||||
if (view is WebView) return view
|
||||
if (view is ViewGroup) {
|
||||
for (i in 0 until view.childCount) {
|
||||
findWeb(view.getChildAt(i))?.let {
|
||||
return it
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
private class Request(private val value: String, private val mainFrame: Boolean) : WebResourceRequest {
|
||||
override fun getUrl(): Uri = Uri.parse(value)
|
||||
override fun isForMainFrame() = mainFrame
|
||||
override fun isRedirect() = false
|
||||
override fun hasGesture() = true
|
||||
override fun getMethod() = "POST"
|
||||
override fun getRequestHeaders(): MutableMap<String, String> = mutableMapOf()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package community.yuvomi.mobile
|
||||
|
||||
import android.Manifest
|
||||
import android.accessibilityservice.AccessibilityServiceInfo
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.view.accessibility.AccessibilityNodeInfo
|
||||
import androidx.test.core.app.ActivityScenario
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.espresso.Espresso.onView
|
||||
import androidx.test.espresso.action.ViewActions.click
|
||||
import androidx.test.espresso.assertion.ViewAssertions.doesNotExist
|
||||
import androidx.test.espresso.matcher.ViewMatchers.withText
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class LocalNetworkPermissionTest {
|
||||
@Test fun permissionIsOptionalAndHonorsSystemDenialAndGrant() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
context.getSharedPreferences("server", Context.MODE_PRIVATE).edit().clear().commit()
|
||||
ActivityScenario.launch(MainActivity::class.java).use {
|
||||
if (Build.VERSION.SDK_INT < 37 ||
|
||||
context.checkSelfPermission(Manifest.permission.ACCESS_LOCAL_NETWORK) == PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
onView(withText("Allow private-network servers")).check(doesNotExist())
|
||||
return@use
|
||||
}
|
||||
val automation = InstrumentationRegistry.getInstrumentation().uiAutomation
|
||||
automation.serviceInfo = automation.serviceInfo.apply { flags = flags or AccessibilityServiceInfo.FLAG_REPORT_VIEW_IDS }
|
||||
onView(withText("Allow private-network servers")).perform(click())
|
||||
onView(withText("Cancel")).perform(click())
|
||||
assertEquals(PackageManager.PERMISSION_DENIED, context.checkSelfPermission(Manifest.permission.ACCESS_LOCAL_NETWORK))
|
||||
onView(withText("Allow private-network servers")).perform(click())
|
||||
onView(withText("Continue")).perform(click())
|
||||
chooseSystemButton("permission_deny_button")
|
||||
await { context.checkSelfPermission(Manifest.permission.ACCESS_LOCAL_NETWORK) == PackageManager.PERMISSION_DENIED }
|
||||
onView(withText("Allow private-network servers")).perform(click())
|
||||
onView(withText("Continue")).perform(click())
|
||||
chooseSystemButton("permission_allow_button")
|
||||
await { context.checkSelfPermission(Manifest.permission.ACCESS_LOCAL_NETWORK) == PackageManager.PERMISSION_GRANTED }
|
||||
}
|
||||
}
|
||||
|
||||
private fun chooseSystemButton(id: String) {
|
||||
val automation = InstrumentationRegistry.getInstrumentation().uiAutomation
|
||||
await {
|
||||
val node = find(automation.rootInActiveWindow, id) ?: return@await false
|
||||
node.performAction(AccessibilityNodeInfo.ACTION_CLICK)
|
||||
}
|
||||
}
|
||||
private fun find(node: AccessibilityNodeInfo?, id: String): AccessibilityNodeInfo? {
|
||||
if (node == null) return null
|
||||
if (node.viewIdResourceName?.endsWith("/$id") == true) return node
|
||||
for (index in 0 until node.childCount) find(node.getChild(index), id)?.let { return it }
|
||||
return null
|
||||
}
|
||||
private fun await(condition: () -> Boolean) {
|
||||
val deadline = System.nanoTime() + 10_000_000_000L
|
||||
while (System.nanoTime() < deadline) {
|
||||
if (condition()) return
|
||||
Thread.sleep(100)
|
||||
}
|
||||
assertTrue("System permission response timed out", condition())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package community.yuvomi.mobile
|
||||
|
||||
import android.content.Context
|
||||
import android.webkit.WebView
|
||||
import androidx.test.core.app.ActivityScenario
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.espresso.Espresso.onView
|
||||
import androidx.test.espresso.action.ViewActions.click
|
||||
import androidx.test.espresso.action.ViewActions.closeSoftKeyboard
|
||||
import androidx.test.espresso.action.ViewActions.replaceText
|
||||
import androidx.test.espresso.assertion.ViewAssertions.matches
|
||||
import androidx.test.espresso.matcher.ViewMatchers.hasErrorText
|
||||
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
|
||||
import androidx.test.espresso.matcher.ViewMatchers.isEnabled
|
||||
import androidx.test.espresso.matcher.ViewMatchers.withContentDescription
|
||||
import androidx.test.espresso.matcher.ViewMatchers.withText
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class OnboardingTest {
|
||||
private val context: Context get() = ApplicationProvider.getApplicationContext()
|
||||
|
||||
@Before fun clearConfiguration() {
|
||||
context.getSharedPreferences("server", Context.MODE_PRIVATE).edit().clear().commit()
|
||||
}
|
||||
|
||||
@Test fun rejectsHttpAndSubpathsLocally() {
|
||||
ActivityScenario.launch(MainActivity::class.java).use {
|
||||
onView(
|
||||
withContentDescription("Yuvomi server URL"),
|
||||
).perform(replaceText("http://family.example"), closeSoftKeyboard())
|
||||
onView(withText("Connect securely")).perform(click())
|
||||
onView(
|
||||
withContentDescription("Yuvomi server URL"),
|
||||
).check(matches(hasErrorText("Use HTTPS with a valid server hostname.")))
|
||||
onView(
|
||||
withContentDescription("Yuvomi server URL"),
|
||||
).perform(replaceText("https://family.example/subpath"), closeSoftKeyboard())
|
||||
onView(withText("Connect securely")).perform(click())
|
||||
onView(
|
||||
withContentDescription("Yuvomi server URL"),
|
||||
).check(matches(hasErrorText("Yuvomi requires a dedicated hostname, not a subpath.")))
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun survivesRecreationWithoutLosingOnboarding() {
|
||||
ActivityScenario.launch(MainActivity::class.java).use { scenario ->
|
||||
scenario.recreate()
|
||||
onView(withText("Connect securely")).check(matches(isDisplayed()))
|
||||
onView(withContentDescription("Yuvomi server URL")).check(matches(isEnabled()))
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun webViewIsHardenedAndBackgroundingPreservesInstance() {
|
||||
context.getSharedPreferences("server", Context.MODE_PRIVATE).edit()
|
||||
.putString("origin", "https://family.example").commit()
|
||||
ActivityScenario.launch(MainActivity::class.java).use { scenario ->
|
||||
var initial: WebView? = null
|
||||
scenario.onActivity { activity ->
|
||||
fun find(view: android.view.View): WebView? {
|
||||
if (view is WebView) return view
|
||||
if (view is android.view.ViewGroup) {
|
||||
for (i in 0 until view.childCount) {
|
||||
find(view.getChildAt(i))?.let {
|
||||
return it
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
initial = find(activity.window.decorView)
|
||||
val web = requireNotNull(initial)
|
||||
assertFalse(web.settings.allowFileAccess)
|
||||
assertFalse(web.settings.allowContentAccess)
|
||||
assertEquals(
|
||||
android.webkit.WebSettings.MIXED_CONTENT_NEVER_ALLOW,
|
||||
web.settings.mixedContentMode,
|
||||
)
|
||||
assertTrue(web.settings.javaScriptEnabled)
|
||||
}
|
||||
scenario.moveToState(androidx.lifecycle.Lifecycle.State.CREATED)
|
||||
scenario.moveToState(androidx.lifecycle.Lifecycle.State.RESUMED)
|
||||
scenario.onActivity { assertNotNull(initial?.parent) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
package community.yuvomi.mobile
|
||||
|
||||
import android.content.Context
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.webkit.CookieManager
|
||||
import android.webkit.WebView
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.test.core.app.ActivityScenario
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.espresso.Espresso.onView
|
||||
import androidx.test.espresso.action.ViewActions.click
|
||||
import androidx.test.espresso.action.ViewActions.closeSoftKeyboard
|
||||
import androidx.test.espresso.action.ViewActions.replaceText
|
||||
import androidx.test.espresso.assertion.ViewAssertions.doesNotExist
|
||||
import androidx.test.espresso.assertion.ViewAssertions.matches
|
||||
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
|
||||
import androidx.test.espresso.matcher.ViewMatchers.withContentDescription
|
||||
import androidx.test.espresso.matcher.ViewMatchers.withText
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import java.io.File
|
||||
import java.net.URI
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.net.ssl.HttpsURLConnection
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class UpstreamIntegrationTest {
|
||||
@Test fun actualUpstreamOnboardingAuthenticationLifecycleAndLogout() {
|
||||
val arguments = InstrumentationRegistry.getArguments()
|
||||
val origin = requireNotNull(arguments.getString("server"))
|
||||
val password = requireNotNull(arguments.getString("password"))
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
if (android.os.Build.VERSION.SDK_INT >= 37) {
|
||||
InstrumentationRegistry.getInstrumentation().uiAutomation.grantRuntimePermission(
|
||||
context.packageName,
|
||||
android.Manifest.permission.ACCESS_LOCAL_NETWORK,
|
||||
)
|
||||
}
|
||||
context.getSharedPreferences("server", Context.MODE_PRIVATE).edit().clear().commit()
|
||||
ActivityScenario.launch(MainActivity::class.java).use { scenario ->
|
||||
onView(
|
||||
withContentDescription("Yuvomi server URL"),
|
||||
).perform(replaceText(origin), closeSoftKeyboard())
|
||||
onView(withText("Connect securely")).perform(click())
|
||||
var web: WebView? = null
|
||||
await("native onboarding completed") {
|
||||
scenario.onActivity { web = findWeb(it.window.decorView) }
|
||||
web != null
|
||||
}
|
||||
await("actual upstream application loaded") {
|
||||
documentLoaded(scenario, requireNotNull(web), origin)
|
||||
}
|
||||
// Run in the real WebView origin, preserving real Set-Cookie and CSRF.
|
||||
val credentials = JSONObject().put(
|
||||
"username",
|
||||
"mobiletest",
|
||||
).put("password", password).toString()
|
||||
evaluate(
|
||||
scenario,
|
||||
requireNotNull(web),
|
||||
"""
|
||||
window.__mobileResult = null;
|
||||
fetch('/api/v1/auth/login', {method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify($credentials)}).then(async r => {
|
||||
const data = await r.json(); window.__mobileCsrf=data.csrfToken;
|
||||
window.__mobileResult=r.status;
|
||||
});
|
||||
""".trimIndent(),
|
||||
)
|
||||
await("upstream password login") {
|
||||
evaluate(scenario, requireNotNull(web), "window.__mobileResult") ==
|
||||
"200"
|
||||
}
|
||||
assertEquals(
|
||||
"false",
|
||||
evaluate(scenario, requireNotNull(web), "document.cookie.includes('yuvomi.sid=')"),
|
||||
)
|
||||
evaluate(
|
||||
scenario,
|
||||
requireNotNull(web),
|
||||
"""
|
||||
window.__mobileResult=null;
|
||||
fetch('/api/v1/documents',{method:'POST',headers:{'Content-Type':'application/json',
|
||||
'X-CSRF-Token':window.__mobileCsrf},body:JSON.stringify({name:'Mobile upload',
|
||||
original_name:'mobile.txt',content_data:'data:text/plain;base64,WXV2b21pIGZpbGUgdGVzdAo='})})
|
||||
.then(async r=>{const d=await r.json();window.__mobileDocument=d.data?.id;window.__mobileResult=r.status});
|
||||
""".trimIndent(),
|
||||
)
|
||||
await("real upstream file upload") {
|
||||
evaluate(scenario, requireNotNull(web), "window.__mobileResult") ==
|
||||
"201"
|
||||
}
|
||||
val documentId = evaluate(
|
||||
scenario,
|
||||
requireNotNull(web),
|
||||
"window.__mobileDocument",
|
||||
).toLong()
|
||||
val downloadURL = "$origin/api/v1/documents/$documentId/download"
|
||||
var cookie = ""
|
||||
scenario.onActivity { cookie = CookieManager.getInstance().getCookie(downloadURL) }
|
||||
val file =
|
||||
File(File(context.cacheDir, "captures").apply { mkdirs() }, "upstream-download.txt")
|
||||
val destination = FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.files",
|
||||
file,
|
||||
)
|
||||
try {
|
||||
FileTransfer.save(
|
||||
ServerAddress.parse(origin),
|
||||
downloadURL,
|
||||
cookie,
|
||||
context.contentResolver,
|
||||
destination,
|
||||
)
|
||||
assertEquals("Yuvomi file test\n", file.readText())
|
||||
} finally {
|
||||
file.delete()
|
||||
}
|
||||
val blobResult =
|
||||
evaluate(
|
||||
scenario,
|
||||
requireNotNull(web),
|
||||
"URL.createObjectURL(new Blob(['Yuvomi export'],{type:'text/plain'}))",
|
||||
)
|
||||
val blobURL = JSONArray("[$blobResult]").getString(0)
|
||||
val exported = CountDownLatch(1)
|
||||
val exportWorker = Executors.newSingleThreadExecutor()
|
||||
var exportSuccess = false
|
||||
try {
|
||||
scenario.onActivity {
|
||||
BlobTransfer.save(
|
||||
ServerAddress.parse(origin),
|
||||
requireNotNull(web),
|
||||
blobURL,
|
||||
context.contentResolver,
|
||||
destination,
|
||||
exportWorker,
|
||||
{ true },
|
||||
) {
|
||||
exportSuccess = it
|
||||
exported.countDown()
|
||||
}
|
||||
}
|
||||
assertTrue(exported.await(50, TimeUnit.SECONDS))
|
||||
assertTrue("Blob export in hardened application WebView", exportSuccess)
|
||||
assertEquals("Yuvomi export", file.readText())
|
||||
} finally {
|
||||
exportWorker.shutdownNow()
|
||||
file.delete()
|
||||
}
|
||||
scenario.moveToState(androidx.lifecycle.Lifecycle.State.CREATED)
|
||||
scenario.moveToState(androidx.lifecycle.Lifecycle.State.RESUMED)
|
||||
evaluate(
|
||||
scenario,
|
||||
requireNotNull(web),
|
||||
"window.__mobileResult=null;fetch('/api/v1/auth/me').then(r=>window.__mobileResult=r.status)",
|
||||
)
|
||||
await("session survives backgrounding") {
|
||||
evaluate(scenario, requireNotNull(web), "window.__mobileResult") ==
|
||||
"200"
|
||||
}
|
||||
scenario.recreate()
|
||||
await("restored WebView") {
|
||||
scenario.onActivity { web = findWeb(it.window.decorView) }
|
||||
web != null && documentLoaded(scenario, requireNotNull(web), origin)
|
||||
}
|
||||
await("persistent session after activity recreation") {
|
||||
evaluate(
|
||||
scenario,
|
||||
requireNotNull(web),
|
||||
"""
|
||||
if(!window.__mobileSessionProbe){window.__mobileSessionProbe=true;
|
||||
fetch('/api/v1/auth/me').then(async r=>{const d=await r.json();
|
||||
window.__mobileCsrf=d.csrfToken;window.__mobileResult=r.status})}
|
||||
window.__mobileResult
|
||||
""".trimIndent(),
|
||||
) ==
|
||||
"200"
|
||||
}
|
||||
evaluate(
|
||||
scenario,
|
||||
requireNotNull(web),
|
||||
"""
|
||||
window.__mobileResult=null;
|
||||
fetch('/api/v1/auth/logout',{method:'POST',headers:{'Content-Type':'application/json',
|
||||
'X-CSRF-Token':window.__mobileCsrf},body:'{}'}).then(r=>window.__mobileResult=r.status);
|
||||
""".trimIndent(),
|
||||
)
|
||||
await("CSRF-protected logout") {
|
||||
evaluate(scenario, requireNotNull(web), "window.__mobileResult") ==
|
||||
"200"
|
||||
}
|
||||
evaluate(
|
||||
scenario,
|
||||
requireNotNull(web),
|
||||
"window.__mobileResult=null;fetch('/api/v1/auth/me').then(r=>window.__mobileResult=r.status)",
|
||||
)
|
||||
await("session revoked") {
|
||||
evaluate(scenario, requireNotNull(web), "window.__mobileResult") ==
|
||||
"401"
|
||||
}
|
||||
// The upstream worker may legitimately serve the cached app shell
|
||||
// during an outage. Use non-cacheable metadata to exercise native
|
||||
// main-frame network recovery without disabling that worker.
|
||||
evaluate(
|
||||
scenario,
|
||||
requireNotNull(web),
|
||||
"fetch('/__mobile_test/network/off',{method:'POST'}).then(()=>location.assign('/api/v1/version'))",
|
||||
)
|
||||
await("native network error recovery panel") {
|
||||
var visible = false
|
||||
scenario.onActivity { visible = hasText(it.window.decorView, "Connection interrupted") }
|
||||
visible
|
||||
}
|
||||
val recovery = URI("$origin/__mobile_test/network/on").toURL().openConnection() as HttpsURLConnection
|
||||
try {
|
||||
recovery.requestMethod = "POST"
|
||||
recovery.connectTimeout = 5_000
|
||||
recovery.readTimeout = 5_000
|
||||
assertEquals(200, recovery.responseCode)
|
||||
} finally {
|
||||
recovery.disconnect()
|
||||
}
|
||||
onView(withText("Retry")).perform(click())
|
||||
await("reconnected to real upstream metadata") {
|
||||
evaluate(scenario, requireNotNull(web), "document.body?.innerText.includes('app_name')") == "true"
|
||||
}
|
||||
scenario.onActivity { requireNotNull(web).loadUrl("$origin/") }
|
||||
await("application restored after outage") {
|
||||
var restored = false
|
||||
scenario.onActivity { restored = requireNotNull(web).url == "$origin/" }
|
||||
restored && documentLoaded(scenario, requireNotNull(web), origin)
|
||||
}
|
||||
scenario.onActivity { requireNotNull(web).loadUrl("$origin/api/v1/auth/oidc/start") }
|
||||
await("native consent before leaving Yuvomi for OIDC") {
|
||||
runCatching { onView(withText("Sign-in window")).check(matches(isDisplayed())) }.isSuccess
|
||||
}
|
||||
onView(withText("Sign-in window")).perform(click())
|
||||
await("OIDC callback returns to main application") {
|
||||
var returned = false
|
||||
scenario.onActivity { returned = requireNotNull(web).url == "$origin/" }
|
||||
val signInClosed = runCatching {
|
||||
onView(withText("Close")).check(doesNotExist())
|
||||
}.isSuccess
|
||||
signInClosed && returned &&
|
||||
documentLoaded(scenario, requireNotNull(web), origin)
|
||||
}
|
||||
evaluate(
|
||||
scenario,
|
||||
requireNotNull(web),
|
||||
"window.__mobileSubject=null;fetch('/api/v1/auth/me').then(r=>r.json()).then(d=>window.__mobileSubject=d.user?.username)",
|
||||
)
|
||||
await("isolated OIDC view shares the initiating server session") {
|
||||
evaluate(scenario, requireNotNull(web), "window.__mobileSubject") == "\"mobile-oidc\""
|
||||
}
|
||||
var previous = ""
|
||||
scenario.onActivity {
|
||||
previous = requireNotNull(web).url.orEmpty()
|
||||
requireNotNull(web).loadUrl("$origin/tasks")
|
||||
}
|
||||
await("internal module navigation") {
|
||||
var loaded = false
|
||||
scenario.onActivity { loaded = requireNotNull(web).url == "$origin/tasks" && requireNotNull(web).canGoBack() }
|
||||
loaded && documentLoaded(scenario, requireNotNull(web), origin)
|
||||
}
|
||||
scenario.onActivity { it.onBackPressedDispatcher.onBackPressed() }
|
||||
await("native back restores WebView history") {
|
||||
var restored = false
|
||||
scenario.onActivity { restored = requireNotNull(web).url == previous }
|
||||
restored
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun documentLoaded(scenario: ActivityScenario<MainActivity>, web: WebView, origin: String): Boolean = evaluate(
|
||||
scenario,
|
||||
web,
|
||||
"location.origin===${JSONObject.quote(origin)} && document.readyState==='complete'",
|
||||
) == "true"
|
||||
|
||||
private fun evaluate(scenario: ActivityScenario<MainActivity>, web: WebView, javascript: String): String {
|
||||
val completed = CountDownLatch(1)
|
||||
var result = ""
|
||||
scenario.onActivity {
|
||||
web.evaluateJavascript(javascript) {
|
||||
result = it
|
||||
completed.countDown()
|
||||
}
|
||||
}
|
||||
assertTrue("JavaScript evaluation timed out", completed.await(10, TimeUnit.SECONDS))
|
||||
return result
|
||||
}
|
||||
private fun await(label: String, condition: () -> Boolean) {
|
||||
val deadline = System.nanoTime() + 30_000_000_000L
|
||||
while (System.nanoTime() < deadline) {
|
||||
if (condition()) return
|
||||
Thread.sleep(100)
|
||||
}
|
||||
fail("Timed out: $label")
|
||||
}
|
||||
private fun findWeb(view: View): WebView? {
|
||||
if (view is WebView) return view
|
||||
if (view is ViewGroup) {
|
||||
for (i in 0 until view.childCount) {
|
||||
findWeb(view.getChildAt(i))?.let {
|
||||
return it
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun hasText(view: View, text: String): Boolean {
|
||||
if (view is android.widget.TextView && view.text.toString() == text) return true
|
||||
if (view is ViewGroup) {
|
||||
for (index in 0 until view.childCount) {
|
||||
if (hasText(view.getChildAt(index), text)) return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user