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:
Yuvomi Mobile Contributors committed 2026-10-21 09:12:47 +02:00
1 parent 81973c3c45
commit 8c480ddca3
28 files changed
+2628 -742

No files matched your search

+111
View File
@@ -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())
}
}
+93
View File
@@ -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
}
}