feat: add native Android app with Jetpack Compose + Material 3#58
feat: add native Android app with Jetpack Compose + Material 3#58RickyNotaro wants to merge 5 commits intomasterfrom
Conversation
Replace the Capacitor WebView wrapper with a fully native Android app: - Kotlin + Jetpack Compose + Material 3 (Material You dynamic colors) - Bottom navigation: Véhicules list + Radar map - WebView login flow preserving the cookie trick (shared CookieManager between WebView and OkHttp for seamless session auth) - Vehicle list with search, sort (distance/number/battery/brand), fuel filter chips, pull-to-refresh, and detail bottom sheet - Radar screen with Google Maps, radius slider, vehicle markers, auto-refresh countdown, and auto-book feature - Retrofit + OkHttp networking with WebViewCookieJar bridge - MVVM architecture with ViewModels and Kotlin Flows - DataStore for auth persistence - Accompanist for runtime permissions (location) https://claude.ai/code/session_01QnYF5hKnktCPP2m5mSgwLp
- Triggers on push to native-app and native-android branches, and PRs - Uses setup-java (17), setup-android, and gradle/actions/setup-gradle - Generates wrapper on-the-fly (no wrapper jar committed) - Uploads debug APK as artifact - Supports MAPS_API_KEY from repo secret or env var https://claude.ai/code/session_01QnYF5hKnktCPP2m5mSgwLp
There was a problem hiding this comment.
Pull request overview
Adds a new fully native Android application (Kotlin + Jetpack Compose + Material 3) under android-native/, replacing the previous Capacitor WebView-wrapper approach while preserving the WebView→CookieManager→OkHttp cookie-bridging authentication flow.
Changes:
- Introduces a standalone Android Gradle project (
android-native/) with Compose UI, MVVM ViewModels, and Retrofit/OkHttp networking. - Implements core screens: login WebView flow, vehicle list (search/sort/filter), and radar map (Google Maps + auto-refresh/auto-book).
- Adds a GitHub Actions workflow to build and upload a debug APK for the native Android app.
Reviewed changes
Copilot reviewed 31 out of 31 changed files in this pull request and generated 11 comments.
Show a summary per file
| File | Description |
|---|---|
| android-native/settings.gradle.kts | Defines standalone Gradle project + :app module. |
| android-native/gradle/wrapper/gradle-wrapper.properties | Adds Gradle wrapper configuration for the native project. |
| android-native/gradle.properties | Adds Gradle properties including MAPS_API_KEY placeholder. |
| android-native/build.gradle.kts | Declares plugin versions (AGP/Kotlin/Compose). |
| android-native/app/src/main/res/values/themes.xml | Adds Material 3 base theme for the app. |
| android-native/app/src/main/res/values/strings.xml | Adds app name string. |
| android-native/app/src/main/java/com/communauto/tools/viewmodel/VehicleViewModel.kt | Vehicle list state, filtering, sorting, and fetching. |
| android-native/app/src/main/java/com/communauto/tools/viewmodel/RadarViewModel.kt | Radar state, radius filtering, refresh loop, and auto-book logic. |
| android-native/app/src/main/java/com/communauto/tools/viewmodel/AuthViewModel.kt | Auth state orchestration around WebView cookie acquisition. |
| android-native/app/src/main/java/com/communauto/tools/util/LocationUtils.kt | Adds default coordinates and Haversine distance helper. |
| android-native/app/src/main/java/com/communauto/tools/ui/theme/Type.kt | Defines custom typography. |
| android-native/app/src/main/java/com/communauto/tools/ui/theme/Theme.kt | Sets up MaterialTheme + dynamic color support. |
| android-native/app/src/main/java/com/communauto/tools/ui/theme/Color.kt | Defines brand color palette. |
| android-native/app/src/main/java/com/communauto/tools/ui/screen/VehicleListScreen.kt | Compose UI for vehicle list with search/sort/filter + detail sheet. |
| android-native/app/src/main/java/com/communauto/tools/ui/screen/RadarScreen.kt | Compose UI for radar map + bottom sheet controls. |
| android-native/app/src/main/java/com/communauto/tools/ui/screen/LoginScreen.kt | Compose login screen + embedded WebView login flow. |
| android-native/app/src/main/java/com/communauto/tools/ui/navigation/AppNavigation.kt | Compose navigation + location permission handling + initial fetching. |
| android-native/app/src/main/java/com/communauto/tools/ui/component/VehicleDetailSheet.kt | Bottom sheet for vehicle details + booking action. |
| android-native/app/src/main/java/com/communauto/tools/ui/component/VehicleCard.kt | Vehicle list item UI. |
| android-native/app/src/main/java/com/communauto/tools/data/repository/VehicleRepository.kt | Repository wrapper around API calls + distance enrichment. |
| android-native/app/src/main/java/com/communauto/tools/data/model/Vehicle.kt | Network models, response wrappers, and UI-friendly helpers. |
| android-native/app/src/main/java/com/communauto/tools/data/auth/AuthManager.kt | DataStore-backed auth flag + CookieManager cookie parsing/UID extraction. |
| android-native/app/src/main/java/com/communauto/tools/data/api/NetworkModule.kt | Retrofit/OkHttp setup + CookieManager-backed CookieJar. |
| android-native/app/src/main/java/com/communauto/tools/data/api/CommunautoApi.kt | Retrofit interface for WCF endpoints. |
| android-native/app/src/main/java/com/communauto/tools/MainActivity.kt | Compose entry activity and theme setup. |
| android-native/app/src/main/java/com/communauto/tools/CommunautoApp.kt | Application singleton wiring for auth + repository. |
| android-native/app/src/main/AndroidManifest.xml | Declares permissions, app class, activity, and Maps API key metadata. |
| android-native/app/proguard-rules.pro | ProGuard rules for Retrofit/Gson/OkHttp. |
| android-native/app/build.gradle.kts | Android app configuration + dependencies (Compose, Maps, Retrofit, DataStore). |
| android-native/.gitignore | Ignores Android/Gradle build artifacts for the native project. |
| .github/workflows/build-native-apk.yml | CI workflow to build and upload a debug APK artifact. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| refreshJob = viewModelScope.launch { | ||
| while (true) { | ||
| delay(1000) | ||
| _countdown.value-- | ||
| if (_countdown.value <= 0) { | ||
| _countdown.value = seconds | ||
| fetchVehicles() | ||
| // Try auto-book after refresh | ||
| if (_autoBookActive.value) tryAutoBook() | ||
| } |
There was a problem hiding this comment.
setRefreshInterval calls fetchVehicles() and then tryAutoBook() in the timer loop, but fetchVehicles() launches a new coroutine and returns immediately. This means auto-book can run against stale vehiclesInRadius data (before the refresh completes). Consider refactoring fetchVehicles into a suspend function (or returning/awaiting the Job) and only calling tryAutoBook() after the vehicles update succeeds.
| } | ||
| vehicleViewModel.fetchVehicles() | ||
| radarViewModel.fetchVehicles() |
There was a problem hiding this comment.
This LaunchedEffect always calls fetchVehicles() for both view models, even when location permission is not granted yet (so it fetches using the default lat/lng). When permission becomes granted, requestLocation calls vehicleViewModel.updateLocation(...), which itself triggers another fetchVehicles(), leading to duplicate network calls. Consider restructuring so fetching happens exactly once per location update (e.g., fetch only after location is set, or remove the fetch inside updateLocation).
| } | |
| vehicleViewModel.fetchVehicles() | |
| radarViewModel.fetchVehicles() | |
| } else { | |
| vehicleViewModel.fetchVehicles() | |
| radarViewModel.fetchVehicles() | |
| } |
| suspend fun logout() { | ||
| _isAuthenticated.value = false | ||
| context.dataStore.edit { it[KEY_AUTHENTICATED] = false } | ||
| CookieManager.getInstance().removeAllCookies(null) | ||
| } |
There was a problem hiding this comment.
logout() clears cookies via CookieManager.removeAllCookies(null), but this API is asynchronous and you are not waiting for completion or flushing. That can leave cookies temporarily present for subsequent requests. Consider using the callback form and calling CookieManager.getInstance().flush() (and/or awaiting completion) before treating the user as fully logged out.
| override fun shouldOverrideUrlLoading( | ||
| view: WebView?, | ||
| request: WebResourceRequest?, | ||
| ): Boolean = false | ||
| } |
There was a problem hiding this comment.
shouldOverrideUrlLoading always returns false, so the WebView will navigate to any URL the page redirects to. For a login WebView, it's safer to restrict navigation to expected https hosts (e.g., *.reservauto.net, identity provider) and block/hand off unexpected schemes/hosts to an external browser to reduce phishing / intent-scheme risks.
| expanded = false, | ||
| onExpandedChange = {}, | ||
| placeholder = { Text("Rechercher...") }, | ||
| leadingIcon = { Icon(Icons.Default.Search, "Rechercher") }, | ||
| ) | ||
| }, | ||
| expanded = false, | ||
| onExpandedChange = {}, |
There was a problem hiding this comment.
The SearchBar is hard-coded with expanded = false and onExpandedChange = {} (no-op), so it can never expand and the onSearch callback just sets local state. If you intend a non-expandable search field, consider using a TextField instead; otherwise wire expanded/onExpandedChange to state.
| expanded = false, | |
| onExpandedChange = {}, | |
| placeholder = { Text("Rechercher...") }, | |
| leadingIcon = { Icon(Icons.Default.Search, "Rechercher") }, | |
| ) | |
| }, | |
| expanded = false, | |
| onExpandedChange = {}, | |
| expanded = searchActive, | |
| onExpandedChange = { searchActive = it }, | |
| placeholder = { Text("Rechercher...") }, | |
| leadingIcon = { Icon(Icons.Default.Search, "Rechercher") }, | |
| ) | |
| }, | |
| expanded = searchActive, | |
| onExpandedChange = { searchActive = it }, |
| .addInterceptor( | ||
| HttpLoggingInterceptor().apply { | ||
| level = HttpLoggingInterceptor.Level.BASIC | ||
| } | ||
| ) |
There was a problem hiding this comment.
HttpLoggingInterceptor is enabled unconditionally. Even at BASIC, this can leak sensitive URLs/metadata in production logs and adds overhead. Consider enabling logging only for debug builds (e.g., via BuildConfig.DEBUG) and disabling it in release.
| val q = query.lowercase() | ||
| v.vehicle.carNo.toString().contains(q) || | ||
| v.vehicle.carBrand.lowercase().contains(q) || | ||
| v.vehicle.carModel.lowercase().contains(q) || | ||
| v.vehicle.carColor.lowercase().contains(q) |
There was a problem hiding this comment.
query.lowercase() uses the device default locale, which can cause unexpected matching behavior for certain locales (e.g., Turkish). For stable search behavior, consider normalizing with lowercase(Locale.ROOT) (and similarly for the vehicle fields) and trimming the query once.
| fun onWebViewPageFinished(url: String) { | ||
| // After the user navigates through login, check for cookies | ||
| if (authManager.hasCookies()) { | ||
| _cookieStatus.value = authManager.getCookieStatus() | ||
| viewModelScope.launch { | ||
| authManager.onLoginSuccess() | ||
| } | ||
| _showWebView.value = false | ||
| } | ||
| } |
There was a problem hiding this comment.
onWebViewPageFinished(url: String) doesn't use its url parameter. If the URL isn't needed, remove the parameter (and adjust the caller) to avoid an unused-parameter warning, or use it to validate that the user reached an expected post-login page before accepting cookies.
| val fuelFilter by viewModel.fuelFilter.collectAsState() | ||
| val selectedVehicle by viewModel.selectedVehicle.collectAsState() | ||
|
|
||
| var searchActive by remember { mutableStateOf(false) } |
There was a problem hiding this comment.
searchActive is set but never read anywhere in this composable, which will trigger an unused-state warning. Consider removing it or using it to drive the SearchBar expanded state.
| var searchActive by remember { mutableStateOf(false) } |
| build: | ||
| runs-on: ubuntu-latest | ||
| permissions: | ||
| contents: write |
There was a problem hiding this comment.
This workflow grants contents: write permissions, but the job only builds and uploads an artifact. Consider reducing to contents: read (or removing the explicit permissions block) to follow least-privilege.
| contents: write | |
| contents: read |
The Material3 XML theme parent (Theme.Material3.DayNight.NoActionBar) requires the MDC-Android library in XML resources. Since all Material 3 theming is handled by Jetpack Compose at runtime, use the platform android:Theme.Material.Light.NoActionBar instead. https://claude.ai/code/session_01QnYF5hKnktCPP2m5mSgwLp
Adds adaptive icon (car silhouette on Communauto green) and required mipmap/color resources to fix AAPT resource linking error. https://claude.ai/code/session_01QnYF5hKnktCPP2m5mSgwLp
- Replace java.lang.Math.toRadians() with pure Kotlin helper - Add Gradle cache to CI workflow for faster rebuilds - Add separate compileDebugKotlin step for fast failure feedback https://claude.ai/code/session_01QnYF5hKnktCPP2m5mSgwLp
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 35 out of 35 changed files in this pull request and generated 9 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Pull-to-refresh | ||
| implementation("androidx.compose.material3:material3-android") | ||
|
|
There was a problem hiding this comment.
androidx.compose.material3:material3 is already included above, and PullToRefreshBox lives in that artifact. The extra androidx.compose.material3:material3-android dependency looks redundant and can be removed unless there’s a specific reason to keep both.
| // Pull-to-refresh | |
| implementation("androidx.compose.material3:material3-android") |
| import com.communauto.tools.ui.theme.VehicleGray | ||
| import com.communauto.tools.viewmodel.FuelFilter | ||
| import com.communauto.tools.viewmodel.RadarViewModel | ||
| import com.google.android.gms.maps.CameraUpdateFactory | ||
| import com.google.android.gms.maps.model.BitmapDescriptorFactory | ||
| import com.google.android.gms.maps.model.CameraPosition | ||
| import com.google.android.gms.maps.model.CircleOptions |
There was a problem hiding this comment.
There are unused imports in this file (e.g., CircleOptions, VehicleGray) that should be removed to avoid warnings and keep the file tidy (and to satisfy stricter lint setups if enabled).
| import com.communauto.tools.ui.theme.VehicleGray | |
| import com.communauto.tools.viewmodel.FuelFilter | |
| import com.communauto.tools.viewmodel.RadarViewModel | |
| import com.google.android.gms.maps.CameraUpdateFactory | |
| import com.google.android.gms.maps.model.BitmapDescriptorFactory | |
| import com.google.android.gms.maps.model.CameraPosition | |
| import com.google.android.gms.maps.model.CircleOptions | |
| import com.communauto.tools.viewmodel.FuelFilter | |
| import com.communauto.tools.viewmodel.RadarViewModel | |
| import com.google.android.gms.maps.CameraUpdateFactory | |
| import com.google.android.gms.maps.model.BitmapDescriptorFactory | |
| import com.google.android.gms.maps.model.CameraPosition |
| if (isAuthenticated) { | ||
| onLoginSuccess() | ||
| return | ||
| } |
There was a problem hiding this comment.
onLoginSuccess() is invoked directly during composition when isAuthenticated becomes true. This is a side effect and can be triggered multiple times across recompositions (and can also cause “state update during composition” issues depending on what the callback does). Move this into a LaunchedEffect(isAuthenticated) (or similar one-shot effect) and keep the composable branch pure UI.
| while (true) { | ||
| delay(1000) | ||
| _countdown.value-- | ||
| if (_countdown.value <= 0) { | ||
| _countdown.value = seconds |
There was a problem hiding this comment.
The refresh job loops forever and triggers fetchVehicles(), but fetchVehicles() launches a new coroutine. If requests are slow, ticks can queue overlapping fetches (and overlapping auto-book attempts). Prefer a single refresh coroutine that calls a suspend refresh function directly and loops with while (isActive) / guards with _loading or a mutex.
| # Replace with your Google Maps API key | ||
| MAPS_API_KEY=YOUR_GOOGLE_MAPS_API_KEY_HERE |
There was a problem hiding this comment.
Committing an API-key placeholder in gradle.properties encourages putting real secrets into a tracked file (and it will also propagate into the manifest placeholder if someone forgets to override it). Prefer removing MAPS_API_KEY from the committed gradle.properties and sourcing it from local.properties (ignored) or environment variables / CI secrets only.
| var searchActive by remember { mutableStateOf(false) } | ||
| var sortMenuExpanded by remember { mutableStateOf(false) } | ||
|
|
There was a problem hiding this comment.
Unused state is introduced (searchActive) and several imports appear unused (e.g., CircularProgressIndicator). Please remove unused imports/state to avoid warnings and keep the new screen code minimal (and compatible with stricter lint configurations).
| viewModel = vehicleViewModel, | ||
| isAuthenticated = isAuthenticated, | ||
| onBookVehicle = { carId -> | ||
| // Booking handled via ViewModel |
There was a problem hiding this comment.
The “Réserver ce véhicule” action is wired to a no-op here: onBookVehicle is passed as an empty lambda, so the booking button in VehicleDetailSheet won’t actually create a booking. Please either implement booking (e.g., ViewModel/repository call using AuthViewModel.getUid()) or remove the booking UI until it’s supported.
| // Booking handled via ViewModel | |
| val userId = authViewModel.getUid() | |
| if (userId != null) { | |
| vehicleViewModel.bookVehicle(userId, carId) | |
| } |
| factory = { context -> | ||
| WebView(context).apply { | ||
| settings.javaScriptEnabled = true | ||
| settings.domStorageEnabled = true | ||
| CookieManager.getInstance().setAcceptThirdPartyCookies(this, true) |
There was a problem hiding this comment.
The WebView created in AndroidView is never explicitly destroyed. WebView instances are heavyweight and can leak resources if not cleaned up when this screen leaves composition. Add a DisposableEffect/onRelease hook to call stopLoading()/destroy() (and clear the reference) when the composable is disposed.
| - name: Generate Gradle wrapper | ||
| run: gradle wrapper --gradle-version 8.11.1 | ||
|
|
There was a problem hiding this comment.
The workflow generates the Gradle wrapper at runtime and then runs ./gradlew. This is fragile (it hides missing wrapper scripts/JARs in the repo and adds work each run). Prefer committing the wrapper scripts + gradle-wrapper.jar under android-native/ and removing the wrapper-generation step so builds are reproducible locally and in CI.
| - name: Generate Gradle wrapper | |
| run: gradle wrapper --gradle-version 8.11.1 |
Replace the Capacitor WebView wrapper with a fully native Android app:
between WebView and OkHttp for seamless session auth)
filter chips, pull-to-refresh, and detail bottom sheet
auto-refresh countdown, and auto-book feature
https://claude.ai/code/session_01QnYF5hKnktCPP2m5mSgwLp