Add Android app UI test scaffolding (#1404)

This commit is contained in:
Leendert de Borst
2026-01-16 22:38:42 +01:00
parent 921df2bc31
commit 74f0e670e1
7 changed files with 1707 additions and 114 deletions

View File

@@ -95,6 +95,9 @@ android {
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 2600100
versionName "0.26.0-alpha"
// Instrumented test configuration
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
signingConfigs {
debug {
@@ -214,7 +217,7 @@ dependencies {
}
}
// Test dependencies
// Test dependencies (unit tests)
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.mockito:mockito-core:4.0.0'
testImplementation 'org.mockito.kotlin:mockito-kotlin:4.0.0'
@@ -227,6 +230,14 @@ dependencies {
testImplementation 'org.junit.jupiter:junit-jupiter-api:5.9.2'
testImplementation 'org.junit.vintage:junit-vintage-engine:5.9.2'
// Instrumented test dependencies (androidTest)
androidTestImplementation 'androidx.test:core:1.5.0'
androidTestImplementation 'androidx.test:runner:1.5.2'
androidTestImplementation 'androidx.test:rules:1.5.0'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.uiautomator:uiautomator:2.2.0'
androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3'
def isGifEnabled = (findProperty('expo.gif.enabled') ?: "") == "true";
def isWebpEnabled = (findProperty('expo.webp.enabled') ?: "") == "true";
def isWebpAnimatedEnabled = (findProperty('expo.webp.animated') ?: "") == "true";

View File

@@ -0,0 +1,724 @@
package net.aliasvault.app
import android.content.Intent
import android.net.Uri
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.UiDevice
import kotlinx.coroutines.runBlocking
import net.aliasvault.app.UITestHelpers.assertTestIdExists
import net.aliasvault.app.UITestHelpers.existsByTestId
import net.aliasvault.app.UITestHelpers.findByTestId
import net.aliasvault.app.UITestHelpers.findByText
import net.aliasvault.app.UITestHelpers.hideKeyboard
import net.aliasvault.app.UITestHelpers.longSleep
import net.aliasvault.app.UITestHelpers.scrollToTestId
import net.aliasvault.app.UITestHelpers.scrollToText
import net.aliasvault.app.UITestHelpers.tapTestId
import net.aliasvault.app.UITestHelpers.typeIntoTestId
import net.aliasvault.app.UITestHelpers.waitForTestId
import net.aliasvault.app.UITestHelpers.waitForText
import net.aliasvault.app.UITestHelpers.waitForTextContains
import org.junit.After
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Assume.assumeTrue
import org.junit.Before
import org.junit.FixMethodOrder
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.MethodSorters
/**
* E2E UI Tests for AliasVault Android app.
*
* These tests use dynamically created test users via the API, so no pre-configured
* credentials are needed. Each test creates its own isolated test user to ensure
* tests can run independently (in isolation or in sequence) with a known state.
*
* Prerequisites:
* - Local API server running at the URL specified in TestConfiguration.apiUrl
* - Android Emulator with the app installed
*
* Note: Tests use UI Automator for interacting with React Native views via accessibility
* labels (testID in React Native maps to contentDescription in Android).
*/
@RunWith(AndroidJUnit4::class)
@FixMethodOrder(MethodSorters.NAME_ASCENDING)
class AliasVaultUITests {
private lateinit var device: UiDevice
private val packageName = "net.aliasvault.app"
@Before
fun setUp() {
device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
// Wake up device if sleeping
device.wakeUp()
}
@After
fun tearDown() {
// Take screenshot on failure would go here if needed
}
// region Test Setup
/**
* Creates a new test user for this test.
* Each test gets its own isolated user to ensure tests can run independently.
*/
private fun createTestUser(): TestUser = runBlocking {
// Check if API is available
val apiAvailable = TestUserRegistration.isApiAvailable()
assumeTrue("API not available at ${TestConfiguration.apiUrl}. Start the local server first.", apiAvailable)
// Create a new test user for this specific test
val user = TestUserRegistration.createTestUser()
println("[Setup] Created test user: ${user.username}")
user
}
/**
* Launch the app fresh.
* Note: For UI tests, the app uses a pre-bundled JS bundle (no Metro needed).
*/
private fun launchApp() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val intent = context.packageManager.getLaunchIntentForPackage(packageName)
intent?.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK)
context.startActivity(intent)
// Wait for app to start and JS bundle to load
Thread.sleep(5000)
// Wait for one of the expected screens (check all simultaneously with polling)
val startTime = System.currentTimeMillis()
val timeout = TestConfiguration.EXTENDED_TIMEOUT_MS
while (System.currentTimeMillis() - startTime < timeout) {
if (device.findByTestId("login-screen") != null ||
device.findByTestId("unlock-screen") != null ||
device.findByTestId("items-screen") != null
) {
return
}
Thread.sleep(200)
}
}
/**
* Open a deep link in the app.
*/
private fun openDeepLink(url: String) {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
setPackage(packageName)
}
context.startActivity(intent)
longSleep()
}
/**
* Pull to refresh gesture.
*/
private fun pullToRefresh() {
device.swipe(
device.displayWidth / 2,
device.displayHeight / 4,
device.displayWidth / 2,
device.displayHeight * 3 / 4,
10,
)
longSleep()
}
// endregion
// region Test 01: Create New Item
/**
* Verifies item creation flow: opens add form, fills in details, saves, and verifies
* the item appears in the list. Creates its own isolated test user.
*/
@Test
fun test01CreateItem() {
val testUser = createTestUser()
val uniqueName = TestConfiguration.generateUniqueName("E2E Test")
println("[Test01] Creating item with name: $uniqueName")
launchApp()
loginWithTestUser(testUser)
// Verify items screen is displayed
println("[Test01] Verifying items screen is displayed")
device.assertTestIdExists("items-screen", TestConfiguration.EXTENDED_TIMEOUT_MS)
// Create item
val itemParams = CreateItemParams(
name = uniqueName,
serviceUrl = "https://example.com",
email = "e2e-test@example.com",
username = "e2euser",
)
assertTrue("Should create item", createItem(itemParams))
// Verify item exists in list
assertTrue("Should find item in list", verifyItemExistsInList(uniqueName))
// Open and verify item details
assertTrue("Should verify item details", openAndVerifyItem(uniqueName, "e2e-test@example.com"))
println("[Test01] Item creation and verification successful")
}
// endregion
// region Test 02: Offline Mode and Sync
/**
* Verifies offline mode and sync recovery:
* 1. Goes offline by setting API URL to invalid (simulates network failure)
* 2. Creates a credential while offline (stored locally)
* 3. Goes back online and triggers sync
* 4. Verifies the credential persists after sync
*/
@Test
fun test02OfflineModeAndSync() {
val testUser = createTestUser()
val originalApiUrl = TestConfiguration.apiUrl
val invalidApiUrl = "http://offline.invalid.localhost:9999"
val uniqueName = TestConfiguration.generateUniqueName("Offline Test")
println("[Test02] Creating offline item with name: $uniqueName")
launchApp()
loginWithTestUser(testUser)
// Step 1: Verify online state
println("[Test02] Step 1: Verify online state")
device.assertTestIdExists("items-screen", TestConfiguration.EXTENDED_TIMEOUT_MS)
// Step 2: Enable offline mode via deep link
println("[Test02] Step 2: Enable offline mode via deep link")
val encodedInvalidUrl = Uri.encode(invalidApiUrl)
println("[Test02] Setting API URL to invalid: $invalidApiUrl")
openDeepLink("aliasvault://open/__debug__/set-api-url/$encodedInvalidUrl")
unlockVaultIfNeeded(testUser)
device.assertTestIdExists("items-screen", TestConfiguration.DEFAULT_TIMEOUT_MS)
// Trigger sync to detect offline state
pullToRefresh()
Thread.sleep(3000)
device.assertTestIdExists("sync-indicator-offline", TestConfiguration.DEFAULT_TIMEOUT_MS)
println("[Test02] Offline mode enabled successfully")
// Step 3: Create item while offline
println("[Test02] Step 3: Create item while offline")
val offlineItemParams = CreateItemParams(
name = uniqueName,
serviceUrl = "https://offline-test.example.com",
email = "offline-test@example.com",
)
assertTrue("Should create item while offline", createItem(offlineItemParams))
assertTrue("Should find item in list", verifyItemExistsInList(uniqueName))
println("[Test02] Item created while offline and appears in list")
// Step 4: Go back online and sync
println("[Test02] Step 4: Go back online and sync")
val encodedValidUrl = Uri.encode(originalApiUrl)
println("[Test02] Restoring API URL to: $originalApiUrl")
openDeepLink("aliasvault://open/__debug__/set-api-url/$encodedValidUrl")
unlockVaultIfNeeded(testUser)
device.assertTestIdExists("items-screen", TestConfiguration.DEFAULT_TIMEOUT_MS)
Thread.sleep(2000)
pullToRefresh()
Thread.sleep(5000)
// Verify offline indicator is gone
assertTrue(
"Offline indicator should be gone after sync",
!device.existsByTestId("sync-indicator-offline"),
)
println("[Test02] Back online and synced successfully")
// Step 5: Verify item persists after sync
println("[Test02] Step 5: Verify item persists after sync")
assertTrue(
"Should verify item after sync",
openAndVerifyItem(uniqueName, "offline-test@example.com"),
)
println("[Test02] Offline item verified after sync - test passed")
}
// endregion
// region Test 03: RPO Recovery
/**
* Verifies RPO (Recovery Point Objective) recovery scenario:
* When the client detects that its local server revision is higher than the actual server revision
* (simulating server data loss/rollback), it should upload its vault to recover the server state.
*/
@Test
fun test03RPORecovery() = runBlocking {
val testUser = createTestUser()
val uniqueName = TestConfiguration.generateUniqueName("RPO Test")
println("[Test03] Testing RPO recovery with item: $uniqueName")
launchApp()
loginWithTestUser(testUser)
// Step 1: Verify initial state and get server revision
println("[Test03] Step 1: Verify initial state")
device.assertTestIdExists("items-screen", TestConfiguration.EXTENDED_TIMEOUT_MS)
val initialRevisions = TestUserRegistration.getVaultRevisionsByUsername(testUser.username)
val initialRevision = initialRevisions.second
println("[Test03] Initial server revision: $initialRevision")
// Step 2: Create credential while online
println("[Test03] Step 2: Create credential while online")
val rpoItemParams = CreateItemParams(
name = uniqueName,
serviceUrl = "https://rpo-test.example.com",
email = "rpo-test@example.com",
)
assertTrue("Should create item", createItem(rpoItemParams))
Thread.sleep(1000) // Wait for sync
println("[Test03] Credential created and synced to server")
val afterCreateRevisions = TestUserRegistration.getVaultRevisionsByUsername(testUser.username)
val revisionAfterCreate = afterCreateRevisions.second
println("[Test03] Server revision after create: $revisionAfterCreate")
assertTrue(
"Server revision should increase after creating credential",
revisionAfterCreate > initialRevision,
)
// Step 3: Simulate server data loss
println("[Test03] Step 3: Simulate server data loss")
val deletedCount = TestUserRegistration.deleteVaultRevisionsByUsername(testUser.username, 1)
println("[Test03] Deleted $deletedCount vault revision(s) from server to simulate data loss")
val afterDeleteRevisions = TestUserRegistration.getVaultRevisionsByUsername(testUser.username)
val revisionAfterDelete = afterDeleteRevisions.second
println("[Test03] Server revision after delete: $revisionAfterDelete")
assertTrue(
"Server revision should decrease after deleting vault revision",
revisionAfterDelete < revisionAfterCreate,
)
// Step 4: Trigger sync for RPO recovery
println("[Test03] Step 4: Trigger sync for RPO recovery")
println("[Test03] Triggering sync - client should detect RPO scenario and upload vault")
pullToRefresh()
Thread.sleep(5000)
// Step 5: Verify credential persists after RPO recovery
println("[Test03] Step 5: Verify credential persists after RPO recovery")
assertTrue(
"Credential should still exist after RPO recovery",
openAndVerifyItem(uniqueName, "rpo-test@example.com"),
)
// Verify server revision restored
val finalRevisions = TestUserRegistration.getVaultRevisionsByUsername(testUser.username)
val finalRevision = finalRevisions.second
println("[Test03] Final server revision: $finalRevision")
assertTrue(
"Server revision should be restored after RPO recovery",
finalRevision >= revisionAfterCreate,
)
println(
"[Test03] SUCCESS - Revision flow: $initialRevision$revisionAfterCreate " +
"(create) → $revisionAfterDelete (rollback) → $finalRevision (recovered)",
)
}
// endregion
// region Test 04: Forced Logout Recovery
/**
* Verifies forced logout recovery mechanism:
* When a forced logout occurs (e.g., 401 unauthorized due to token invalidation),
* the client should preserve the encrypted vault locally. On next login with the same
* credentials, the client should detect the preserved vault and recover by uploading
* it to the server.
*/
@Test
fun test04ForcedLogoutRecovery() = runBlocking {
val testUser = createTestUser()
val uniqueName = TestConfiguration.generateUniqueName("Forced Logout Test")
println("[Test04] Testing forced logout recovery with item: $uniqueName")
launchApp()
loginWithTestUser(testUser)
// Step 1: Verify initial state
println("[Test04] Step 1: Verify initial state")
device.assertTestIdExists("items-screen", TestConfiguration.EXTENDED_TIMEOUT_MS)
// Step 2: Create credential while online
println("[Test04] Step 2: Create credential while online")
val forcedLogoutItemParams = CreateItemParams(
name = uniqueName,
serviceUrl = "https://forced-logout-test.example.com",
email = "forced-logout-test@example.com",
)
assertTrue("Should create item", createItem(forcedLogoutItemParams))
Thread.sleep(1000) // Wait for sync
println("[Test04] Credential created and synced to server")
val beforeLogoutRevisions = TestUserRegistration.getVaultRevisionsByUsername(testUser.username)
val revisionBeforeLogout = beforeLogoutRevisions.second
println("[Test04] Server revision before forced logout: $revisionBeforeLogout")
// Simulate server data loss
val deletedCount = TestUserRegistration.deleteVaultRevisionsByUsername(testUser.username, 1)
println("[Test04] Deleted $deletedCount vault revision(s) to simulate server data loss")
val afterRollbackRevisions = TestUserRegistration.getVaultRevisionsByUsername(testUser.username)
val revisionAfterRollback = afterRollbackRevisions.second
println("[Test04] Server revision after rollback: $revisionAfterRollback")
// Step 3: Block user to trigger forced logout
println("[Test04] Step 3: Block user to trigger forced logout")
TestUserRegistration.blockUserByUsername(testUser.username)
println("[Test04] User blocked")
// Step 4: Trigger sync to cause forced logout
println("[Test04] Step 4: Trigger sync to cause forced logout")
pullToRefresh()
// Wait for session expired dialog and dismiss it
val okButton = device.waitForText("OK", TestConfiguration.DEFAULT_TIMEOUT_MS)
if (okButton != null) {
println("[Test04] Session expired modal detected, dismissing...")
okButton.click()
Thread.sleep(1000)
} else {
println("[Test04] No session expired modal detected, continuing...")
}
// Step 5: Verify login screen
println("[Test04] Step 5: Verify login screen after forced logout")
device.assertTestIdExists("login-screen", TestConfiguration.EXTENDED_TIMEOUT_MS)
// Verify username is prefilled (orphan vault preservation)
val usernameInput = device.findByTestId("username-input")
assertNotNull("Username input should be visible", usernameInput)
val usernameValue = usernameInput?.text ?: ""
println("[Test04] Username field value: '$usernameValue'")
println("[Test04] Forced logout confirmed - on login screen")
// Unblock user so they can log in again
TestUserRegistration.unblockUserByUsername(testUser.username)
println("[Test04] User unblocked")
// Step 6: Re-login with same credentials
println("[Test04] Step 6: Re-login with same credentials")
assertTrue("Should type username", device.typeIntoTestId("username-input", testUser.username))
assertTrue("Should tap password input", device.tapTestId("password-input"))
assertTrue("Should type password", device.typeIntoTestId("password-input", testUser.password))
device.hideKeyboard()
assertTrue("Should tap login button", device.tapTestId("login-button"))
// Wait for login to complete
device.assertTestIdExists("items-screen", TestConfiguration.EXTENDED_TIMEOUT_MS)
Thread.sleep(5000) // Wait for sync
println("[Test04] Re-login successful")
// Step 7: Verify credential still exists
println("[Test04] Step 7: Verify credential still exists")
assertTrue(
"Credential should still exist after forced logout recovery",
openAndVerifyItem(uniqueName, "forced-logout-test@example.com"),
)
// Verify server revision is restored
val finalRevisions = TestUserRegistration.getVaultRevisionsByUsername(testUser.username)
val finalRevision = finalRevisions.second
println("[Test04] Final server revision: $finalRevision")
assertTrue(
"Server revision should be restored after forced logout recovery",
finalRevision >= revisionBeforeLogout,
)
println("[Test04] SUCCESS - Forced logout recovery verified!")
println("[Test04] Revision flow: $revisionBeforeLogout (before) → $revisionAfterRollback (rollback) → $finalRevision (recovered)")
}
// endregion
// region Helper Methods
/**
* Logs in with test user at the beginning of a test.
* Always logs out first if already logged in to ensure we're using the correct test user.
*/
private fun loginWithTestUser(testUser: TestUser) {
// Wait for app to settle - poll for any expected screen
val startTime = System.currentTimeMillis()
val maxWaitTime = 15000L
while (System.currentTimeMillis() - startTime < maxWaitTime) {
if (device.findByTestId("unlock-screen") != null ||
device.findByTestId("login-screen") != null ||
device.findByTestId("items-screen") != null
) {
break
}
Thread.sleep(200)
}
// Handle unlock screen - logout to start fresh
if (device.existsByTestId("unlock-screen")) {
println("[Helper] Unlock screen detected - logging out to login fresh with test user")
if (device.tapTestId("logout-button")) {
// Handle logout confirmation
val confirmButton = device.waitForText("Logout", TestConfiguration.SHORT_TIMEOUT_MS)
confirmButton?.click()
}
device.waitForTestId("login-screen", TestConfiguration.DEFAULT_TIMEOUT_MS)
}
// Check if we're on login screen
if (device.existsByTestId("login-screen")) {
performLogin(testUser)
return
}
// Check if already on items screen
if (device.existsByTestId("items-screen")) {
println("[Helper] Already on items screen, assuming correct user is logged in")
return
}
println("[Helper] Unknown app state after waiting, test may fail")
}
/**
* Unlocks the vault if the unlock screen is displayed.
*/
private fun unlockVaultIfNeeded(testUser: TestUser) {
val unlockScreen = device.waitForTestId("unlock-screen", TestConfiguration.SHORT_TIMEOUT_MS)
if (unlockScreen == null) {
if (device.existsByTestId("items-screen")) {
println("[Helper] Already on items screen, no unlock needed")
} else {
println("[Helper] Not on unlock or items screen, proceeding anyway")
}
return
}
println("[Helper] Unlock screen detected - entering password to unlock")
device.tapTestId("unlock-password-input")
device.typeIntoTestId("unlock-password-input", testUser.password)
device.hideKeyboard()
device.tapTestId("unlock-button")
device.waitForTestId("items-screen", TestConfiguration.EXTENDED_TIMEOUT_MS)
}
/**
* Performs login with the given test user credentials.
*/
private fun performLogin(testUser: TestUser) {
// Check if API URL is already configured to localhost
val serverUrlLink = device.findByTestId("server-url-link")
var needsApiConfig = true
if (serverUrlLink != null) {
val urlText = serverUrlLink.text ?: ""
if (urlText.contains("localhost")) {
println("[Helper] API URL already configured to localhost, skipping API configuration")
needsApiConfig = false
} else {
println("[Helper] API URL shows '$urlText', need to configure to localhost")
}
}
if (needsApiConfig) {
if (device.tapTestId("server-url-link-button") || device.tapTestId("server-url-link")) {
if (device.waitForTestId("api-option-custom", TestConfiguration.DEFAULT_TIMEOUT_MS) != null) {
device.tapTestId("api-option-custom")
if (device.waitForTestId("custom-api-url-input") != null) {
device.tapTestId("custom-api-url-input")
device.typeIntoTestId("custom-api-url-input", TestConfiguration.apiUrl)
println("[Helper] Configured API URL: ${TestConfiguration.apiUrl}")
}
device.hideKeyboard()
device.tapTestId("back-button")
}
}
}
// Wait for login screen
device.waitForTestId("login-screen")
// Enter credentials
device.typeIntoTestId("username-input", testUser.username)
device.tapTestId("password-input")
device.typeIntoTestId("password-input", testUser.password)
device.hideKeyboard()
device.tapTestId("login-button")
// Wait for items screen
device.waitForTestId("items-screen", TestConfiguration.EXTENDED_TIMEOUT_MS)
}
/**
* Parameters for creating a new item.
*/
data class CreateItemParams(
val name: String,
val serviceUrl: String = "https://example.com",
val email: String = "test@example.com",
val username: String? = null,
)
/**
* Creates a new item with the given parameters.
*/
private fun createItem(params: CreateItemParams): Boolean {
println("[Helper] Creating item: ${params.name}")
// Tap add button
if (!device.tapTestId("add-item-button")) {
println("[Helper] Failed to tap add button")
return false
}
if (device.waitForTestId("add-edit-screen", TestConfiguration.DEFAULT_TIMEOUT_MS) == null) {
println("[Helper] Add/edit screen did not appear")
return false
}
// Fill item name
device.scrollToTestId("item-name-input")
device.tapTestId("item-name-input")
device.typeIntoTestId("item-name-input", params.name)
// Fill service URL
device.scrollToTestId("service-url-input")
device.tapTestId("service-url-input")
device.typeIntoTestId("service-url-input", params.serviceUrl)
// Add email
device.scrollToTestId("add-email-button")
device.tapTestId("add-email-button")
device.scrollToTestId("login-email-input")
device.tapTestId("login-email-input")
device.typeIntoTestId("login-email-input", params.email)
// Optionally add username
if (params.username != null) {
device.scrollToTestId("login-username-input")
if (device.existsByTestId("login-username-input")) {
device.tapTestId("login-username-input")
device.typeIntoTestId("login-username-input", params.username)
}
}
device.hideKeyboard()
// Save item
device.tapTestId("save-button")
if (device.waitForText("Login credentials", TestConfiguration.DEFAULT_TIMEOUT_MS) == null) {
println("[Helper] Item detail screen did not appear after save")
return false
}
// Return to items list
Thread.sleep(1000)
device.tapTestId("back-button")
if (device.waitForTestId("items-screen", TestConfiguration.DEFAULT_TIMEOUT_MS) == null) {
println("[Helper] Did not return to items screen")
return false
}
// Wait for list to populate
Thread.sleep(2000)
println("[Helper] Item '${params.name}' created successfully")
return true
}
/**
* Verifies that an item with the given name exists in the items list.
*/
private fun verifyItemExistsInList(name: String): Boolean {
val itemFound = device.scrollToText(name) != null ||
device.waitForText(name, TestConfiguration.DEFAULT_TIMEOUT_MS) != null
if (!itemFound) {
println("[Helper] Item '$name' not found in list")
return false
}
println("[Helper] Item '$name' found in list")
return true
}
/**
* Opens an item from the list and verifies its details.
*/
private fun openAndVerifyItem(name: String, expectedEmail: String? = null): Boolean {
val itemCard = device.scrollToText(name) ?: device.findByText(name)
if (itemCard == null) {
println("[Helper] Item '$name' not found in list")
return false
}
itemCard.click()
if (device.waitForText("Login credentials", TestConfiguration.DEFAULT_TIMEOUT_MS) == null) {
println("[Helper] Item detail screen did not appear")
return false
}
if (expectedEmail != null) {
if (device.waitForTextContains(expectedEmail) == null) {
println("[Helper] Expected email '$expectedEmail' not found")
return false
}
}
println("[Helper] Item '$name' verified successfully")
return true
}
// endregion
}

View File

@@ -0,0 +1,49 @@
package net.aliasvault.app
/**
* Configuration for E2E UI tests.
*/
object TestConfiguration {
/**
* API URL for testing (defaults to local development server).
* Can be overridden by setting the API_URL instrumentation argument.
*/
val apiUrl: String
get() = System.getProperty("API_URL") ?: "http://10.0.2.2:5092"
/**
* Generate a unique name for test items.
*/
fun generateUniqueName(prefix: String = "E2E Test"): String {
val timestamp = System.currentTimeMillis()
return "$prefix $timestamp"
}
/**
* Default timeout for element waiting (milliseconds).
*/
const val DEFAULT_TIMEOUT_MS = 10_000L
/**
* Extended timeout for operations that may take longer (like login with network).
*/
const val EXTENDED_TIMEOUT_MS = 30_000L
/**
* Short timeout for quick checks (milliseconds).
*/
const val SHORT_TIMEOUT_MS = 2_000L
/**
* Default Argon2Id encryption settings matching server defaults.
*/
object EncryptionDefaults {
const val TYPE = "Argon2Id"
const val ITERATIONS = 2
const val MEMORY_SIZE = 19456
const val PARALLELISM = 1
val settingsJson: String
get() = """{"DegreeOfParallelism":$PARALLELISM,"MemorySize":$MEMORY_SIZE,"Iterations":$ITERATIONS}"""
}
}

View File

@@ -0,0 +1,523 @@
package net.aliasvault.app
import android.database.sqlite.SQLiteDatabase
import android.util.Base64
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import net.aliasvault.app.vaultstore.models.VaultSql
import net.aliasvault.app.vaultstore.models.VaultVersions
import org.json.JSONArray
import org.json.JSONObject
import uniffi.aliasvault_core.argon2HashPassword
import uniffi.aliasvault_core.srpDerivePrivateKey
import uniffi.aliasvault_core.srpDeriveVerifier
import uniffi.aliasvault_core.srpGenerateSalt
import java.io.File
import java.net.HttpURLConnection
import java.net.URL
import java.nio.charset.StandardCharsets
import java.security.KeyPairGenerator
import java.security.SecureRandom
import javax.crypto.Cipher
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.SecretKeySpec
/**
* Test user credentials and token.
*/
data class TestUser(
val username: String,
val password: String,
val token: String,
val refreshToken: String,
)
/**
* Token response from the API.
*/
data class TokenResponse(
val token: String,
val refreshToken: String,
)
/**
* Test user registration helper using SRP protocol.
* Uses the Rust core library for SRP operations via UniFFI bindings.
*/
object TestUserRegistration {
private const val TAG = "TestUserRegistration"
/**
* API URL for testing.
*/
val apiUrl: String
get() = TestConfiguration.apiUrl
// region Username/Password Generation
/**
* Generate a random test username.
*/
fun generateTestUsername(): String {
val chars = "abcdefghijklmnopqrstuvwxyz0123456789"
val randomPart = (1..10)
.map { chars.random() }
.joinToString("")
return "test_$randomPart@example.tld"
}
/**
* Generate a test password.
* Uses a static password for easier debugging and test reproducibility.
*/
fun generateTestPassword(): String = "password"
/**
* Normalize username by converting to lowercase and trimming whitespace.
*/
fun normalizeUsername(username: String): String =
username.lowercase().trim()
// endregion
// region Hex Conversion
/**
* Convert ByteArray to uppercase hex string.
*/
fun bytesToHex(data: ByteArray): String =
data.joinToString("") { "%02X".format(it) }
/**
* Convert hex string to ByteArray.
*/
fun hexToBytes(hex: String): ByteArray {
val cleanHex = hex.removePrefix("0x").removePrefix("0X").trim()
require(cleanHex.length % 2 == 0) { "Invalid hex string length" }
return ByteArray(cleanHex.length / 2) { i ->
cleanHex.substring(i * 2, i * 2 + 2).toInt(16).toByte()
}
}
// endregion
// region Registration
/**
* Register a new test user via the API using SRP protocol.
* Uses Rust core for all SRP operations.
*/
suspend fun registerTestUser(
apiBaseUrl: String,
username: String,
password: String,
): TokenResponse = withContext(Dispatchers.IO) {
val baseUrl = apiBaseUrl.trimEnd('/') + "/v1/"
val normalizedUsername = normalizeUsername(username)
// Generate salt using Rust core
val salt = srpGenerateSalt()
// Derive key from password using Rust core Argon2
val passwordHashHex = argon2HashPassword(password, salt)
// Derive SRP private key and verifier using Rust core
val privateKey = srpDerivePrivateKey(salt, normalizedUsername, passwordHashHex)
val verifier = srpDeriveVerifier(privateKey)
// Build registration request
val registerRequest = JSONObject().apply {
put("username", normalizedUsername)
put("salt", salt)
put("verifier", verifier)
put("encryptionType", TestConfiguration.EncryptionDefaults.TYPE)
put("encryptionSettings", TestConfiguration.EncryptionDefaults.settingsJson)
}
// Send registration request
val connection = URL("${baseUrl}Auth/register").openConnection() as HttpURLConnection
try {
connection.requestMethod = "POST"
connection.setRequestProperty("Content-Type", "application/json")
connection.doOutput = true
connection.outputStream.bufferedWriter().use {
it.write(registerRequest.toString())
}
if (connection.responseCode != 200) {
val errorBody = try {
connection.errorStream?.bufferedReader()?.readText() ?: "Unknown error"
} catch (e: Exception) {
"HTTP ${connection.responseCode}"
}
throw Exception("Registration failed: $errorBody")
}
val responseBody = connection.inputStream.bufferedReader().readText()
val json = JSONObject(responseBody)
val tokenResponse = TokenResponse(
token = json.getString("token"),
refreshToken = json.getString("refreshToken"),
)
// Upload initial empty vault
val encryptionKey = hexToBytes(passwordHashHex)
uploadInitialVault(
apiBaseUrl = apiBaseUrl,
token = tokenResponse.token,
username = normalizedUsername,
encryptionKey = encryptionKey,
)
tokenResponse
} finally {
connection.disconnect()
}
}
// endregion
// region Vault Upload
/**
* Upload an initial empty vault to the server.
*/
private suspend fun uploadInitialVault(
apiBaseUrl: String,
token: String,
username: String,
encryptionKey: ByteArray,
) = withContext(Dispatchers.IO) {
val baseUrl = apiBaseUrl.trimEnd('/') + "/v1/"
// Create empty vault database
val vaultBase64 = createEmptyVaultDatabase()
// Encrypt the vault using AES-GCM
val encryptedVault = symmetricEncrypt(vaultBase64, encryptionKey)
// Generate RSA key pair for the vault
val rsaKeyPair = generateRsaKeyPair()
// Get current timestamp in ISO8601 format
val now = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", java.util.Locale.US)
.apply { timeZone = java.util.TimeZone.getTimeZone("UTC") }
.format(java.util.Date())
// Build vault upload request
val vaultRequest = JSONObject().apply {
put("username", normalizeUsername(username))
put("blob", encryptedVault)
put("version", VaultVersions.latestVersion)
put("currentRevisionNumber", VaultVersions.latestRevision)
put("encryptionPublicKey", rsaKeyPair.first)
put("credentialsCount", 0)
put("emailAddressList", JSONArray())
put("privateEmailDomainList", JSONArray())
put("hiddenPrivateEmailDomainList", JSONArray())
put("publicEmailDomainList", JSONArray())
put("createdAt", now)
put("updatedAt", now)
}
val connection = URL("${baseUrl}Vault").openConnection() as HttpURLConnection
try {
connection.requestMethod = "POST"
connection.setRequestProperty("Content-Type", "application/json")
connection.setRequestProperty("Authorization", "Bearer $token")
connection.doOutput = true
connection.outputStream.bufferedWriter().use {
it.write(vaultRequest.toString())
}
if (connection.responseCode != 200) {
val errorBody = try {
connection.errorStream?.bufferedReader()?.readText() ?: "Unknown error"
} catch (e: Exception) {
"HTTP ${connection.responseCode}"
}
throw Exception("Failed to upload vault: $errorBody")
}
} finally {
connection.disconnect()
}
}
// endregion
// region Encryption Helpers
/**
* Encrypt data using AES-GCM.
*/
private fun symmetricEncrypt(plaintext: String, key: ByteArray): String {
val iv = ByteArray(12)
SecureRandom().nextBytes(iv)
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
val keySpec = SecretKeySpec(key, "AES")
val gcmSpec = GCMParameterSpec(128, iv)
cipher.init(Cipher.ENCRYPT_MODE, keySpec, gcmSpec)
val encrypted = cipher.doFinal(plaintext.toByteArray(StandardCharsets.UTF_8))
// Combined format: nonce + ciphertext + tag
val combined = ByteArray(iv.size + encrypted.size)
System.arraycopy(iv, 0, combined, 0, iv.size)
System.arraycopy(encrypted, 0, combined, iv.size, encrypted.size)
return Base64.encodeToString(combined, Base64.NO_WRAP)
}
/**
* Generate RSA key pair for vault encryption.
*/
private fun generateRsaKeyPair(): Pair<String, String> {
val keyPairGenerator = KeyPairGenerator.getInstance("RSA")
keyPairGenerator.initialize(2048)
val keyPair = keyPairGenerator.generateKeyPair()
val publicKey = keyPair.public as java.security.interfaces.RSAPublicKey
val privateKey = keyPair.private as java.security.interfaces.RSAPrivateKey
// Export as simple JWK format
val publicKeyJwk = JSONObject().apply {
put("kty", "RSA")
put("key_ops", JSONArray().put("encrypt"))
put("ext", true)
put(
"n",
Base64.encodeToString(
publicKey.modulus.toByteArray(),
Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP,
),
)
put(
"e",
Base64.encodeToString(
publicKey.publicExponent.toByteArray(),
Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP,
),
)
}
val privateKeyJwk = JSONObject().apply {
put("kty", "RSA")
put("key_ops", JSONArray().put("decrypt"))
put("ext", true)
put(
"n",
Base64.encodeToString(
privateKey.modulus.toByteArray(),
Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP,
),
)
}
return Pair(publicKeyJwk.toString(), privateKeyJwk.toString())
}
// endregion
// region Empty Vault Creation
/**
* Create an empty vault database as base64 string.
*/
private fun createEmptyVaultDatabase(): String {
val tempFile = File.createTempFile("vault", ".db")
try {
val db = SQLiteDatabase.openOrCreateDatabase(tempFile, null)
try {
// Execute the complete schema SQL
val statements = VaultSql.completeSchema.split(";")
for (statement in statements) {
val trimmed = statement.trim()
if (trimmed.isNotEmpty()) {
db.execSQL("$trimmed;")
}
}
} finally {
db.close()
}
// Read the database file and encode as base64
val dbBytes = tempFile.readBytes()
return Base64.encodeToString(dbBytes, Base64.NO_WRAP)
} finally {
tempFile.delete()
}
}
// endregion
// region Public API
/**
* Create a test user with random credentials.
*/
suspend fun createTestUser(apiBaseUrl: String? = null): TestUser {
val url = apiBaseUrl ?: apiUrl
val username = generateTestUsername()
val password = generateTestPassword()
val tokenResponse = registerTestUser(
apiBaseUrl = url,
username = username,
password = password,
)
return TestUser(
username = username,
password = password,
token = tokenResponse.token,
refreshToken = tokenResponse.refreshToken,
)
}
/**
* Check if the API is available.
*/
suspend fun isApiAvailable(apiBaseUrl: String? = null): Boolean = withContext(Dispatchers.IO) {
val url = (apiBaseUrl ?: apiUrl).trimEnd('/') + "/v1/"
try {
val connection = URL("${url}Auth/status").openConnection() as HttpURLConnection
connection.requestMethod = "GET"
connection.setRequestProperty("Content-Type", "application/json")
connection.connectTimeout = 5000
connection.readTimeout = 5000
val responseCode = connection.responseCode
connection.disconnect()
// Status endpoint returns 401 when not authenticated, but that means API is running
responseCode == 401 || responseCode == 200
} catch (e: Exception) {
Log.e(TAG, "API not available", e)
false
}
}
// endregion
// region Test Helpers (DEV API Endpoints)
/**
* Get vault revision information for a user by username.
* This is an anonymous endpoint that doesn't require authentication.
*/
suspend fun getVaultRevisionsByUsername(
username: String,
apiBaseUrl: String? = null,
): Pair<Int, Int> = withContext(Dispatchers.IO) {
val url = (apiBaseUrl ?: apiUrl).trimEnd('/') + "/v1/"
val encodedUsername = java.net.URLEncoder.encode(username, "UTF-8")
val connection =
URL("${url}Test/vault-revisions/by-username/$encodedUsername").openConnection() as HttpURLConnection
try {
connection.requestMethod = "GET"
connection.setRequestProperty("Content-Type", "application/json")
if (connection.responseCode != 200) {
throw Exception("Failed to get vault revisions: HTTP ${connection.responseCode}")
}
val responseBody = connection.inputStream.bufferedReader().readText()
val json = JSONObject(responseBody)
Pair(
json.optInt("count", 0),
json.optInt("currentRevision", 0),
)
} finally {
connection.disconnect()
}
}
/**
* Delete the newest vault revisions for a user by username.
*/
suspend fun deleteVaultRevisionsByUsername(
username: String,
count: Int,
apiBaseUrl: String? = null,
): Int = withContext(Dispatchers.IO) {
val url = (apiBaseUrl ?: apiUrl).trimEnd('/') + "/v1/"
val encodedUsername = java.net.URLEncoder.encode(username, "UTF-8")
val connection =
URL("${url}Test/vault-revisions/by-username/$encodedUsername/$count").openConnection() as HttpURLConnection
try {
connection.requestMethod = "DELETE"
connection.setRequestProperty("Content-Type", "application/json")
if (connection.responseCode != 200) {
throw Exception("Failed to delete vault revisions: HTTP ${connection.responseCode}")
}
val responseBody = connection.inputStream.bufferedReader().readText()
val json = JSONObject(responseBody)
json.optInt("deleted", 0)
} finally {
connection.disconnect()
}
}
/**
* Block a user's account by username.
*/
suspend fun blockUserByUsername(
username: String,
apiBaseUrl: String? = null,
) = withContext(Dispatchers.IO) {
val url = (apiBaseUrl ?: apiUrl).trimEnd('/') + "/v1/"
val encodedUsername = java.net.URLEncoder.encode(username, "UTF-8")
val connection =
URL("${url}Test/block-user/by-username/$encodedUsername").openConnection() as HttpURLConnection
try {
connection.requestMethod = "POST"
connection.setRequestProperty("Content-Type", "application/json")
if (connection.responseCode != 200) {
throw Exception("Failed to block user: HTTP ${connection.responseCode}")
}
} finally {
connection.disconnect()
}
}
/**
* Unblock a user's account by username.
*/
suspend fun unblockUserByUsername(
username: String,
apiBaseUrl: String? = null,
) = withContext(Dispatchers.IO) {
val url = (apiBaseUrl ?: apiUrl).trimEnd('/') + "/v1/"
val encodedUsername = java.net.URLEncoder.encode(username, "UTF-8")
val connection =
URL("${url}Test/unblock-user/by-username/$encodedUsername").openConnection() as HttpURLConnection
try {
connection.requestMethod = "POST"
connection.setRequestProperty("Content-Type", "application/json")
if (connection.responseCode != 200) {
throw Exception("Failed to unblock user: HTTP ${connection.responseCode}")
}
} finally {
connection.disconnect()
}
}
// endregion
}

View File

@@ -0,0 +1,399 @@
package net.aliasvault.app
import android.util.Log
import androidx.test.uiautomator.By
import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.UiObject2
import androidx.test.uiautomator.Until
/**
* UI test helper functions for Android instrumented tests.
* Provides utilities for interacting with React Native views via UI Automator.
*/
object UITestHelpers {
private const val TAG = "UITestHelpers"
// region Element Finding
/**
* Find an element by its testID.
* React Native on Android exposes testID via resource-id (without package prefix).
*/
fun UiDevice.findByTestId(testId: String): UiObject2? {
// Primary: resource-id without package prefix - this is where RN maps testID
return findObject(By.res(testId))
}
/**
* Find an element by text content.
*/
fun UiDevice.findByText(text: String): UiObject2? {
return findObject(By.text(text))
}
/**
* Find an element by text containing a substring.
*/
fun UiDevice.findByTextContains(text: String): UiObject2? {
return findObject(By.textContains(text))
}
// endregion
// region Waiting
/**
* Wait for an element with testID to exist.
* Uses resource-id without package prefix (By.res) which is where React Native maps testID.
*/
fun UiDevice.waitForTestId(
testId: String,
timeout: Long = TestConfiguration.DEFAULT_TIMEOUT_MS,
): UiObject2? {
// Primary: resource-id without package prefix - this is where RN maps testID
val result = wait(Until.findObject(By.res(testId)), timeout)
if (result == null) {
Log.w(TAG, "Timeout waiting for testId: $testId")
}
return result
}
/**
* Wait for an element with text to exist.
*/
fun UiDevice.waitForText(
text: String,
timeout: Long = TestConfiguration.SHORT_TIMEOUT_MS,
): UiObject2? {
val result = wait(Until.findObject(By.text(text)), timeout)
if (result == null) {
Log.w(TAG, "Timeout waiting for text: $text")
}
return result
}
/**
* Wait for an element with text containing substring to exist.
*/
fun UiDevice.waitForTextContains(
text: String,
timeout: Long = TestConfiguration.SHORT_TIMEOUT_MS,
): UiObject2? {
val result = wait(Until.findObject(By.textContains(text)), timeout)
if (result == null) {
Log.w(TAG, "Timeout waiting for text containing: $text")
}
return result
}
/**
* Wait for an element to be gone.
*/
fun UiDevice.waitForTestIdGone(
testId: String,
timeout: Long = TestConfiguration.SHORT_TIMEOUT_MS,
): Boolean {
// Primary: resource-id without package prefix - this is where RN maps testID
return wait(Until.gone(By.res(testId)), timeout) ?: true
}
/**
* Wait for text to be gone.
*/
fun UiDevice.waitForTextGone(
text: String,
timeout: Long = TestConfiguration.SHORT_TIMEOUT_MS,
): Boolean {
return wait(Until.gone(By.text(text)), timeout) ?: false
}
// endregion
// region Existence Checks
/**
* Check if an element with testID exists.
*/
fun UiDevice.existsByTestId(testId: String): Boolean {
return findByTestId(testId) != null
}
/**
* Check if an element with text exists.
*/
fun UiDevice.existsByText(text: String): Boolean {
return findByText(text) != null
}
/**
* Check if an element with text containing substring exists.
*/
fun UiDevice.existsByTextContains(text: String): Boolean {
return findByTextContains(text) != null
}
// endregion
// region Actions
/**
* Tap on an element with testID.
*/
fun UiDevice.tapTestId(
testId: String,
timeout: Long = TestConfiguration.SHORT_TIMEOUT_MS,
): Boolean {
val element = waitForTestId(testId, timeout)
return if (element != null) {
element.click()
true
} else {
Log.e(TAG, "Failed to tap testId: $testId - element not found")
false
}
}
/**
* Tap on an element with text.
*/
fun UiDevice.tapText(
text: String,
timeout: Long = TestConfiguration.SHORT_TIMEOUT_MS,
): Boolean {
val element = waitForText(text, timeout)
return if (element != null) {
element.click()
true
} else {
Log.e(TAG, "Failed to tap text: $text - element not found")
false
}
}
/**
* Type text into an element with testID.
*/
fun UiDevice.typeIntoTestId(
testId: String,
text: String,
timeout: Long = TestConfiguration.SHORT_TIMEOUT_MS,
): Boolean {
val element = waitForTestId(testId, timeout)
return if (element != null) {
element.click()
Thread.sleep(100) // Small delay for focus
element.text = text
true
} else {
Log.e(TAG, "Failed to type into testId: $testId - element not found")
false
}
}
/**
* Clear text in an element with testID.
*/
fun UiDevice.clearTestId(
testId: String,
timeout: Long = TestConfiguration.SHORT_TIMEOUT_MS,
): Boolean {
val element = waitForTestId(testId, timeout)
return if (element != null) {
element.click()
Thread.sleep(100) // Small delay for focus
element.clear()
true
} else {
Log.e(TAG, "Failed to clear testId: $testId - element not found")
false
}
}
// endregion
// region Scrolling
/**
* Scroll down to find an element with testID.
*/
fun UiDevice.scrollToTestId(
testId: String,
maxScrolls: Int = 5,
): UiObject2? {
repeat(maxScrolls) {
findByTestId(testId)?.let { return it }
swipe(
displayWidth / 2,
displayHeight * 3 / 4,
displayWidth / 2,
displayHeight / 4,
10,
)
Thread.sleep(300) // Wait for scroll to settle
}
return findByTestId(testId)
}
/**
* Scroll down to find an element with text.
*/
fun UiDevice.scrollToText(
text: String,
maxScrolls: Int = 5,
): UiObject2? {
repeat(maxScrolls) {
findByText(text)?.let { return it }
swipe(
displayWidth / 2,
displayHeight * 3 / 4,
displayWidth / 2,
displayHeight / 4,
10,
)
Thread.sleep(300) // Wait for scroll to settle
}
return findByText(text)
}
// endregion
// region Navigation
/**
* Navigate back using the device back button.
*/
fun UiDevice.navigateBack() {
pressBack()
Thread.sleep(500) // Wait for navigation animation
}
/**
* Navigate home using the device home button.
*/
fun UiDevice.navigateHome() {
pressHome()
Thread.sleep(500) // Wait for navigation animation
}
// endregion
// region Assert Helpers
/**
* Assert that an element with testID exists.
*/
fun UiDevice.assertTestIdExists(
testId: String,
timeout: Long = TestConfiguration.DEFAULT_TIMEOUT_MS,
) {
val element = waitForTestId(testId, timeout)
if (element == null) {
throw AssertionError("Expected element with testId '$testId' to exist, but it was not found")
}
}
/**
* Assert that an element with text exists.
*/
fun UiDevice.assertTextExists(
text: String,
timeout: Long = TestConfiguration.DEFAULT_TIMEOUT_MS,
) {
val element = waitForText(text, timeout)
if (element == null) {
throw AssertionError("Expected element with text '$text' to exist, but it was not found")
}
}
/**
* Assert that an element with text containing substring exists.
*/
fun UiDevice.assertTextContains(
text: String,
timeout: Long = TestConfiguration.DEFAULT_TIMEOUT_MS,
) {
val element = waitForTextContains(text, timeout)
if (element == null) {
throw AssertionError(
"Expected element containing text '$text' to exist, but it was not found",
)
}
}
/**
* Assert that an element with testID does not exist.
*/
fun UiDevice.assertTestIdNotExists(testId: String) {
val element = findByTestId(testId)
if (element != null) {
throw AssertionError("Expected element with testId '$testId' to NOT exist, but it was found")
}
}
/**
* Assert that an element with text does not exist.
*/
fun UiDevice.assertTextNotExists(text: String) {
val element = findByText(text)
if (element != null) {
throw AssertionError("Expected element with text '$text' to NOT exist, but it was found")
}
}
// endregion
// region Text Field Helpers
/**
* Get the text value from an element with testID.
*/
fun UiDevice.getTextFromTestId(testId: String): String? {
return findByTestId(testId)?.text
}
/**
* Check if a text field with testID has specific text.
*/
fun UiDevice.testIdHasText(testId: String, expectedText: String): Boolean {
return findByTestId(testId)?.text == expectedText
}
// endregion
// region Keyboard
/**
* Hide the keyboard if visible.
*/
fun UiDevice.hideKeyboard() {
pressBack()
Thread.sleep(200)
}
// endregion
// region Sleep Helpers
/**
* Short sleep for UI to update.
*/
fun shortSleep() {
Thread.sleep(500)
}
/**
* Medium sleep for animations.
*/
fun mediumSleep() {
Thread.sleep(1000)
}
/**
* Long sleep for network operations.
*/
fun longSleep() {
Thread.sleep(2000)
}
// endregion
}

View File

@@ -74,7 +74,6 @@
"eslint-config-expo": "~9.2.0",
"eslint-plugin-jsdoc": "^55.2.0",
"eslint-plugin-react-native": "^5.0.0",
"expo-dev-client": "~5.1.8",
"globals": "^16.3.0",
"jest": "^29.2.1",
"jest-expo": "~53.0.0",
@@ -8775,86 +8774,6 @@
"react-native": "*"
}
},
"node_modules/expo-dev-client": {
"version": "5.1.8",
"resolved": "https://registry.npmjs.org/expo-dev-client/-/expo-dev-client-5.1.8.tgz",
"integrity": "sha512-IopYPgBi3JflksO5ieTphbKsbYHy9iIVdT/d69It++y0iBMSm0oBIoDmUijrHKjE3fV6jnrwrm8luU13/mzIQQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"expo-dev-launcher": "5.1.11",
"expo-dev-menu": "6.1.10",
"expo-dev-menu-interface": "1.10.0",
"expo-manifests": "~0.16.4",
"expo-updates-interface": "~1.1.0"
},
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-dev-launcher": {
"version": "5.1.11",
"resolved": "https://registry.npmjs.org/expo-dev-launcher/-/expo-dev-launcher-5.1.11.tgz",
"integrity": "sha512-bN0+nv5H038s8Gzf8i16hwCyD3sWDmHp7vb+QbL1i6B3XNnICCKS/H/3VH6H3PRMvCmoLGPlg+ODDqGlf0nu3g==",
"dev": true,
"license": "MIT",
"dependencies": {
"ajv": "8.11.0",
"expo-dev-menu": "6.1.10",
"expo-manifests": "~0.16.4",
"resolve-from": "^5.0.0"
},
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-dev-launcher/node_modules/ajv": {
"version": "8.11.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz",
"integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2",
"uri-js": "^4.2.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/expo-dev-launcher/node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"dev": true,
"license": "MIT"
},
"node_modules/expo-dev-menu": {
"version": "6.1.10",
"resolved": "https://registry.npmjs.org/expo-dev-menu/-/expo-dev-menu-6.1.10.tgz",
"integrity": "sha512-LaI0Bw5zzw5XefjYSX6YaMydzk0YBysjqQoxzj6ufDyKgwAfPmFwOLkZ03DOSerc9naezGLNAGgTEN6QTgMmgQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"expo-dev-menu-interface": "1.10.0"
},
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-dev-menu-interface": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/expo-dev-menu-interface/-/expo-dev-menu-interface-1.10.0.tgz",
"integrity": "sha512-NxtM/qot5Rh2cY333iOE87dDg1S8CibW+Wu4WdLua3UMjy81pXYzAGCZGNOeY7k9GpNFqDPNDXWyBSlk9r2pBg==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-document-picker": {
"version": "13.1.6",
"resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-13.1.6.tgz",
@@ -8896,13 +8815,6 @@
"expo": "*"
}
},
"node_modules/expo-json-utils": {
"version": "0.15.0",
"resolved": "https://registry.npmjs.org/expo-json-utils/-/expo-json-utils-0.15.0.tgz",
"integrity": "sha512-duRT6oGl80IDzH2LD2yEFWNwGIC2WkozsB6HF3cDYNoNNdUvFk6uN3YiwsTsqVM/D0z6LEAQ01/SlYvN+Fw0JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/expo-keep-awake": {
"version": "14.1.4",
"resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-14.1.4.tgz",
@@ -8963,20 +8875,6 @@
"react": "*"
}
},
"node_modules/expo-manifests": {
"version": "0.16.6",
"resolved": "https://registry.npmjs.org/expo-manifests/-/expo-manifests-0.16.6.tgz",
"integrity": "sha512-1A+do6/mLUWF9xd3uCrlXr9QFDbjbfqAYmUy8UDLOjof1lMrOhyeC4Yi6WexA/A8dhZEpIxSMCKfn7G4aHAh4w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@expo/config": "~11.0.12",
"expo-json-utils": "~0.15.0"
},
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-modules-autolinking": {
"version": "2.1.14",
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-2.1.14.tgz",
@@ -9115,16 +9013,6 @@
"react-native": "*"
}
},
"node_modules/expo-updates-interface": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/expo-updates-interface/-/expo-updates-interface-1.1.0.tgz",
"integrity": "sha512-DeB+fRe0hUDPZhpJ4X4bFMAItatFBUPjw/TVSbJsaf3Exeami+2qbbJhWkcTMoYHOB73nOIcaYcWXYJnCJXO0w==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-web-browser": {
"version": "14.2.0",
"resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-14.2.0.tgz",

View File

@@ -95,7 +95,6 @@
"eslint-config-expo": "~9.2.0",
"eslint-plugin-jsdoc": "^55.2.0",
"eslint-plugin-react-native": "^5.0.0",
"expo-dev-client": "~5.1.8",
"globals": "^16.3.0",
"jest": "^29.2.1",
"jest-expo": "~53.0.0",