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,8 @@
|
||||
# Superseded WebView prototype
|
||||
|
||||
These files are retained as historical reference, not shipped application code.
|
||||
The Android browser tests in `legacy/android/tests` and Apple BrowserModel tests
|
||||
describe this prototype, not native feature parity.
|
||||
Android now selects the explicitly named native test source directories instead.
|
||||
Do not restore a WebView as the application surface or count old browser tests as
|
||||
native-client validation.
|
||||
@@ -0,0 +1,16 @@
|
||||
package community.yuvomi.mobile
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class BlobOriginsTest {
|
||||
@Test fun blobDownloadsCannotCrossOriginsOrDowngradeTls() {
|
||||
val server = ServerAddress.parse("https://family.example")
|
||||
assertTrue(BlobTransfer.owns(server, "blob:https://family.example/unique-id"))
|
||||
listOf(
|
||||
"blob:https://evil.example/id", "blob:http://family.example/id",
|
||||
"blob:https://family.example.evil.test/id", "data:text/plain,hi",
|
||||
).forEach { assertFalse(it, BlobTransfer.owns(server, it)) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package community.yuvomi.mobile
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.net.Uri
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Base64
|
||||
import android.webkit.WebView
|
||||
import androidx.webkit.WebViewCompat
|
||||
import androidx.webkit.WebViewFeature
|
||||
import java.io.OutputStream
|
||||
import java.util.UUID
|
||||
import java.util.WeakHashMap
|
||||
import java.util.concurrent.Executor
|
||||
import org.json.JSONObject
|
||||
|
||||
/** Same-origin, user-selected blob exports. No JS bridge or CSP/network bypass. */
|
||||
object BlobTransfer {
|
||||
private val stores = WeakHashMap<WebView, String>()
|
||||
|
||||
fun owns(server: ServerAddress, value: String): Boolean = value.startsWith("blob:") && server.owns(value.substring(5))
|
||||
|
||||
fun install(web: WebView, server: ServerAddress) {
|
||||
if (!WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) return
|
||||
val key = "__yuvomi_blobs_${UUID.randomUUID().toString().replace("-", "") }"
|
||||
stores[web] = key
|
||||
val script = """
|
||||
(function(){if(window.top!==window)return;
|
||||
const entries=new Map();let total=0;const limit=25*1024*1024;
|
||||
const original=URL.createObjectURL.bind(URL);
|
||||
URL.createObjectURL=function(blob){const url=original(blob);
|
||||
if(blob instanceof Blob && blob.size<=limit){
|
||||
while(entries.size && (total+blob.size>limit || entries.size>=8)){
|
||||
const first=entries.keys().next().value;total-=entries.get(first).size;entries.delete(first);
|
||||
}
|
||||
entries.set(url,blob);total+=blob.size;
|
||||
}return url;};
|
||||
Object.defineProperty(window,${JSONObject.quote(key)},{value:{take:function(url){
|
||||
const blob=entries.get(url);if(blob){total-=blob.size;entries.delete(url);}return blob;
|
||||
}}});
|
||||
})();
|
||||
""".trimIndent()
|
||||
// Retain a bounded Blob reference even if upstream revokes the object URL
|
||||
// immediately after clicking its download anchor. Never fetch a blob URL:
|
||||
// upstream's CSP correctly refuses that operation in a WebView.
|
||||
WebViewCompat.addDocumentStartJavaScript(web, script, setOf(server.origin))
|
||||
}
|
||||
|
||||
fun forget(web: WebView) {
|
||||
stores.remove(web)
|
||||
}
|
||||
|
||||
fun save(
|
||||
server: ServerAddress,
|
||||
web: WebView,
|
||||
value: String,
|
||||
resolver: ContentResolver,
|
||||
destination: Uri,
|
||||
worker: Executor,
|
||||
authorized: () -> Boolean,
|
||||
completed: (Boolean) -> Unit,
|
||||
) {
|
||||
val store = stores[web]
|
||||
if (store == null || !authorized() || !server.owns(web.url.orEmpty()) || !owns(server, value)) {
|
||||
completed(false)
|
||||
return
|
||||
}
|
||||
val handler = Handler(Looper.getMainLooper())
|
||||
val key = JSONObject.quote("__yuvomi_export_${UUID.randomUUID().toString().replace("-", "")}")
|
||||
val maxBytes = 25 * 1024 * 1024
|
||||
var output: OutputStream? = null
|
||||
var offset = 0
|
||||
var finished = false
|
||||
lateinit var expiry: Runnable
|
||||
fun allowed() = authorized() && server.owns(web.url.orEmpty())
|
||||
fun finish(success: Boolean) {
|
||||
if (finished) return
|
||||
finished = true
|
||||
handler.removeCallbacks(expiry)
|
||||
if (runCatching { allowed() }.getOrDefault(false)) {
|
||||
runCatching { web.evaluateJavascript("delete window[$key]", null) }
|
||||
}
|
||||
val close = Runnable {
|
||||
val closed = runCatching { output?.close() }.isSuccess
|
||||
handler.post { completed(success && closed) }
|
||||
}
|
||||
runCatching { worker.execute(close) }.onFailure { close.run() }
|
||||
}
|
||||
expiry = Runnable { finish(false) }
|
||||
lateinit var pull: Runnable
|
||||
pull = Runnable {
|
||||
if (!runCatching { allowed() }.getOrDefault(false)) {
|
||||
finish(false)
|
||||
return@Runnable
|
||||
}
|
||||
val script = """
|
||||
(function(){const x=window[$key];
|
||||
if(!x || x.state!=='ready')return {state:x?x.state:'failed'};
|
||||
const b=x.bytes;if(b.length>$maxBytes)return {state:'failed'};
|
||||
const end=Math.min($offset+32768,b.length);
|
||||
return {state:'ready',data:btoa(String.fromCharCode.apply(null,b.subarray($offset,end))),done:end===b.length};})()
|
||||
""".trimIndent()
|
||||
runCatching {
|
||||
web.evaluateJavascript(script) { result ->
|
||||
if (finished) return@evaluateJavascript
|
||||
val data = runCatching {
|
||||
require(result.length < 50_000)
|
||||
JSONObject(result)
|
||||
}.getOrNull()
|
||||
when (data?.optString("state")) {
|
||||
"loading" -> handler.postDelayed(pull, 50)
|
||||
"ready" -> {
|
||||
val chunk = runCatching { Base64.decode(data.getString("data"), Base64.NO_WRAP) }.getOrNull()
|
||||
if (chunk == null || chunk.size > 32768 || offset + chunk.size > maxBytes) {
|
||||
finish(false)
|
||||
} else {
|
||||
offset += chunk.size
|
||||
runCatching {
|
||||
worker.execute {
|
||||
val success = runCatching { requireNotNull(output).write(chunk) }.isSuccess
|
||||
handler.post {
|
||||
if (!success) {
|
||||
finish(false)
|
||||
} else if (data.optBoolean("done")) {
|
||||
finish(true)
|
||||
} else {
|
||||
handler.post(pull)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.onFailure { finish(false) }
|
||||
}
|
||||
}
|
||||
else -> finish(false)
|
||||
}
|
||||
}
|
||||
}.onFailure { finish(false) }
|
||||
}
|
||||
handler.postDelayed(expiry, 45_000)
|
||||
runCatching {
|
||||
worker.execute {
|
||||
output = runCatching { resolver.openOutputStream(destination, "w") }.getOrNull()
|
||||
handler.post {
|
||||
if (finished) {
|
||||
runCatching { output?.close() }
|
||||
return@post
|
||||
}
|
||||
if (output == null || !allowed()) {
|
||||
finish(false)
|
||||
return@post
|
||||
}
|
||||
val script = """
|
||||
(function(){window[$key]={state:'loading'};
|
||||
try{const b=window[${JSONObject.quote(store)}].take(${JSONObject.quote(value)});
|
||||
if(!b || b.size>$maxBytes)throw new Error('size');
|
||||
b.arrayBuffer().then(a=>window[$key]={state:'ready',bytes:new Uint8Array(a)})
|
||||
.catch(()=>window[$key]={state:'failed'});
|
||||
}catch(e){window[$key]={state:'failed'};}})()
|
||||
""".trimIndent()
|
||||
runCatching { web.evaluateJavascript(script) { handler.post(pull) } }.onFailure { finish(false) }
|
||||
}
|
||||
}
|
||||
}.onFailure { finish(false) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package community.yuvomi.mobile
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.net.Uri
|
||||
import java.net.URI
|
||||
import javax.net.ssl.HttpsURLConnection
|
||||
|
||||
object FileTransfer {
|
||||
/** Never forwards session cookies across a redirect, even to another HTTPS origin. */
|
||||
fun save(server: ServerAddress, url: String, cookie: String?, resolver: ContentResolver, destination: Uri) {
|
||||
require(server.owns(url))
|
||||
val connection = URI(url).toURL().openConnection() as HttpsURLConnection
|
||||
try {
|
||||
connection.instanceFollowRedirects = false
|
||||
connection.connectTimeout = 15_000
|
||||
connection.readTimeout = 20_000
|
||||
cookie?.let { connection.setRequestProperty("Cookie", it) }
|
||||
require(connection.responseCode == 200) { "Download refused" }
|
||||
require(connection.contentLengthLong <= 100L * 1024 * 1024) { "Download too large" }
|
||||
connection.inputStream.use { input ->
|
||||
requireNotNull(resolver.openOutputStream(destination, "w")).use { output ->
|
||||
val buffer = ByteArray(8192)
|
||||
var total = 0L
|
||||
val deadline = System.nanoTime() + 120_000_000_000L
|
||||
while (true) {
|
||||
check(!Thread.currentThread().isInterrupted && System.nanoTime() < deadline)
|
||||
val count = input.read(buffer)
|
||||
if (count < 0) break
|
||||
total += count
|
||||
require(total <= 100L * 1024 * 1024)
|
||||
output.write(buffer, 0, count)
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,689 @@
|
||||
package community.yuvomi.mobile
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.AlertDialog
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import android.net.http.SslError
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.provider.MediaStore
|
||||
import android.text.InputType
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.WindowManager
|
||||
import android.webkit.CookieManager
|
||||
import android.webkit.GeolocationPermissions
|
||||
import android.webkit.PermissionRequest
|
||||
import android.webkit.RenderProcessGoneDetail
|
||||
import android.webkit.SslErrorHandler
|
||||
import android.webkit.URLUtil
|
||||
import android.webkit.ValueCallback
|
||||
import android.webkit.WebChromeClient
|
||||
import android.webkit.WebResourceError
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebResourceResponse
|
||||
import android.webkit.WebSettings
|
||||
import android.webkit.WebStorage
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import android.widget.Button
|
||||
import android.widget.EditText
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.ProgressBar
|
||||
import android.widget.ScrollView
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.content.edit
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.webkit.WebStorageCompat
|
||||
import androidx.webkit.WebViewFeature
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.File
|
||||
import java.net.URI
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
private var worker = Executors.newSingleThreadExecutor()
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private val preferences by lazy { getSharedPreferences("server", MODE_PRIVATE) }
|
||||
private var server: ServerAddress? = null
|
||||
private var web: WebView? = null
|
||||
private var authDialog: AlertDialog? = null
|
||||
private lateinit var root: LinearLayout
|
||||
private lateinit var content: FrameLayout
|
||||
private lateinit var progress: ProgressBar
|
||||
private var fileCallback: ValueCallback<Array<Uri>>? = null
|
||||
private var capture: Uri? = null
|
||||
private var pendingDownload: String? = null
|
||||
private var generation = 0
|
||||
private val uploadSelection = SelectionGate()
|
||||
private val downloadSelection = SelectionGate()
|
||||
private val loadTimeout =
|
||||
Runnable { showError("The server is taking too long. Check your connection or try again.") }
|
||||
private val localNetworkPermission =
|
||||
registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||
if (granted) {
|
||||
toast("Private-network access allowed. Retry your server connection.")
|
||||
} else {
|
||||
toast("Private-network access was not granted. Public HTTPS servers can still be used.")
|
||||
}
|
||||
}
|
||||
|
||||
private val picker =
|
||||
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
val accepted = uploadSelection.consume(generation)
|
||||
val callback = fileCallback
|
||||
fileCallback = null
|
||||
val uris = if (!accepted || result.resultCode != RESULT_OK) {
|
||||
null
|
||||
} else {
|
||||
val data = result.data
|
||||
data?.clipData?.let { clip ->
|
||||
Array(clip.itemCount.coerceAtMost(50)) { clip.getItemAt(it).uri }
|
||||
}
|
||||
?: data?.data?.let { arrayOf(it) } ?: capture?.let { arrayOf(it) }
|
||||
}
|
||||
callback?.onReceiveValue(uris?.filter { it.scheme == "content" }?.toTypedArray())
|
||||
revokeCapture()
|
||||
capture = null
|
||||
}
|
||||
private val saveDocument =
|
||||
registerForActivityResult(
|
||||
ActivityResultContracts.CreateDocument("application/octet-stream"),
|
||||
) { destination ->
|
||||
val accepted = downloadSelection.consume(generation)
|
||||
val url = pendingDownload
|
||||
pendingDownload = null
|
||||
if (!accepted) return@registerForActivityResult
|
||||
val current = server
|
||||
if (destination != null && url != null && current != null &&
|
||||
BlobTransfer.owns(current, url)
|
||||
) {
|
||||
val view = web ?: return@registerForActivityResult
|
||||
val epoch = generation
|
||||
BlobTransfer.save(
|
||||
current,
|
||||
view,
|
||||
url,
|
||||
contentResolver,
|
||||
destination,
|
||||
worker,
|
||||
{ !isDestroyed && epoch == generation },
|
||||
) { success ->
|
||||
if (!success) runCatching { contentResolver.delete(destination, null, null) }
|
||||
if (!isDestroyed &&
|
||||
epoch == generation
|
||||
) {
|
||||
toast(
|
||||
if (success) "Export saved" else "Export failed or exceeded the 25 MB limit.",
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if (destination != null && url != null && current != null && current.owns(url)) {
|
||||
val cookies = CookieManager.getInstance().getCookie(url)
|
||||
val epoch = generation
|
||||
worker.execute {
|
||||
val success = runCatching {
|
||||
FileTransfer.save(current, url, cookies, contentResolver, destination)
|
||||
}.isSuccess
|
||||
if (!success) runCatching { contentResolver.delete(destination, null, null) }
|
||||
runOnUiThread {
|
||||
if (!isDestroyed && epoch == generation) {
|
||||
if (success) {
|
||||
toast("Download saved")
|
||||
} else {
|
||||
toast(
|
||||
"Download failed. Check your connection and sign-in, then retry.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
if (savedInstanceState?.getBoolean("uploadOutstanding") == true) uploadSelection.restoreOutstanding()
|
||||
if (savedInstanceState?.getBoolean("downloadOutstanding") == true) downloadSelection.restoreOutstanding()
|
||||
// Household data is hidden from screenshots and the recent-apps preview.
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||
WebView.setWebContentsDebuggingEnabled(false)
|
||||
root = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL }
|
||||
ViewCompat.setOnApplyWindowInsetsListener(root) { view, insets ->
|
||||
val bars = insets.getInsets(
|
||||
WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.ime(),
|
||||
)
|
||||
view.setPadding(bars.left, bars.top, bars.right, bars.bottom)
|
||||
insets
|
||||
}
|
||||
setContentView(root)
|
||||
onBackPressedDispatcher.addCallback(
|
||||
this,
|
||||
object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
if (web?.canGoBack() == true) {
|
||||
web?.goBack()
|
||||
} else if (web != null) {
|
||||
moveTaskToBack(true)
|
||||
} else {
|
||||
isEnabled = false
|
||||
onBackPressedDispatcher.onBackPressed()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
File(cacheDir, "captures").listFiles()?.filter {
|
||||
it.lastModified() <
|
||||
System.currentTimeMillis() - 86_400_000
|
||||
}
|
||||
?.forEach { it.delete() }
|
||||
server =
|
||||
preferences.getString("origin", null)?.let {
|
||||
runCatching { ServerAddress.parse(it) }.getOrNull()
|
||||
}
|
||||
if (server == null) onboarding() else browser()
|
||||
}
|
||||
|
||||
private fun column(): LinearLayout = LinearLayout(this).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setPadding(dp(24), dp(20), dp(24), dp(20))
|
||||
}
|
||||
private fun text(value: String, size: Float = 17f) = TextView(this).apply {
|
||||
text = value
|
||||
textSize = size
|
||||
setPadding(0, dp(8), 0, dp(8))
|
||||
}
|
||||
private fun button(label: String, action: () -> Unit) = Button(this).apply {
|
||||
text = label
|
||||
isAllCaps = false
|
||||
minHeight = dp(48)
|
||||
setOnClickListener { action() }
|
||||
}
|
||||
private fun dp(value: Int) = (value * resources.displayMetrics.density).toInt()
|
||||
|
||||
private fun onboarding() {
|
||||
root.removeAllViews()
|
||||
val form = column()
|
||||
form.addView(text("Your home, connected", 30f))
|
||||
form.addView(
|
||||
text("Connect to your own Yuvomi server. Your data stays with your household."),
|
||||
)
|
||||
val address = EditText(this).apply {
|
||||
hint = "https://family.example.com"
|
||||
inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI
|
||||
setSingleLine(true)
|
||||
contentDescription = "Yuvomi server URL"
|
||||
setText(server?.origin.orEmpty())
|
||||
id = View.generateViewId()
|
||||
}
|
||||
val label =
|
||||
text("HTTPS required. Custom ports supported. Use a dedicated hostname, not a subpath.")
|
||||
label.accessibilityLiveRegion = View.ACCESSIBILITY_LIVE_REGION_POLITE
|
||||
lateinit var connect: Button
|
||||
connect = button("Connect securely") {
|
||||
val candidate = runCatching { ServerAddress.parse(address.text.toString()) }.getOrElse {
|
||||
address.error = it.message
|
||||
return@button
|
||||
}
|
||||
connect.isEnabled = false
|
||||
address.isEnabled = false
|
||||
label.setText(R.string.checking_server)
|
||||
val epoch = generation
|
||||
worker.execute {
|
||||
val result = runCatching { ServerProbe.check(candidate) }
|
||||
runOnUiThread {
|
||||
if (isDestroyed || epoch != generation) return@runOnUiThread
|
||||
connect.isEnabled = true
|
||||
address.isEnabled = true
|
||||
if (result.isSuccess) {
|
||||
server = candidate
|
||||
preferences.edit {
|
||||
putString("origin", candidate.origin)
|
||||
remove("path")
|
||||
}
|
||||
browser()
|
||||
} else {
|
||||
// Exception details may contain URL parameters; never expose them.
|
||||
label.setText(R.string.probe_failed)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
form.addView(address)
|
||||
form.addView(label)
|
||||
localNetworkButton()?.let { form.addView(it) }
|
||||
form.addView(connect)
|
||||
form.addView(
|
||||
text("Independent community app. No telemetry or mandatory cloud service.", 14f),
|
||||
)
|
||||
root.addView(ScrollView(this).apply { addView(form) })
|
||||
}
|
||||
|
||||
private fun browser() {
|
||||
val current = server ?: return onboarding()
|
||||
root.removeAllViews()
|
||||
val toolbar = LinearLayout(this)
|
||||
toolbar.addView(button("Server settings") { settings() })
|
||||
toolbar.addView(
|
||||
button("Share server") {
|
||||
startActivity(
|
||||
Intent.createChooser(
|
||||
Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
putExtra(Intent.EXTRA_TEXT, current.origin)
|
||||
},
|
||||
"Share server address",
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
root.addView(toolbar)
|
||||
progress = ProgressBar(this, null, android.R.attr.progressBarStyleHorizontal).apply {
|
||||
contentDescription = "Loading Yuvomi"
|
||||
max = 100
|
||||
}
|
||||
root.addView(progress, LinearLayout.LayoutParams(-1, dp(4)))
|
||||
content = FrameLayout(this)
|
||||
root.addView(content, LinearLayout.LayoutParams(-1, 0, 1f))
|
||||
val view = WebView(this)
|
||||
web = view
|
||||
secure(view)
|
||||
BlobTransfer.install(view, current)
|
||||
CookieManager.getInstance().setAcceptCookie(true)
|
||||
CookieManager.getInstance().setAcceptThirdPartyCookies(view, false)
|
||||
view.webViewClient = object : WebViewClient() {
|
||||
override fun shouldInterceptRequest(v: WebView, request: WebResourceRequest): WebResourceResponse? {
|
||||
if (!current.owns(request.url.toString())) {
|
||||
return WebResourceResponse(
|
||||
"text/plain",
|
||||
"UTF-8",
|
||||
403,
|
||||
"Blocked",
|
||||
emptyMap(),
|
||||
ByteArrayInputStream(ByteArray(0)),
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
override fun shouldOverrideUrlLoading(v: WebView, request: WebResourceRequest): Boolean {
|
||||
val url = request.url.toString()
|
||||
if (current.owns(url)) return false
|
||||
if (request.isForMainFrame) external(url)
|
||||
return true
|
||||
}
|
||||
override fun onPageStarted(v: WebView, url: String, icon: Bitmap?) {
|
||||
if (!current.owns(url)) {
|
||||
v.stopLoading()
|
||||
external(url)
|
||||
return
|
||||
}
|
||||
progress.visibility = View.VISIBLE
|
||||
handler.removeCallbacks(loadTimeout)
|
||||
handler.postDelayed(loadTimeout, 30_000)
|
||||
}
|
||||
override fun onPageFinished(v: WebView, url: String) {
|
||||
handler.removeCallbacks(loadTimeout)
|
||||
progress.visibility = View.GONE
|
||||
CookieManager.getInstance().flush()
|
||||
if (current.owns(url)) {
|
||||
val path = URI(url).rawPath.orEmpty()
|
||||
if (!listOf("auth", "reset", "invite", "login").any {
|
||||
path.contains(it, true)
|
||||
}
|
||||
) {
|
||||
preferences.edit { putString("path", path) }
|
||||
}
|
||||
}
|
||||
}
|
||||
override fun onReceivedError(v: WebView, r: WebResourceRequest, e: WebResourceError) {
|
||||
if (r.isForMainFrame) {
|
||||
showError(
|
||||
"Unable to connect. Cached content may still be available. Check your network or VPN.",
|
||||
)
|
||||
}
|
||||
}
|
||||
override fun onReceivedHttpError(v: WebView, r: WebResourceRequest, e: WebResourceResponse) {
|
||||
if (r.isForMainFrame &&
|
||||
e.statusCode >= 400
|
||||
) {
|
||||
showError("The server is unavailable (HTTP ${e.statusCode}).")
|
||||
}
|
||||
}
|
||||
override fun onReceivedSslError(v: WebView, h: SslErrorHandler, e: SslError) {
|
||||
h.cancel()
|
||||
showError(
|
||||
"The server certificate could not be verified. Contact your server administrator.",
|
||||
)
|
||||
}
|
||||
override fun onRenderProcessGone(v: WebView, detail: RenderProcessGoneDetail): Boolean {
|
||||
releaseWeb()
|
||||
browser()
|
||||
toast("Yuvomi recovered after a browser interruption.")
|
||||
return true
|
||||
}
|
||||
}
|
||||
view.webChromeClient = object : WebChromeClient() {
|
||||
override fun onProgressChanged(v: WebView, value: Int) {
|
||||
progress.progress = value
|
||||
}
|
||||
override fun onPermissionRequest(request: PermissionRequest) {
|
||||
request.deny()
|
||||
}
|
||||
override fun onGeolocationPermissionsShowPrompt(origin: String, callback: GeolocationPermissions.Callback) {
|
||||
callback.invoke(origin, false, false)
|
||||
}
|
||||
override fun onShowFileChooser(v: WebView, callback: ValueCallback<Array<Uri>>, params: FileChooserParams): Boolean {
|
||||
if (!current.owns(v.url.orEmpty())) return false
|
||||
if (!uploadSelection.begin(generation)) {
|
||||
callback.onReceiveValue(null)
|
||||
toast("Finish or cancel the existing system file picker first.")
|
||||
return true
|
||||
}
|
||||
fileCallback?.onReceiveValue(null)
|
||||
fileCallback = callback
|
||||
runCatching {
|
||||
val types = params.acceptTypes.filter {
|
||||
it.matches(Regex("[a-zA-Z0-9.+*-]+/[a-zA-Z0-9.+*-]+"))
|
||||
}
|
||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
type = types.singleOrNull() ?: "*/*"
|
||||
if (types.size > 1) putExtra(Intent.EXTRA_MIME_TYPES, types.toTypedArray())
|
||||
putExtra(
|
||||
Intent.EXTRA_ALLOW_MULTIPLE,
|
||||
params.mode == FileChooserParams.MODE_OPEN_MULTIPLE,
|
||||
)
|
||||
}
|
||||
val chooser = Intent.createChooser(intent, "Select a file")
|
||||
if (types.any { it.startsWith("image/") }) {
|
||||
val directory = File(cacheDir, "captures").apply { mkdirs() }
|
||||
capture =
|
||||
FileProvider.getUriForFile(
|
||||
this@MainActivity,
|
||||
"$packageName.files",
|
||||
File.createTempFile("capture-", ".jpg", directory),
|
||||
)
|
||||
chooser.putExtra(
|
||||
Intent.EXTRA_INITIAL_INTENTS,
|
||||
arrayOf(
|
||||
Intent(MediaStore.ACTION_IMAGE_CAPTURE).apply {
|
||||
putExtra(MediaStore.EXTRA_OUTPUT, capture)
|
||||
clipData =
|
||||
android.content.ClipData.newRawUri("Yuvomi photo", capture)
|
||||
addFlags(
|
||||
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION,
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
picker.launch(chooser)
|
||||
}.onFailure {
|
||||
uploadSelection.consume(generation)
|
||||
fileCallback?.onReceiveValue(null)
|
||||
fileCallback = null
|
||||
toast("No compatible file picker is available.")
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
view.setDownloadListener { url, _, disposition, mime, _ ->
|
||||
if (!current.owns(url) &&
|
||||
!BlobTransfer.owns(current, url)
|
||||
) {
|
||||
toast("Only downloads from your server are supported.")
|
||||
} else if (pendingDownload == null && downloadSelection.begin(generation)) {
|
||||
pendingDownload = url
|
||||
val name = URLUtil.guessFileName(
|
||||
url,
|
||||
disposition,
|
||||
mime,
|
||||
).replace(Regex("[^a-zA-Z0-9._ -]"), "_").take(120)
|
||||
runCatching { saveDocument.launch(name.ifBlank { "download" }) }.onFailure {
|
||||
downloadSelection.consume(generation)
|
||||
pendingDownload = null
|
||||
toast("No compatible document provider is available.")
|
||||
}
|
||||
}
|
||||
}
|
||||
content.addView(view, FrameLayout.LayoutParams(-1, -1))
|
||||
val path = preferences.getString("path", "/").orEmpty()
|
||||
view.loadUrl(
|
||||
current.origin + if (path.startsWith("/") && !path.startsWith("//")) path else "/",
|
||||
)
|
||||
}
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
private fun secure(view: WebView) {
|
||||
view.settings.apply {
|
||||
javaScriptEnabled = true
|
||||
domStorageEnabled = true
|
||||
allowFileAccess = false
|
||||
allowContentAccess = false
|
||||
mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW
|
||||
javaScriptCanOpenWindowsAutomatically = false
|
||||
setSupportMultipleWindows(false)
|
||||
mediaPlaybackRequiresUserGesture = true
|
||||
safeBrowsingEnabled = true
|
||||
setGeolocationEnabled(false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun external(value: String) {
|
||||
handler.removeCallbacks(loadTimeout)
|
||||
progress.visibility = View.GONE
|
||||
val uri = runCatching { URI(value) }.getOrNull() ?: return
|
||||
if (uri.scheme != "https" || uri.host.isNullOrBlank() || uri.rawUserInfo != null) {
|
||||
toast("This link type is blocked for your security.")
|
||||
return
|
||||
}
|
||||
AlertDialog.Builder(this).setTitle("Leave your Yuvomi server?")
|
||||
.setMessage(
|
||||
"Destination: ${uri.host}\n\nOpen normal links in your browser. For SSO, use the isolated sign-in window only if you trust this provider. Providers that require a system browser cannot transfer a session back to this app.",
|
||||
)
|
||||
.setPositiveButton("Browser") { _, _ -> openBrowser(value) }
|
||||
.setNeutralButton("Sign-in window") { _, _ -> authentication(value) }
|
||||
.setNegativeButton("Cancel", null).show()
|
||||
}
|
||||
|
||||
private fun authentication(value: String) {
|
||||
if (authDialog != null) return
|
||||
val auth = WebView(this)
|
||||
secure(auth)
|
||||
CookieManager.getInstance().setAcceptThirdPartyCookies(auth, false)
|
||||
auth.webChromeClient = object : WebChromeClient() {
|
||||
override fun onPermissionRequest(request: PermissionRequest) {
|
||||
request.deny()
|
||||
}
|
||||
}
|
||||
auth.webViewClient = object : WebViewClient() {
|
||||
override fun onPageStarted(v: WebView, url: String, favicon: Bitmap?) {
|
||||
authDialog?.setTitle(
|
||||
"Sign in: ${runCatching {
|
||||
URI(url).host
|
||||
}.getOrNull().orEmpty()}",
|
||||
)
|
||||
}
|
||||
override fun shouldOverrideUrlLoading(v: WebView, request: WebResourceRequest): Boolean {
|
||||
val uri = request.url
|
||||
return uri.scheme != "https" || !uri.userInfo.isNullOrEmpty()
|
||||
}
|
||||
override fun onReceivedSslError(v: WebView, h: SslErrorHandler, e: SslError) {
|
||||
h.cancel()
|
||||
}
|
||||
override fun onPageFinished(v: WebView, url: String) {
|
||||
if (server?.owns(url) == true && !URI(url).path.startsWith("/api/")) {
|
||||
CookieManager.getInstance().flush()
|
||||
authDialog?.dismiss()
|
||||
web?.loadUrl(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
authDialog = AlertDialog.Builder(this).setTitle("Isolated web sign-in")
|
||||
.setView(auth).setNegativeButton("Close", null).create().also { dialog ->
|
||||
dialog.setOnDismissListener {
|
||||
auth.stopLoading()
|
||||
auth.removeAllViews()
|
||||
auth.destroy()
|
||||
authDialog = null
|
||||
}
|
||||
dialog.show()
|
||||
dialog.window?.setLayout(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
)
|
||||
}
|
||||
auth.loadUrl(value)
|
||||
}
|
||||
|
||||
private fun openBrowser(value: String) {
|
||||
runCatching {
|
||||
startActivity(
|
||||
Intent(Intent.ACTION_VIEW, value.toUri()).addCategory(Intent.CATEGORY_BROWSABLE),
|
||||
)
|
||||
}
|
||||
.onFailure { toast("No browser is available.") }
|
||||
}
|
||||
|
||||
private fun showError(message: String) {
|
||||
if (isDestroyed || web == null) return
|
||||
handler.removeCallbacks(loadTimeout)
|
||||
progress.visibility = View.GONE
|
||||
if (content.childCount > 1) return
|
||||
val panel = column().apply {
|
||||
addView(text("Connection interrupted", 25f))
|
||||
addView(text(message))
|
||||
addView(
|
||||
button("Retry") {
|
||||
content.removeView(this)
|
||||
web?.reload()
|
||||
},
|
||||
)
|
||||
addView(button("Keep viewing") { content.removeView(this) })
|
||||
addView(button("Server settings") { settings() })
|
||||
localNetworkButton()?.let { addView(it) }
|
||||
}
|
||||
val dark =
|
||||
resources.configuration.uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK ==
|
||||
android.content.res.Configuration.UI_MODE_NIGHT_YES
|
||||
panel.setBackgroundColor(if (dark) 0xff191816.toInt() else 0xfff5f3ed.toInt())
|
||||
content.addView(panel, FrameLayout.LayoutParams(-1, -1))
|
||||
panel.accessibilityLiveRegion = View.ACCESSIBILITY_LIVE_REGION_POLITE
|
||||
ViewCompat.setAccessibilityPaneTitle(panel, "Connection interrupted")
|
||||
}
|
||||
|
||||
private fun settings() {
|
||||
AlertDialog.Builder(this).setTitle("Server settings")
|
||||
.setMessage(
|
||||
"${server?.origin.orEmpty()}\n\nSwitching servers erases all app cookies, storage and cached household data. Downloads saved outside the app remain. This does not revoke the server session; sign out in Yuvomi first where possible.",
|
||||
)
|
||||
.setPositiveButton("Erase and change server") { _, _ ->
|
||||
generation++
|
||||
worker.shutdownNow()
|
||||
worker = Executors.newSingleThreadExecutor()
|
||||
authDialog?.dismiss()
|
||||
releaseWeb()
|
||||
val captures = File(cacheDir, "captures")
|
||||
val capturesErased = !captures.exists() || captures.listFiles()?.all { !it.exists() || it.delete() } == true
|
||||
if (capturesErased && WebViewFeature.isFeatureSupported(WebViewFeature.DELETE_BROWSING_DATA)) {
|
||||
WebStorageCompat.deleteBrowsingData(WebStorage.getInstance()) {
|
||||
CookieManager.getInstance().removeAllCookies {
|
||||
CookieManager.getInstance().flush()
|
||||
preferences.edit { clear() }
|
||||
server = null
|
||||
onboarding()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Older engines cannot reliably erase CacheStorage/IndexedDB.
|
||||
// OS reset is complete and scoped to this application, but closes it.
|
||||
val reset = getSystemService(
|
||||
android.app.ActivityManager::class.java,
|
||||
).clearApplicationUserData()
|
||||
if (!reset) {
|
||||
toast("Local data could not be erased. Clear this app's storage in Android settings before switching servers.")
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
.setNeutralButton("Open server in browser") { _, _ ->
|
||||
server?.let { openBrowser(it.origin) }
|
||||
}
|
||||
.setNegativeButton("Continue", null).show()
|
||||
}
|
||||
|
||||
private fun localNetworkButton(): Button? {
|
||||
if (android.os.Build.VERSION.SDK_INT < 37 ||
|
||||
checkSelfPermission(android.Manifest.permission.ACCESS_LOCAL_NETWORK) ==
|
||||
android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return button("Allow private-network servers") {
|
||||
AlertDialog.Builder(this).setTitle("Private server access")
|
||||
.setMessage(
|
||||
"Android requires permission to connect to a Yuvomi server on your local network. The app connects only to your configured server; it does not scan for devices. Public internet servers do not need this permission.",
|
||||
)
|
||||
.setPositiveButton("Continue") { _, _ ->
|
||||
localNetworkPermission.launch(android.Manifest.permission.ACCESS_LOCAL_NETWORK)
|
||||
}
|
||||
.setNegativeButton("Cancel", null).show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun releaseWeb() {
|
||||
handler.removeCallbacks(loadTimeout)
|
||||
fileCallback?.onReceiveValue(null)
|
||||
fileCallback = null
|
||||
pendingDownload = null
|
||||
revokeCapture()
|
||||
capture = null
|
||||
web?.let { view ->
|
||||
BlobTransfer.forget(view)
|
||||
view.stopLoading()
|
||||
(view.parent as? ViewGroup)?.removeView(view)
|
||||
view.removeAllViews()
|
||||
view.destroy()
|
||||
}
|
||||
web = null
|
||||
}
|
||||
private fun revokeCapture() {
|
||||
capture?.let {
|
||||
revokeUriPermission(it, Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
|
||||
}
|
||||
}
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
outState.putBoolean("uploadOutstanding", uploadSelection.outstanding)
|
||||
outState.putBoolean("downloadOutstanding", downloadSelection.outstanding)
|
||||
super.onSaveInstanceState(outState)
|
||||
}
|
||||
private fun toast(message: String) = Toast.makeText(this, message, Toast.LENGTH_LONG).show()
|
||||
override fun onPause() {
|
||||
web?.onPause()
|
||||
CookieManager.getInstance().flush()
|
||||
super.onPause()
|
||||
}
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
web?.onResume()
|
||||
}
|
||||
override fun onDestroy() {
|
||||
generation++
|
||||
authDialog?.dismiss()
|
||||
releaseWeb()
|
||||
worker.shutdownNow()
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package community.yuvomi.mobile
|
||||
|
||||
/** Never delivers a late system-picker result into a different server/web context. */
|
||||
class SelectionGate {
|
||||
private var owner: Int? = null
|
||||
val outstanding: Boolean get() = owner != null
|
||||
|
||||
fun begin(generation: Int): Boolean {
|
||||
if (outstanding) return false
|
||||
owner = generation
|
||||
return true
|
||||
}
|
||||
|
||||
fun consume(generation: Int): Boolean {
|
||||
val previous = owner
|
||||
owner = null
|
||||
return previous == generation
|
||||
}
|
||||
|
||||
fun restoreOutstanding() {
|
||||
owner = -1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package community.yuvomi.mobile
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SelectionGateTest {
|
||||
@Test fun acceptsOnlyTheInitiatingContextOnce() {
|
||||
val gate = SelectionGate()
|
||||
assertTrue(gate.begin(0))
|
||||
assertTrue(gate.consume(0))
|
||||
assertFalse(gate.consume(0))
|
||||
}
|
||||
|
||||
@Test fun serverSwitchCannotDeliverAnOldSelectionToTheNewServer() {
|
||||
val gate = SelectionGate()
|
||||
assertTrue(gate.begin(0))
|
||||
assertFalse(gate.begin(1))
|
||||
assertFalse(gate.consume(1))
|
||||
assertTrue(gate.begin(1))
|
||||
assertTrue(gate.consume(1))
|
||||
}
|
||||
|
||||
@Test fun processRecreationCancelsSelectionWithoutReusingItsCallback() {
|
||||
val gate = SelectionGate()
|
||||
gate.restoreOutstanding()
|
||||
assertFalse(gate.begin(0))
|
||||
assertFalse(gate.consume(0))
|
||||
assertTrue(gate.begin(0))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<resources>
|
||||
<string name="app_name">Yuvomi Mobile</string>
|
||||
<string name="checking_server">Checking your server…</string>
|
||||
<string name="probe_failed">Cannot verify a compatible Yuvomi server. Check the URL, TLS certificate, VPN, and reverse-proxy access.</string>
|
||||
</resources>
|
||||
@@ -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