Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ Test workspace for [FlutterProbe](https://github.com/AlphaWaveSystems/flutter-pr
## Structure

```
mobile/ Flutter test app (iOS + Android) — 11 screens, 78 E2E tests
mobile/ Flutter test app (iOS + Android) — 11 screens, 78 E2E tests
native/android-kotlin/ Native Android twin (Kotlin + Jetpack Compose) — same 11 screens, for native-verb testing
```

## Mobile App
Expand All @@ -28,6 +29,17 @@ The `BiometricLoginPage` exercises the new `enroll biometric` / `biometric match
- Android: emulator with fingerprint ID `1` pre-enrolled in Settings → Security
- Physical devices skip these steps with a warning (`set location`-style behavior)

## Native Android Twin

[`native/android-kotlin/`](native/android-kotlin/) is a full native Android replica of the Flutter app (Kotlin, Jetpack Compose, applicationId `com.alphawavesystems.probe_test_app_native`) built to exercise FlutterProbe's **native verb family** (`tap native`, `type native`, `see native`, `don't see native`) against a real non-Flutter app. Every interactive/assertable view carries a Compose `testTag` that mirrors the Flutter app's `ValueKey` names 1:1 and is surfaced as a uiautomator `resource-id` (`testTagsAsResourceId`), so the same element vocabulary works across both apps. The Biometric Login screen uses the real Android `BiometricPrompt`, driven by `biometric match` / `biometric no match`. Its native-verb suite lives in [`native/android-kotlin/probe-tests/`](native/android-kotlin/probe-tests/) — see the suite header for the run pattern (native verbs are CLI-side; a ProbeAgent host app holds the session while this app is foregrounded).

```bash
cd native/android-kotlin
JAVA_HOME=$(/usr/libexec/java_home -v 21) ./gradlew assembleDebug
adb install -r app/build/outputs/apk/debug/app-debug.apk
adb shell am start -n com.alphawavesystems.probe_test_app_native/.MainActivity
```

## Running Tests

```bash
Expand Down
6 changes: 6 additions & 0 deletions native/android-kotlin/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.gradle/
build/
local.properties
.idea/
*.iml
.DS_Store
52 changes: 52 additions & 0 deletions native/android-kotlin/app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
}

android {
namespace = "com.alphawavesystems.probe_test_app_native"
compileSdk = 35
buildToolsVersion = "35.0.0"

defaultConfig {
// Distinct from the Flutter app (com.alphawavesystems.probe_test_app)
// so both twins can be installed on the same device.
applicationId = "com.alphawavesystems.probe_test_app_native"
minSdk = 24
targetSdk = 35
versionCode = 1
versionName = "1.0"
}

buildFeatures {
compose = true
}

compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}

kotlinOptions {
jvmTarget = "17"
}
}

dependencies {
val composeBom = platform("androidx.compose:compose-bom:2025.06.01")
implementation(composeBom)

implementation("androidx.core:core-ktx:1.16.0")
implementation("androidx.activity:activity-compose:1.10.1")
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-core")
implementation("androidx.navigation:navigation-compose:2.9.0")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.9.1")

// Real BiometricPrompt (fingerprint) — requires a FragmentActivity host.
implementation("androidx.biometric:biometric:1.1.0")
implementation("androidx.fragment:fragment-ktx:1.8.8")
}
21 changes: 21 additions & 0 deletions native/android-kotlin/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<uses-permission android:name="android.permission.INTERNET" />

<application
android:label="Probe Native Twin"
android:theme="@android:style/Theme.Material.Light.NoActionBar">
<activity
android:name=".MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>

</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package com.alphawavesystems.probe_test_app_native

import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.fragment.app.FragmentActivity
import com.alphawavesystems.probe_test_app_native.navigation.AppNavHost

/// Native Android (Jetpack Compose) twin of the Flutter probe test app.
///
/// Extends [FragmentActivity] (not plain ComponentActivity) because
/// androidx.biometric's BiometricPrompt requires a FragmentActivity host.
class MainActivity : FragmentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
ProbeTestAppNative()
}
}
}

@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun ProbeTestAppNative() {
// Mirror the Flutter app's deep-purple Material 3 light theme.
MaterialTheme(
colorScheme = lightColorScheme(primary = Color(0xFF6750A4)),
) {
Surface(
modifier = Modifier
.fillMaxSize()
// Surfaces every Modifier.testTag(...) in the tree as a
// uiautomator resource-id so flutter-probe's native verbs
// (`tap native`, `see native`, `type native`) can match by id.
.semantics { testTagsAsResourceId = true },
) {
AppNavHost()
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.alphawavesystems.probe_test_app_native.data

import kotlinx.coroutines.delay

data class User(
val id: Int,
val email: String,
val name: String,
)

class AuthFailure(message: String) : Exception(message)

/// Mirrors the Flutter app's AuthRemoteDatasource: simulated remote login
/// with a 1 second network delay and two hardcoded credential pairs.
object AuthRepository {
/// Accepted credentials:
/// - test@test.com / password -> Test User
/// - admin@test.com / admin123 -> Admin User
///
/// Throws [AuthFailure] for anything else.
suspend fun login(email: String, password: String): User {
delay(1000)

if (email == "test@test.com" && password == "password") {
return User(id = 1, email = "test@test.com", name = "Test User")
}
if (email == "admin@test.com" && password == "admin123") {
return User(id = 2, email = "admin@test.com", name = "Admin User")
}
throw AuthFailure("Invalid email or password")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package com.alphawavesystems.probe_test_app_native.data

import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch

/// Twin of the Flutter app's AuthProvider (ChangeNotifier).
class AuthViewModel : ViewModel() {
var currentUser by mutableStateOf<User?>(null)
private set
var isLoading by mutableStateOf(false)
private set
var error by mutableStateOf<String?>(null)
private set

val isAuthenticated: Boolean get() = currentUser != null

fun login(email: String, password: String, onSuccess: () -> Unit) {
isLoading = true
error = null
viewModelScope.launch {
try {
currentUser = AuthRepository.login(email.trim(), password)
isLoading = false
onSuccess()
} catch (e: AuthFailure) {
error = e.message
isLoading = false
} catch (e: Exception) {
error = "An unexpected error occurred"
isLoading = false
}
}
}

/// Marks the user as authenticated after a successful biometric capture.
/// Bypasses email/password since the OS has already proven identity.
fun loginWithBiometrics() {
currentUser = User(id = -1, email = "biometric@local", name = "Biometric User")
error = null
}

fun logout() {
currentUser = null
error = null
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package com.alphawavesystems.probe_test_app_native.data

import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel

data class Item(
val id: Int,
val title: String,
val description: String,
)

/// Twin of the Flutter app's ItemProvider: 50 generated items, live
/// case-insensitive title search, and delete (driven by swipe-to-dismiss).
class ItemsViewModel : ViewModel() {
private val allItems = (0 until 50)
.map { Item(id = it, title = "Item $it", description = "Description for item $it") }
.toMutableList()

var searchQuery by mutableStateOf("")
private set
var items by mutableStateOf<List<Item>>(allItems.toList())
private set

fun search(query: String) {
searchQuery = query
applySearch()
}

fun deleteItem(id: Int) {
allItems.removeAll { it.id == id }
applySearch()
}

private fun applySearch() {
items = if (searchQuery.isEmpty()) {
allItems.toList()
} else {
allItems.filter { it.title.contains(searchQuery, ignoreCase = true) }
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.alphawavesystems.probe_test_app_native.data

import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel

/// Twin of the Flutter app's SettingsProvider + AppPreferences entity.
/// Same defaults: darkMode=false, notifications=true, terms=false, English.
class SettingsViewModel : ViewModel() {
var darkMode by mutableStateOf(false)
private set
var notifications by mutableStateOf(true)
private set
var termsAccepted by mutableStateOf(false)
private set
var language by mutableStateOf("English")
private set

val languages = listOf("English", "Spanish", "French", "German", "Japanese")

fun toggleDarkMode(value: Boolean) { darkMode = value }
fun toggleNotifications(value: Boolean) { notifications = value }
fun toggleTermsAccepted(value: Boolean) { termsAccepted = value }
fun selectLanguage(value: String) { language = value }

/// In-memory persistence, matching the Flutter twin's
/// SettingsRepositoryImpl (which also keeps preferences in memory).
fun savePreferences() { /* no-op: state is already the source of truth */ }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package com.alphawavesystems.probe_test_app_native.navigation

import androidx.compose.runtime.Composable
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.alphawavesystems.probe_test_app_native.data.AuthViewModel
import com.alphawavesystems.probe_test_app_native.data.ItemsViewModel
import com.alphawavesystems.probe_test_app_native.data.SettingsViewModel
import com.alphawavesystems.probe_test_app_native.screens.ApiScreen
import com.alphawavesystems.probe_test_app_native.screens.BiometricLoginScreen
import com.alphawavesystems.probe_test_app_native.screens.DashboardScreen
import com.alphawavesystems.probe_test_app_native.screens.DeviceScreen
import com.alphawavesystems.probe_test_app_native.screens.DynamicScreen
import com.alphawavesystems.probe_test_app_native.screens.GesturesScreen
import com.alphawavesystems.probe_test_app_native.screens.HomeScreen
import com.alphawavesystems.probe_test_app_native.screens.ItemListScreen
import com.alphawavesystems.probe_test_app_native.screens.LoginScreen
import com.alphawavesystems.probe_test_app_native.screens.SettingsScreen
import com.alphawavesystems.probe_test_app_native.screens.SignalScreen
import com.alphawavesystems.probe_test_app_native.screens.VisualScreen

/// Route names mirror the Flutter app's Routes class (minus the leading '/').
object Routes {
const val HOME = "home"
const val LOGIN = "login"
const val BIOMETRIC = "biometric"
const val DASHBOARD = "dashboard"
const val SETTINGS = "settings"
const val ITEMS = "items"
const val GESTURES = "gestures"
const val API = "api"
const val DEVICE = "device"
const val VISUAL = "visual"
const val DYNAMIC = "dynamic"
const val SIGNAL = "signal"
}

@Composable
fun AppNavHost() {
val navController = rememberNavController()

// Activity-scoped view models — shared across destinations, surviving
// navigation, exactly like the Flutter app's app-level Providers.
val authViewModel: AuthViewModel = viewModel()
val itemsViewModel: ItemsViewModel = viewModel()
val settingsViewModel: SettingsViewModel = viewModel()

NavHost(navController = navController, startDestination = Routes.HOME) {
composable(Routes.HOME) { HomeScreen(navController) }
composable(Routes.LOGIN) { LoginScreen(navController, authViewModel) }
composable(Routes.BIOMETRIC) { BiometricLoginScreen(navController, authViewModel) }
composable(Routes.DASHBOARD) { DashboardScreen(navController, authViewModel) }
composable(Routes.SETTINGS) { SettingsScreen(navController, settingsViewModel) }
composable(Routes.ITEMS) { ItemListScreen(navController, itemsViewModel) }
composable(Routes.GESTURES) { GesturesScreen(navController) }
composable(Routes.API) { ApiScreen(navController) }
composable(Routes.DEVICE) { DeviceScreen(navController) }
composable(Routes.VISUAL) { VisualScreen(navController) }
composable(Routes.DYNAMIC) { DynamicScreen(navController) }
composable(Routes.SIGNAL) { SignalScreen(navController) }
}
}
Loading
Loading