mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-05-18 21:40:41 -04:00
Add Android app UI test scaffolding (#1404)
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}"""
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
112
apps/mobile-app/package-lock.json
generated
112
apps/mobile-app/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user