-
Notifications
You must be signed in to change notification settings - Fork 1
feat: add native Android app with Jetpack Compose + Material 3 #58
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
7345bd4
afde629
70a93b6
9b2f864
200a60f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,64 @@ | ||||||
| name: Build Native Android APK | ||||||
|
|
||||||
| on: | ||||||
| push: | ||||||
| branches: | ||||||
| - 'claude/native-android-*' | ||||||
| - 'native-app' | ||||||
| paths: | ||||||
| - 'android-native/**' | ||||||
| - '.github/workflows/build-native-apk.yml' | ||||||
| pull_request: | ||||||
| branches: [master] | ||||||
| paths: | ||||||
| - 'android-native/**' | ||||||
| - '.github/workflows/build-native-apk.yml' | ||||||
| workflow_dispatch: | ||||||
|
|
||||||
| jobs: | ||||||
| build: | ||||||
| runs-on: ubuntu-latest | ||||||
| permissions: | ||||||
| contents: write | ||||||
|
|
||||||
| defaults: | ||||||
| run: | ||||||
| working-directory: android-native | ||||||
|
|
||||||
| steps: | ||||||
| - uses: actions/checkout@v4 | ||||||
|
|
||||||
| - name: Setup Java 17 | ||||||
| uses: actions/setup-java@v4 | ||||||
| with: | ||||||
| distribution: temurin | ||||||
| java-version: 17 | ||||||
|
|
||||||
| - name: Setup Android SDK | ||||||
| uses: android-actions/setup-android@v3 | ||||||
|
|
||||||
| - name: Setup Gradle | ||||||
| uses: gradle/actions/setup-gradle@v4 | ||||||
| with: | ||||||
| gradle-version: 8.11.1 | ||||||
| cache-read-only: ${{ github.ref != 'refs/heads/native-app' }} | ||||||
|
|
||||||
| - name: Generate Gradle wrapper | ||||||
| run: gradle wrapper --gradle-version 8.11.1 | ||||||
|
|
||||||
|
Comment on lines
+46
to
+48
|
||||||
| - name: Generate Gradle wrapper | |
| run: gradle wrapper --gradle-version 8.11.1 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| *.iml | ||
| .gradle | ||
| /local.properties | ||
| /.idea | ||
| .DS_Store | ||
| /build | ||
| /captures | ||
| .externalNativeBuild | ||
| .cxx | ||
| local.properties | ||
| /app/build | ||
| /app/release | ||
| *.apk | ||
| *.aab |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,87 @@ | ||||||
| plugins { | ||||||
| id("com.android.application") | ||||||
| id("org.jetbrains.kotlin.android") | ||||||
| id("org.jetbrains.kotlin.plugin.compose") | ||||||
| } | ||||||
|
|
||||||
| android { | ||||||
| namespace = "com.communauto.tools" | ||||||
| compileSdk = 35 | ||||||
|
|
||||||
| defaultConfig { | ||||||
| applicationId = "com.communauto.tools" | ||||||
| minSdk = 26 | ||||||
| targetSdk = 35 | ||||||
| versionCode = 1 | ||||||
| versionName = "1.0" | ||||||
|
|
||||||
| // Google Maps API key — gradle.properties, local.properties, or MAPS_API_KEY env var | ||||||
| manifestPlaceholders["MAPS_API_KEY"] = | ||||||
| project.findProperty("MAPS_API_KEY") as? String | ||||||
| ?: System.getenv("MAPS_API_KEY") | ||||||
| ?: "" | ||||||
| } | ||||||
|
|
||||||
| buildTypes { | ||||||
| release { | ||||||
| isMinifyEnabled = true | ||||||
| proguardFiles( | ||||||
| getDefaultProguardFile("proguard-android-optimize.txt"), | ||||||
| "proguard-rules.pro" | ||||||
| ) | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| buildFeatures { | ||||||
| compose = true | ||||||
| } | ||||||
|
|
||||||
| compileOptions { | ||||||
| sourceCompatibility = JavaVersion.VERSION_17 | ||||||
| targetCompatibility = JavaVersion.VERSION_17 | ||||||
| } | ||||||
|
|
||||||
| kotlinOptions { | ||||||
| jvmTarget = "17" | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| dependencies { | ||||||
| val composeBom = platform("androidx.compose:compose-bom:2024.12.01") | ||||||
| implementation(composeBom) | ||||||
|
|
||||||
| // Compose | ||||||
| implementation("androidx.compose.material3:material3") | ||||||
| implementation("androidx.compose.ui:ui") | ||||||
| implementation("androidx.compose.ui:ui-tooling-preview") | ||||||
| implementation("androidx.compose.material:material-icons-extended") | ||||||
| implementation("androidx.activity:activity-compose:1.9.3") | ||||||
| implementation("androidx.navigation:navigation-compose:2.8.5") | ||||||
| implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7") | ||||||
| implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7") | ||||||
|
|
||||||
| // Networking | ||||||
| implementation("com.squareup.retrofit2:retrofit:2.11.0") | ||||||
| implementation("com.squareup.retrofit2:converter-gson:2.11.0") | ||||||
| implementation("com.squareup.okhttp3:okhttp:4.12.0") | ||||||
| implementation("com.squareup.okhttp3:logging-interceptor:4.12.0") | ||||||
|
|
||||||
| // Google Maps Compose | ||||||
| implementation("com.google.maps.android:maps-compose:6.2.1") | ||||||
| implementation("com.google.android.gms:play-services-maps:19.0.0") | ||||||
| implementation("com.google.android.gms:play-services-location:21.3.0") | ||||||
|
|
||||||
| // WebView | ||||||
| implementation("androidx.webkit:webkit:1.12.1") | ||||||
|
|
||||||
| // Accompanist (permissions) | ||||||
| implementation("com.google.accompanist:accompanist-permissions:0.36.0") | ||||||
|
|
||||||
| // DataStore for preferences | ||||||
| implementation("androidx.datastore:datastore-preferences:1.1.1") | ||||||
|
|
||||||
| // Pull-to-refresh | ||||||
| implementation("androidx.compose.material3:material3-android") | ||||||
|
|
||||||
|
Comment on lines
+83
to
+85
|
||||||
| // Pull-to-refresh | |
| implementation("androidx.compose.material3:material3-android") |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| # Retrofit | ||
| -keepattributes Signature | ||
| -keepattributes *Annotation* | ||
| -keep class com.communauto.tools.data.model.** { *; } | ||
|
|
||
| # Gson | ||
| -keep class com.google.gson.** { *; } | ||
| -keepclassmembers class * { | ||
| @com.google.gson.annotations.SerializedName <fields>; | ||
| } | ||
|
|
||
| # OkHttp | ||
| -dontwarn okhttp3.** | ||
| -dontwarn okio.** |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| <?xml version="1.0" encoding="utf-8"?> | ||
| <manifest xmlns:android="http://schemas.android.com/apk/res/android"> | ||
|
|
||
| <uses-permission android:name="android.permission.INTERNET" /> | ||
| <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> | ||
| <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> | ||
|
|
||
| <application | ||
| android:name=".CommunautoApp" | ||
| android:allowBackup="true" | ||
| android:icon="@mipmap/ic_launcher" | ||
| android:label="@string/app_name" | ||
| android:supportsRtl="true" | ||
| android:theme="@style/Theme.CommunautoNative" | ||
| android:usesCleartextTraffic="false"> | ||
|
|
||
| <meta-data | ||
| android:name="com.google.android.geo.API_KEY" | ||
| android:value="${MAPS_API_KEY}" /> | ||
|
|
||
| <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,25 @@ | ||
| package com.communauto.tools | ||
|
|
||
| import android.app.Application | ||
| import android.webkit.CookieManager | ||
| import com.communauto.tools.data.auth.AuthManager | ||
| import com.communauto.tools.data.repository.VehicleRepository | ||
|
|
||
| class CommunautoApp : Application() { | ||
|
|
||
| lateinit var authManager: AuthManager | ||
| private set | ||
|
|
||
| lateinit var vehicleRepository: VehicleRepository | ||
| private set | ||
|
|
||
| override fun onCreate() { | ||
| super.onCreate() | ||
|
|
||
| // Enable cookies for WebView (shared with OkHttp via WebViewCookieJar) | ||
| CookieManager.getInstance().setAcceptCookie(true) | ||
|
|
||
| authManager = AuthManager(this) | ||
| vehicleRepository = VehicleRepository() | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| package com.communauto.tools | ||
|
|
||
| import android.os.Bundle | ||
| import androidx.activity.ComponentActivity | ||
| import androidx.activity.compose.setContent | ||
| import androidx.activity.enableEdgeToEdge | ||
| import androidx.lifecycle.viewmodel.compose.viewModel | ||
| import com.communauto.tools.ui.navigation.AppNavigation | ||
| import com.communauto.tools.ui.theme.CommunautoTheme | ||
| import com.communauto.tools.viewmodel.AuthViewModel | ||
|
|
||
| class MainActivity : ComponentActivity() { | ||
| override fun onCreate(savedInstanceState: Bundle?) { | ||
| super.onCreate(savedInstanceState) | ||
| enableEdgeToEdge() | ||
| setContent { | ||
| CommunautoTheme { | ||
| val authViewModel: AuthViewModel = viewModel() | ||
| AppNavigation(authViewModel = authViewModel) | ||
| } | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| package com.communauto.tools.data.api | ||
|
|
||
| import com.communauto.tools.data.model.BookingResponse | ||
| import com.communauto.tools.data.model.VehiclesResponse | ||
| import com.communauto.tools.data.model.WcfResponse | ||
| import retrofit2.http.GET | ||
| import retrofit2.http.Query | ||
|
|
||
| interface CommunautoApi { | ||
|
|
||
| @GET("WCF/LSI/LSIBookingServiceV3.svc/GetAvailableVehicles") | ||
| suspend fun getAvailableVehicles( | ||
| @Query("BranchID") branchId: Int = 1, | ||
| @Query("LanguageID") languageId: Int = 2, | ||
| @Query("CityID") cityId: Int? = null, | ||
| ): WcfResponse<VehiclesResponse> | ||
|
|
||
| @GET("WCF/LSI/LSIBookingServiceV3.svc/CreateBooking") | ||
| suspend fun createBooking( | ||
| @Query("CustomerID") customerId: Int, | ||
| @Query("CarID") carId: Int, | ||
| ): WcfResponse<BookingResponse> | ||
|
|
||
| @GET("WCF/LSI/LSIBookingServiceV3.svc/CancelBooking") | ||
| suspend fun cancelBooking( | ||
| @Query("CustomerID") customerId: Int, | ||
| @Query("BranchID") branchId: Int = 1, | ||
| ): WcfResponse<BookingResponse> | ||
|
|
||
| @GET("WCF/LSI/LSIBookingServiceV3.svc/GetCurrentBooking") | ||
| suspend fun getCurrentBooking( | ||
| @Query("CustomerID") customerId: Int, | ||
| ): WcfResponse<BookingResponse> | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,67 @@ | ||
| package com.communauto.tools.data.api | ||
|
|
||
| import android.webkit.CookieManager | ||
| import okhttp3.Cookie | ||
| import okhttp3.CookieJar | ||
| import okhttp3.HttpUrl | ||
| import okhttp3.OkHttpClient | ||
| import okhttp3.logging.HttpLoggingInterceptor | ||
| import retrofit2.Retrofit | ||
| import retrofit2.converter.gson.GsonConverterFactory | ||
| import java.util.concurrent.TimeUnit | ||
|
|
||
| /** | ||
| * CookieJar that delegates to Android's [CookieManager]. | ||
| * | ||
| * This is the key to the "cookie trick": the WebView login flow sets cookies | ||
| * in CookieManager. Because OkHttp shares the same CookieManager, all API | ||
| * calls automatically include the session cookies — no manual extraction needed. | ||
| */ | ||
| class WebViewCookieJar : CookieJar { | ||
|
|
||
| private val cookieManager: CookieManager = CookieManager.getInstance() | ||
|
|
||
| override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) { | ||
| val urlString = url.toString() | ||
| for (cookie in cookies) { | ||
| cookieManager.setCookie(urlString, cookie.toString()) | ||
| } | ||
| } | ||
|
|
||
| override fun loadForRequest(url: HttpUrl): List<Cookie> { | ||
| val urlString = url.toString() | ||
| val cookieString = cookieManager.getCookie(urlString) ?: return emptyList() | ||
| return cookieString.split(";").mapNotNull { raw -> | ||
| Cookie.parse(url, raw.trim()) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| object NetworkModule { | ||
|
|
||
| private const val BASE_URL = "https://www.reservauto.net/" | ||
|
|
||
| private val cookieJar = WebViewCookieJar() | ||
|
|
||
| private val okHttpClient: OkHttpClient by lazy { | ||
| OkHttpClient.Builder() | ||
| .cookieJar(cookieJar) | ||
| .connectTimeout(15, TimeUnit.SECONDS) | ||
| .readTimeout(30, TimeUnit.SECONDS) | ||
| .addInterceptor( | ||
| HttpLoggingInterceptor().apply { | ||
| level = HttpLoggingInterceptor.Level.BASIC | ||
| } | ||
| ) | ||
|
Comment on lines
+51
to
+55
|
||
| .build() | ||
| } | ||
|
|
||
| val api: CommunautoApi by lazy { | ||
| Retrofit.Builder() | ||
| .baseUrl(BASE_URL) | ||
| .client(okHttpClient) | ||
| .addConverterFactory(GsonConverterFactory.create()) | ||
| .build() | ||
| .create(CommunautoApi::class.java) | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This workflow grants
contents: writepermissions, but the job only builds and uploads an artifact. Consider reducing tocontents: read(or removing the explicit permissions block) to follow least-privilege.