diff --git a/.idea/markdown.xml b/.idea/markdown.xml new file mode 100644 index 0000000..c61ea33 --- /dev/null +++ b/.idea/markdown.xml @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5480392..eca23e2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -2,18 +2,17 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.serialization) } android { namespace = "com.example.livingai_lg" - compileSdk { - version = release(36) - } + compileSdk = 34 defaultConfig { applicationId = "com.example.livingai_lg" minSdk = 24 - targetSdk = 36 + targetSdk = 34 versionCode = 1 versionName = "1.0" @@ -30,14 +29,15 @@ android { } } compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 } kotlinOptions { - jvmTarget = "11" + jvmTarget = "1.8" } buildFeatures { compose = true + buildConfig = true } } @@ -50,7 +50,20 @@ dependencies { implementation(libs.androidx.compose.ui.graphics) implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.material3) - implementation("androidx.navigation:navigation-compose:2.7.7") + implementation(libs.androidx.navigation.compose) + + // Ktor + implementation(libs.ktor.client.core) + implementation(libs.ktor.client.cio) + implementation(libs.ktor.client.content.negotiation) + implementation(libs.ktor.serialization.kotlinx.json) + + // Kotlinx Serialization + implementation(libs.kotlinx.serialization.json) + + // AndroidX Security + implementation(libs.androidx.security.crypto) + testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) diff --git a/app/how_to_use_Auth.md b/app/how_to_use_Auth.md new file mode 100644 index 0000000..439b8f4 --- /dev/null +++ b/app/how_to_use_Auth.md @@ -0,0 +1,1130 @@ +# Farm Auth Service - API Integration Guide + +Complete integration guide for Farm Auth Service with Kotlin Multiplatform Mobile (KMM) or Kotlin Native applications. + +## Base URL + +``` +http://localhost:3000 (development) +https://your-domain.com (production) +``` + +## Table of Contents + +- [Authentication Flow](#authentication-flow) +- [API Endpoints](#api-endpoints) +- [Kotlin Multiplatform Implementation](#kotlin-multiplatform-implementation) +- [Error Handling](#error-handling) +- [Security Best Practices](#security-best-practices) +- [Token Management](#token-management) + +--- + +## Authentication Flow + +1. **Request OTP** → User enters phone number +2. **Verify OTP** → User enters code, receives tokens + user info +3. **Use Access Token** → Include in `Authorization` header for protected endpoints +4. **Refresh Token** → When access token expires, get new tokens (auto-rotation) +5. **Logout** → Revoke refresh token on current device +6. **Device Management** → View/manage active devices + +--- + +## API Endpoints + +### 1. Request OTP + +**Endpoint:** `POST /auth/request-otp` + +**Request:** +```json +{ + "phone_number": "+919876543210" +} +``` + +**Response (200):** +```json +{ + "ok": true +} +``` + +**Error (400):** +```json +{ + "error": "phone_number is required" +} +``` + +**Error (500):** +```json +{ + "error": "Failed to send OTP" +} +``` + +**Phone Number Normalization:** +- `9876543210` → Automatically becomes `+919876543210` (10-digit assumed as Indian) +- `+919876543210` → Kept as is +- Phone numbers should ideally be in E.164 format with `+` prefix + +--- + +### 2. Verify OTP + +**Endpoint:** `POST /auth/verify-otp` + +**Request:** +```json +{ + "phone_number": "+919876543210", + "code": "123456", + "device_id": "android-installation-id-123", + "device_info": { + "platform": "android", + "model": "Samsung SM-M326B", + "os_version": "Android 14", + "app_version": "1.0.0", + "language_code": "en-IN", + "timezone": "Asia/Kolkata" + } +} +``` + +**Response (200):** +```json +{ + "user": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "phone_number": "+919876543210", + "name": null, + "role": "user", + "user_type": null + }, + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "needs_profile": true, + "is_new_device": true, + "is_new_account": false, + "active_devices_count": 2 +} +``` + +**Error (400):** +```json +{ + "error": "phone_number and code are required" +} +``` +or +```json +{ + "error": "Invalid or expired OTP" +} +``` + +**Notes:** +- `device_id` is required and will be sanitized (must be 4-128 alphanumeric characters, otherwise hashed) +- `device_info` is optional but recommended for better device tracking +- `needs_profile` is `true` if `name` or `user_type` is null +- `is_new_device` indicates if this device was seen for the first time +- `is_new_account` indicates if the user account was just created +- `active_devices_count` shows how many active devices the user has + +--- + +### 3. Refresh Token + +**Endpoint:** `POST /auth/refresh` + +**Request:** +```json +{ + "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." +} +``` + +**Response (200):** +```json +{ + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." +} +``` + +**Error (400):** +```json +{ + "error": "refresh_token is required" +} +``` + +**Error (401):** +```json +{ + "error": "Invalid refresh token" +} +``` + +**Important:** +- Refresh tokens **rotate on each use** - always save the new `refresh_token` +- Old refresh token is automatically revoked +- If refresh token is compromised and reused, all tokens for that device are revoked + +--- + +### 4. Logout + +**Endpoint:** `POST /auth/logout` + +**Request:** +```json +{ + "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." +} +``` + +**Response (200):** +```json +{ + "ok": true +} +``` + +**Note:** Returns `ok: true` even if token is already invalid (idempotent) + +--- + +### 5. Get Current User Profile + +**Endpoint:** `GET /users/me` + +**Headers:** +``` +Authorization: Bearer +``` + +**Response (200):** +```json +{ + "id": "550e8400-e29b-41d4-a716-446655440000", + "phone_number": "+919876543210", + "name": "John Doe", + "role": "user", + "user_type": "seller", + "created_at": "2024-01-15T10:30:00Z", + "last_login_at": "2024-01-20T14:22:00Z", + "active_devices_count": 2 +} +``` + +**Error (401):** +```json +{ + "error": "Missing Authorization header" +} +``` +or +```json +{ + "error": "Invalid or expired token" +} +``` + +--- + +### 6. Update User Profile + +**Endpoint:** `PUT /users/me` + +**Headers:** +``` +Authorization: Bearer +``` + +**Request:** +```json +{ + "name": "John Doe", + "user_type": "seller" +} +``` + +**Response (200):** +```json +{ + "id": "550e8400-e29b-41d4-a716-446655440000", + "phone_number": "+919876543210", + "name": "John Doe", + "role": "user", + "user_type": "seller" +} +``` + +**Valid `user_type` values:** +- `seller` +- `buyer` +- `service_provider` + +**Error (400):** +```json +{ + "error": "name and user_type are required" +} +``` + +--- + +### 7. List Active Devices + +**Endpoint:** `GET /users/me/devices` + +**Headers:** +``` +Authorization: Bearer +``` + +**Response (200):** +```json +{ + "devices": [ + { + "device_identifier": "android-installation-id-123", + "device_platform": "android", + "device_model": "Samsung SM-M326B", + "os_version": "Android 14", + "app_version": "1.0.0", + "language_code": "en-IN", + "timezone": "Asia/Kolkata", + "first_seen_at": "2024-01-15T10:30:00Z", + "last_seen_at": "2024-01-20T14:22:00Z", + "is_active": true + } + ] +} +``` + +--- + +### 8. Logout Specific Device + +**Endpoint:** `DELETE /users/me/devices/:device_id` + +**Headers:** +``` +Authorization: Bearer +``` + +**Response (200):** +```json +{ + "ok": true, + "message": "Device logged out successfully" +} +``` + +**Note:** This will: +- Mark the device as inactive in `user_devices` +- Revoke all refresh tokens for that device +- Log the action in `auth_audit` + +--- + +### 9. Logout All Other Devices + +**Endpoint:** `POST /users/me/logout-all-other-devices` + +**Headers:** +``` +Authorization: Bearer +X-Device-Id: +``` + +**Alternative:** Send `current_device_id` in request body: +```json +{ + "current_device_id": "android-installation-id-123" +} +``` + +**Response (200):** +```json +{ + "ok": true, + "message": "Logged out 2 device(s)", + "revoked_devices_count": 2 +} +``` + +**Error (400):** +```json +{ + "error": "current_device_id is required in header or body" +} +``` + +--- + +### 10. Health Check + +**Endpoint:** `GET /health` + +**Response (200):** +```json +{ + "ok": true +} +``` + +Use this to verify the service is running before attempting authentication. + +--- + +## Kotlin Multiplatform Implementation + +### 1. Data Models (Common Module) + +```kotlin +// commonMain/kotlin/models/Requests.kt +package com.farm.auth.models + +import kotlinx.serialization.Serializable + +@Serializable +data class RequestOtpRequest(val phone_number: String) + +@Serializable +data class RequestOtpResponse(val ok: Boolean) + +@Serializable +data class DeviceInfo( + val platform: String, + val model: String? = null, + @SerialName("os_version") val osVersion: String? = null, + @SerialName("app_version") val appVersion: String? = null, + @SerialName("language_code") val languageCode: String? = null, + val timezone: String? = null +) + +@Serializable +data class VerifyOtpRequest( + @SerialName("phone_number") val phoneNumber: String, + val code: String, + @SerialName("device_id") val deviceId: String, + @SerialName("device_info") val deviceInfo: DeviceInfo? = null +) + +@Serializable +data class User( + val id: String, + @SerialName("phone_number") val phoneNumber: String, + val name: String?, + val role: String, + @SerialName("user_type") val userType: String?, + @SerialName("created_at") val createdAt: String? = null, + @SerialName("last_login_at") val lastLoginAt: String? = null, + @SerialName("active_devices_count") val activeDevicesCount: Int? = null +) + +@Serializable +data class VerifyOtpResponse( + val user: User, + @SerialName("access_token") val accessToken: String, + @SerialName("refresh_token") val refreshToken: String, + @SerialName("needs_profile") val needsProfile: Boolean, + @SerialName("is_new_device") val isNewDevice: Boolean, + @SerialName("is_new_account") val isNewAccount: Boolean, + @SerialName("active_devices_count") val activeDevicesCount: Int +) + +@Serializable +data class RefreshRequest(@SerialName("refresh_token") val refreshToken: String) + +@Serializable +data class RefreshResponse( + @SerialName("access_token") val accessToken: String, + @SerialName("refresh_token") val refreshToken: String +) + +@Serializable +data class LogoutRequest(@SerialName("refresh_token") val refreshToken: String) + +@Serializable +data class LogoutResponse(val ok: Boolean) + +@Serializable +data class UpdateProfileRequest( + val name: String, + @SerialName("user_type") val userType: String +) + +@Serializable +data class Device( + @SerialName("device_identifier") val deviceIdentifier: String, + @SerialName("device_platform") val devicePlatform: String, + @SerialName("device_model") val deviceModel: String?, + @SerialName("os_version") val osVersion: String?, + @SerialName("app_version") val appVersion: String?, + @SerialName("language_code") val languageCode: String?, + val timezone: String?, + @SerialName("first_seen_at") val firstSeenAt: String, + @SerialName("last_seen_at") val lastSeenAt: String, + @SerialName("is_active") val isActive: Boolean +) + +@Serializable +data class DevicesResponse(val devices: List) + +@Serializable +data class LogoutAllOtherDevicesRequest( + @SerialName("current_device_id") val currentDeviceId: String +) + +@Serializable +data class LogoutAllOtherDevicesResponse( + val ok: Boolean, + val message: String, + @SerialName("revoked_devices_count") val revokedDevicesCount: Int +) + +@Serializable +data class ErrorResponse(val error: String) + +@Serializable +data class HealthResponse(val ok: Boolean) +``` + +### 2. API Client (Common Module) + +```kotlin +// commonMain/kotlin/network/AuthApiClient.kt +package com.farm.auth.network + +import com.farm.auth.models.* +import io.ktor.client.* +import io.ktor.client.call.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.client.request.* +import io.ktor.http.* +import io.ktor.serialization.kotlinx.json.* +import kotlinx.serialization.json.Json + +class AuthApiClient( + private val baseUrl: String, + private val httpClient: HttpClient +) { + private val json = Json { + ignoreUnknownKeys = true + isLenient = true + encodeDefaults = false + } + + init { + httpClient.config { + install(ContentNegotiation) { + json(json) + } + } + } + + suspend fun requestOtp(phoneNumber: String): Result { + return try { + val response = httpClient.post("$baseUrl/auth/request-otp") { + contentType(ContentType.Application.Json) + setBody(RequestOtpRequest(phoneNumber)) + } + Result.success(response.body()) + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun verifyOtp( + phoneNumber: String, + code: String, + deviceId: String, + deviceInfo: DeviceInfo? = null + ): Result { + return try { + val request = VerifyOtpRequest(phoneNumber, code, deviceId, deviceInfo) + val response = httpClient.post("$baseUrl/auth/verify-otp") { + contentType(ContentType.Application.Json) + setBody(request) + } + Result.success(response.body()) + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun refreshToken(refreshToken: String): Result { + return try { + val request = RefreshRequest(refreshToken) + val response = httpClient.post("$baseUrl/auth/refresh") { + contentType(ContentType.Application.Json) + setBody(request) + } + Result.success(response.body()) + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun logout(refreshToken: String): Result { + return try { + val request = LogoutRequest(refreshToken) + val response = httpClient.post("$baseUrl/auth/logout") { + contentType(ContentType.Application.Json) + setBody(request) + } + Result.success(response.body()) + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun getCurrentUser(accessToken: String): Result { + return try { + val response = httpClient.get("$baseUrl/users/me") { + contentType(ContentType.Application.Json) + header("Authorization", "Bearer $accessToken") + } + Result.success(response.body()) + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun updateProfile( + name: String, + userType: String, + accessToken: String + ): Result { + return try { + val request = UpdateProfileRequest(name, userType) + val response = httpClient.put("$baseUrl/users/me") { + contentType(ContentType.Application.Json) + header("Authorization", "Bearer $accessToken") + setBody(request) + } + Result.success(response.body()) + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun getDevices(accessToken: String): Result { + return try { + val response = httpClient.get("$baseUrl/users/me/devices") { + contentType(ContentType.Application.Json) + header("Authorization", "Bearer $accessToken") + } + Result.success(response.body()) + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun logoutDevice( + deviceId: String, + accessToken: String + ): Result { + return try { + val response = httpClient.delete("$baseUrl/users/me/devices/$deviceId") { + contentType(ContentType.Application.Json) + header("Authorization", "Bearer $accessToken") + } + Result.success(response.body()) + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun logoutAllOtherDevices( + currentDeviceId: String, + accessToken: String + ): Result { + return try { + val request = LogoutAllOtherDevicesRequest(currentDeviceId) + val response = httpClient.post("$baseUrl/users/me/logout-all-other-devices") { + contentType(ContentType.Application.Json) + header("Authorization", "Bearer $accessToken") + header("X-Device-Id", currentDeviceId) + setBody(request) + } + Result.success(response.body()) + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun healthCheck(): Result { + return try { + val response = httpClient.get("$baseUrl/health") + Result.success(response.body()) + } catch (e: Exception) { + Result.failure(e) + } + } +} +``` + +### 3. Token Storage Interface (Common Module) + +```kotlin +// commonMain/kotlin/storage/TokenStorage.kt +package com.farm.auth.storage + +interface TokenStorage { + fun saveTokens(accessToken: String, refreshToken: String) + fun getAccessToken(): String? + fun getRefreshToken(): String? + fun clearTokens() +} +``` + +### 4. Android Token Storage Implementation + +```kotlin +// androidMain/kotlin/storage/AndroidTokenStorage.kt +package com.farm.auth.storage + +import android.content.Context +import android.content.SharedPreferences +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey + +class AndroidTokenStorage(private val context: Context) : TokenStorage { + private val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + + private val prefs: SharedPreferences = EncryptedSharedPreferences.create( + context, + "auth_tokens", + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + + override fun saveTokens(accessToken: String, refreshToken: String) { + prefs.edit().apply { + putString("access_token", accessToken) + putString("refresh_token", refreshToken) + apply() + } + } + + override fun getAccessToken(): String? = prefs.getString("access_token", null) + + override fun getRefreshToken(): String? = prefs.getString("refresh_token", null) + + override fun clearTokens() { + prefs.edit().clear().apply() + } +} +``` + +### 5. iOS Token Storage Implementation (using Keychain) + +```kotlin +// iosMain/kotlin/storage/IosTokenStorage.kt +package com.farm.auth.storage + +import platform.Security.* +import platform.Foundation.* + +class IosTokenStorage : TokenStorage { + private val accessTokenKey = "access_token" + private val refreshTokenKey = "refresh_token" + private val service = "com.farm.auth" + + override fun saveTokens(accessToken: String, refreshToken: String) { + saveToKeychain(accessTokenKey, accessToken) + saveToKeychain(refreshTokenKey, refreshToken) + } + + override fun getAccessToken(): String? = getFromKeychain(accessTokenKey) + + override fun getRefreshToken(): String? = getFromKeychain(refreshTokenKey) + + override fun clearTokens() { + deleteFromKeychain(accessTokenKey) + deleteFromKeychain(refreshTokenKey) + } + + private fun saveToKeychain(key: String, value: String): Boolean { + val data = (value as NSString).dataUsingEncoding(NSUTF8StringEncoding) ?: return false + val query = mapOf( + kSecClass to kSecClassGenericPassword, + kSecAttrService to service, + kSecAttrAccount to key, + kSecValueData to data + ) + SecItemDelete(query.toCFDictionary()) + return SecItemAdd(query.toCFDictionary(), null) == errSecSuccess + } + + private fun getFromKeychain(key: String): String? { + val query = mapOf( + kSecClass to kSecClassGenericPassword, + kSecAttrService to service, + kSecAttrAccount to key, + kSecReturnData to kCFBooleanTrue + ) + val result = alloc() + if (SecItemCopyMatching(query.toCFDictionary(), result.ptr) == errSecSuccess) { + val data = result.value as? NSData ?: return null + return NSString.create(data, NSUTF8StringEncoding) as String + } + return null + } + + private fun deleteFromKeychain(key: String) { + val query = mapOf( + kSecClass to kSecClassGenericPassword, + kSecAttrService to service, + kSecAttrAccount to key + ) + SecItemDelete(query.toCFDictionary()) + } +} +``` + +### 6. Device Info Provider Interface + +```kotlin +// commonMain/kotlin/device/DeviceInfoProvider.kt +package com.farm.auth.device + +import com.farm.auth.models.DeviceInfo + +interface DeviceInfoProvider { + fun getDeviceId(): String + fun getDeviceInfo(): DeviceInfo +} +``` + +### 7. Authentication Manager + +```kotlin +// commonMain/kotlin/auth/AuthManager.kt +package com.farm.auth.auth + +import com.farm.auth.models.* +import com.farm.auth.network.AuthApiClient +import com.farm.auth.storage.TokenStorage +import com.farm.auth.device.DeviceInfoProvider +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +class AuthManager( + private val apiClient: AuthApiClient, + private val tokenStorage: TokenStorage, + private val deviceInfoProvider: DeviceInfoProvider +) { + private val _currentUser = MutableStateFlow(null) + val currentUser: StateFlow = _currentUser.asStateFlow() + + private val _isAuthenticated = MutableStateFlow(false) + val isAuthenticated: StateFlow = _isAuthenticated.asStateFlow() + + suspend fun requestOtp(phoneNumber: String): Result { + return apiClient.requestOtp(phoneNumber) + } + + suspend fun verifyOtp(phoneNumber: String, code: String): Result { + val deviceId = deviceInfoProvider.getDeviceId() + val deviceInfo = deviceInfoProvider.getDeviceInfo() + + return apiClient.verifyOtp(phoneNumber, code, deviceId, deviceInfo) + .onSuccess { response -> + tokenStorage.saveTokens(response.accessToken, response.refreshToken) + _currentUser.value = response.user + _isAuthenticated.value = true + } + } + + suspend fun refreshTokens(): Result> { + val refreshToken = tokenStorage.getRefreshToken() + ?: return Result.failure(Exception("No refresh token")) + + return apiClient.refreshToken(refreshToken) + .onSuccess { response -> + tokenStorage.saveTokens(response.accessToken, response.refreshToken) + } + .onFailure { + // If refresh fails, clear tokens and logout + if (it.message?.contains("Invalid refresh token") == true) { + logout() + } + } + .map { it.accessToken to it.refreshToken } + } + + suspend fun logout() { + tokenStorage.getRefreshToken()?.let { refreshToken -> + apiClient.logout(refreshToken) + } + tokenStorage.clearTokens() + _currentUser.value = null + _isAuthenticated.value = false + } + + suspend fun getCurrentUser(): Result { + val accessToken = tokenStorage.getAccessToken() + ?: return Result.failure(Exception("Not authenticated")) + + return apiClient.getCurrentUser(accessToken) + .onSuccess { user -> + _currentUser.value = user + } + .recoverCatching { error -> + if (error.message?.contains("401") == true || + error.message?.contains("Unauthorized") == true) { + // Try to refresh token and retry + refreshTokens().getOrNull()?.let { (newAccessToken, _) -> + apiClient.getCurrentUser(newAccessToken) + .onSuccess { user -> + _currentUser.value = user + } + } ?: Result.failure(error) + } else { + Result.failure(error) + } + } + } + + suspend fun updateProfile(name: String, userType: String): Result { + val accessToken = tokenStorage.getAccessToken() + ?: return Result.failure(Exception("Not authenticated")) + + return apiClient.updateProfile(name, userType, accessToken) + .onSuccess { user -> + _currentUser.value = user + } + } + + suspend fun getDevices(): Result { + val accessToken = tokenStorage.getAccessToken() + ?: return Result.failure(Exception("Not authenticated")) + + return callWithAuth { token -> + apiClient.getDevices(token) + } + } + + suspend fun logoutDevice(deviceId: String): Result { + return callWithAuth { token -> + apiClient.logoutDevice(deviceId, token) + } + } + + suspend fun logoutAllOtherDevices(): Result { + val currentDeviceId = deviceInfoProvider.getDeviceId() + return callWithAuth { token -> + apiClient.logoutAllOtherDevices(currentDeviceId, token) + } + } + + fun getAccessToken(): String? = tokenStorage.getAccessToken() + + private suspend fun callWithAuth( + block: suspend (String) -> Result + ): Result { + val token = tokenStorage.getAccessToken() + ?: return Result.failure(Exception("Not authenticated")) + + return block(token).recoverCatching { error -> + if (error.message?.contains("401") == true || + error.message?.contains("Unauthorized") == true) { + // Token expired, refresh and retry + refreshTokens().getOrNull()?.let { (newAccessToken, _) -> + block(newAccessToken) + } ?: Result.failure(Exception("Failed to refresh token")) + } else { + Result.failure(error) + } + } + } +} +``` + +--- + +## Error Handling + +### Common Error Codes + +| Status | Error | Description | +|--------|-------|-------------| +| 400 | `phone_number is required` | Missing phone number in request | +| 400 | `Invalid or expired OTP` | Wrong code, OTP expired (10 min), or max attempts exceeded (5) | +| 400 | `name and user_type are required` | Missing required fields in profile update | +| 400 | `current_device_id is required` | Missing device ID for logout all devices | +| 401 | `Invalid refresh token` | Token expired, revoked, or compromised | +| 401 | `Missing Authorization header` | Access token not provided | +| 401 | `Invalid or expired token` | Access token invalid or expired | +| 404 | `User not found` | User account doesn't exist | +| 403 | `Origin not allowed` | CORS restriction (production only) | +| 500 | `Internal server error` | Server-side error | +| 500 | `Failed to send OTP` | Twilio SMS sending failed | + +### Error Response Format + +```json +{ + "error": "Error message here" +} +``` + +--- + +## Security Best Practices + +1. **Store tokens securely** + - **Android**: Use `EncryptedSharedPreferences` (Android Security Library) + - **iOS**: Use Keychain Services + - Never store tokens in plain SharedPreferences/UserDefaults + +2. **Handle token expiration** + - Automatically refresh when access token expires (401 response) + - Implement retry logic with token refresh + +3. **Rotate refresh tokens** + - Always save the new `refresh_token` after refresh + - Old refresh tokens are automatically revoked + +4. **Validate device_id** + - Use consistent device identifier (Android ID, Installation ID, or Vendor ID) + - Device ID should be 4-128 alphanumeric characters + - Server will hash invalid device IDs + +5. **Handle reuse detection** + - If refresh returns 401 with "Invalid refresh token", force re-login + - This indicates potential token compromise + +6. **Secure network communication** + - Always use HTTPS in production + - Implement certificate pinning if needed + +7. **Rate limiting awareness** + - Don't spam OTP requests + - Implement client-side rate limiting if possible + +--- + +## Token Management + +### Token Expiration + +- **Access Token:** 15 minutes (default, configurable via `JWT_ACCESS_TTL` env var) +- **Refresh Token:** 7 days (default, configurable via `JWT_REFRESH_TTL` env var) +- **Refresh Token Idle Timeout:** 3 days (default, configurable via `REFRESH_MAX_IDLE_MINUTES` = 4320) +- **OTP:** 10 minutes (fixed) + +### Auto-refresh Pattern + +```kotlin +suspend fun callWithAuth( + block: suspend (String) -> Result +): Result { + val token = tokenStorage.getAccessToken() + ?: return Result.failure(Exception("Not authenticated")) + + return block(token).recoverCatching { error -> + if (error.message?.contains("401") == true || + error.message?.contains("Unauthorized") == true) { + // Token expired, refresh and retry + refreshTokens().getOrNull()?.let { (newAccessToken, _) -> + block(newAccessToken) + } ?: Result.failure(Exception("Failed to refresh token")) + } else { + Result.failure(error) + } + } +} +``` + +--- + +## Notes + +- Phone numbers are auto-normalized: 10-digit numbers → `+91` prefix +- Device ID should be 4-128 alphanumeric characters (server sanitizes if invalid) +- Refresh tokens rotate on each use - always update stored token +- If `needs_profile: true`, prompt user to complete profile before accessing app +- `is_new_device` and `is_new_account` flags help with onboarding flows +- Device management endpoints require authentication +- All timestamps are in ISO 8601 format with timezone (UTC) + +--- + +## Example Usage (Android Activity) + +```kotlin +class LoginActivity : AppCompatActivity() { + private lateinit var authManager: AuthManager + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val httpClient = HttpClient(Android) { + install(ContentNegotiation) { + json() + } + } + + val apiClient = AuthApiClient("http://your-api-url", httpClient) + val tokenStorage = AndroidTokenStorage(this) + val deviceInfoProvider = AndroidDeviceInfoProvider(this) + + authManager = AuthManager(apiClient, tokenStorage, deviceInfoProvider) + + // Observe authentication state + lifecycleScope.launch { + authManager.isAuthenticated.collect { isAuth -> + if (isAuth) { + // Navigate to main screen + } + } + } + } + + private fun requestOtp() { + lifecycleScope.launch { + val phoneNumber = phoneInput.text.toString() + authManager.requestOtp(phoneNumber) + .onSuccess { + showToast("OTP sent!") + } + .onFailure { + showError(it.message ?: "Failed to send OTP") + } + } + } + + private fun verifyOtp() { + lifecycleScope.launch { + val phoneNumber = phoneInput.text.toString() + val code = otpInput.text.toString() + + authManager.verifyOtp(phoneNumber, code) + .onSuccess { response -> + if (response.needsProfile) { + startActivity(Intent(this@LoginActivity, ProfileSetupActivity::class.java)) + } else { + startActivity(Intent(this@LoginActivity, MainActivity::class.java)) + } + finish() + } + .onFailure { + showError("Invalid OTP") + } + } + } +} +``` diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 64f3c6c..3a291db 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,8 @@ + + + android:theme="@style/Theme.LivingAi_Lg" + android:usesCleartextTraffic="true" + tools:targetApi="31"> diff --git a/app/src/main/java/com/example/livingai_lg/MainActivity.kt b/app/src/main/java/com/example/livingai_lg/MainActivity.kt index f0520ef..4f324d8 100644 --- a/app/src/main/java/com/example/livingai_lg/MainActivity.kt +++ b/app/src/main/java/com/example/livingai_lg/MainActivity.kt @@ -1,17 +1,15 @@ - package com.example.livingai_lg import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController -import com.example.livingai_lg.ui.login.LoginScreen -import com.example.livingai_lg.ui.login.SignUpScreen -import com.example.livingai_lg.ui.login.OtpScreen -import com.example.livingai_lg.ui.login.CreateProfileScreen +import androidx.navigation.navArgument +import com.example.livingai_lg.ui.login.* import com.example.livingai_lg.ui.theme.LivingAi_LgTheme class MainActivity : ComponentActivity() { @@ -28,11 +26,32 @@ class MainActivity : ComponentActivity() { composable("signup") { SignUpScreen(navController = navController) } - composable("otp") { - OtpScreen(navController = navController) + composable("signin") { + SignInScreen(navController = navController) } - composable("create_profile") { - CreateProfileScreen(navController = navController) + composable( + "otp/{phoneNumber}/{name}", + arguments = listOf( + navArgument("phoneNumber") { type = NavType.StringType }, + navArgument("name") { type = NavType.StringType }) + ) { backStackEntry -> + OtpScreen( + navController = navController, + phoneNumber = backStackEntry.arguments?.getString("phoneNumber") ?: "", + name = backStackEntry.arguments?.getString("name") ?: "" + ) + } + composable( + "create_profile/{name}", + arguments = listOf(navArgument("name") { type = NavType.StringType }) + ) { backStackEntry -> + CreateProfileScreen( + navController = navController, + name = backStackEntry.arguments?.getString("name") ?: "" + ) + } + composable("success") { + SuccessScreen() } } } diff --git a/app/src/main/java/com/example/livingai_lg/api/AuthApiClient.kt b/app/src/main/java/com/example/livingai_lg/api/AuthApiClient.kt new file mode 100644 index 0000000..c97a0f4 --- /dev/null +++ b/app/src/main/java/com/example/livingai_lg/api/AuthApiClient.kt @@ -0,0 +1,107 @@ +package com.example.livingai_lg.api + +import android.os.Build +import com.example.livingai_lg.BuildConfig +import io.ktor.client.* +import io.ktor.client.call.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.client.request.* +import io.ktor.http.* +import io.ktor.serialization.kotlinx.json.* +import kotlinx.serialization.json.Json +import java.util.Locale +import java.util.TimeZone + +class AuthApiClient(private val baseUrl: String) { + private val client = HttpClient { + install(ContentNegotiation) { + json(Json { + ignoreUnknownKeys = true + }) + } + } + + suspend fun requestOtp(phoneNumber: String): Result { + return try { + val response = client.post("$baseUrl/auth/request-otp") { + contentType(ContentType.Application.Json) + setBody(RequestOtpRequest(phoneNumber)) + } + Result.success(response.body()) + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun verifyOtp( + phoneNumber: String, + code: String, + deviceId: String + ): Result { + return try { + val request = VerifyOtpRequest(phoneNumber, code, deviceId, getDeviceInfo()) + val response = client.post("$baseUrl/auth/verify-otp") { + contentType(ContentType.Application.Json) + setBody(request) + } + Result.success(response.body()) + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun refreshToken(refreshToken: String): Result { + return try { + val request = RefreshRequest(refreshToken) + val response = client.post("$baseUrl/auth/refresh") { + contentType(ContentType.Application.Json) + setBody(request) + } + Result.success(response.body()) + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun updateProfile( + name: String, + userType: String, + accessToken: String + ): Result { + return try { + val request = UpdateProfileRequest(name, userType) + val response = client.put("$baseUrl/users/me") { + contentType(ContentType.Application.Json) + header("Authorization", "Bearer $accessToken") + setBody(request) + } + Result.success(response.body()) + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun logout(refreshToken: String): Result { + return try { + val request = RefreshRequest(refreshToken) + client.post("$baseUrl/auth/logout") { + contentType(ContentType.Application.Json) + setBody(request) + } + Result.success(Unit) + } catch (e: Exception) { + Result.failure(e) + } + } + + private fun getDeviceInfo(): DeviceInfo { + return DeviceInfo( + platform = "android", + model = Build.MODEL, + os_version = Build.VERSION.RELEASE, + app_version = BuildConfig.VERSION_NAME, + language_code = Locale.getDefault().toString(), + timezone = TimeZone.getDefault().id + ) + } +} diff --git a/app/src/main/java/com/example/livingai_lg/api/AuthManager.kt b/app/src/main/java/com/example/livingai_lg/api/AuthManager.kt new file mode 100644 index 0000000..0a1fce9 --- /dev/null +++ b/app/src/main/java/com/example/livingai_lg/api/AuthManager.kt @@ -0,0 +1,34 @@ +package com.example.livingai_lg.api + +import android.content.Context +import android.provider.Settings + +class AuthManager( + private val context: Context, + private val apiClient: AuthApiClient, + private val tokenManager: TokenManager +) { + suspend fun requestOtp(phoneNumber: String): Result { + return apiClient.requestOtp(phoneNumber) + } + + suspend fun login(phoneNumber: String, code: String): Result { + val deviceId = getDeviceId() + return apiClient.verifyOtp(phoneNumber, code, deviceId) + .onSuccess { response -> + tokenManager.saveTokens(response.access_token, response.refresh_token) + } + } + + suspend fun updateProfile(name: String, userType: String): Result { + val accessToken = tokenManager.getAccessToken() ?: return Result.failure(Exception("No access token found")) + return apiClient.updateProfile(name, userType, accessToken) + } + + private fun getDeviceId(): String { + return Settings.Secure.getString( + context.contentResolver, + Settings.Secure.ANDROID_ID + ) + } +} diff --git a/app/src/main/java/com/example/livingai_lg/api/TokenManager.kt b/app/src/main/java/com/example/livingai_lg/api/TokenManager.kt new file mode 100644 index 0000000..3ee62e5 --- /dev/null +++ b/app/src/main/java/com/example/livingai_lg/api/TokenManager.kt @@ -0,0 +1,35 @@ +package com.example.livingai_lg.api + +import android.content.Context +import android.content.SharedPreferences +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey + +class TokenManager(context: Context) { + private val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + + private val prefs: SharedPreferences = EncryptedSharedPreferences.create( + context, + "auth_tokens", + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + + fun saveTokens(accessToken: String, refreshToken: String) { + prefs.edit().apply { + putString("access_token", accessToken) + putString("refresh_token", refreshToken) + apply() + } + } + + fun getAccessToken(): String? = prefs.getString("access_token", null) + fun getRefreshToken(): String? = prefs.getString("refresh_token", null) + + fun clearTokens() { + prefs.edit().clear().apply() + } +} diff --git a/app/src/main/java/com/example/livingai_lg/api/models.kt b/app/src/main/java/com/example/livingai_lg/api/models.kt new file mode 100644 index 0000000..50777c2 --- /dev/null +++ b/app/src/main/java/com/example/livingai_lg/api/models.kt @@ -0,0 +1,62 @@ +package com.example.livingai_lg.api + +import kotlinx.serialization.Serializable + +@Serializable +data class RequestOtpRequest(val phone_number: String) + +@Serializable +data class RequestOtpResponse(val ok: Boolean) + +@Serializable +data class DeviceInfo( + val platform: String, + val model: String? = null, + val os_version: String? = null, + val app_version: String? = null, + val language_code: String? = null, + val timezone: String? = null +) + +@Serializable +data class VerifyOtpRequest( + val phone_number: String, + val code: String, + val device_id: String, + val device_info: DeviceInfo? = null +) + +@Serializable +data class User( + val id: String, + val phone_number: String, + val name: String?, + val role: String, + val user_type: String? +) + +@Serializable +data class VerifyOtpResponse( + val user: User, + val access_token: String, + val refresh_token: String, + val needs_profile: Boolean +) + +@Serializable +data class RefreshRequest(val refresh_token: String) + +@Serializable +data class RefreshResponse(val access_token: String, val refresh_token: String) + +@Serializable +data class UpdateProfileRequest(val name: String, val user_type: String) + +@Serializable +data class UpdateProfileResponse( + val id: String, + val phone_number: String, + val name: String?, + val role: String, + val user_type: String? +) \ No newline at end of file diff --git a/app/src/main/java/com/example/livingai_lg/ui/login/CreateProfileScreen.kt b/app/src/main/java/com/example/livingai_lg/ui/login/CreateProfileScreen.kt index ef62bc1..cee669c 100644 --- a/app/src/main/java/com/example/livingai_lg/ui/login/CreateProfileScreen.kt +++ b/app/src/main/java/com/example/livingai_lg/ui/login/CreateProfileScreen.kt @@ -1,5 +1,6 @@ package com.example.livingai_lg.ui.login +import android.widget.Toast import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* @@ -7,11 +8,14 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview @@ -20,10 +24,30 @@ import androidx.compose.ui.unit.sp import androidx.navigation.NavController import androidx.navigation.compose.rememberNavController import com.example.livingai_lg.R +import com.example.livingai_lg.api.AuthApiClient +import com.example.livingai_lg.api.AuthManager +import com.example.livingai_lg.api.TokenManager import com.example.livingai_lg.ui.theme.* +import kotlinx.coroutines.launch @Composable -fun CreateProfileScreen(navController: NavController) { +fun CreateProfileScreen(navController: NavController, name: String) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val authManager = remember { AuthManager(context, AuthApiClient("http://10.0.2.2:3000"), TokenManager(context)) } + + fun updateProfile(userType: String) { + scope.launch { + authManager.updateProfile(name, userType) + .onSuccess { + navController.navigate("success") { popUpTo("login") { inclusive = true } } + } + .onFailure { + Toast.makeText(context, "Failed to update profile: ${it.message}", Toast.LENGTH_LONG).show() + } + } + } + Box( modifier = Modifier .fillMaxSize() @@ -46,26 +70,26 @@ fun CreateProfileScreen(navController: NavController) { Spacer(modifier = Modifier.height(64.dp)) - ProfileTypeItem(text = "I'm a Seller", icon = R.drawable.ic_seller) + ProfileTypeItem(text = "I'm a Seller", icon = R.drawable.ic_seller) { updateProfile("seller") } Spacer(modifier = Modifier.height(16.dp)) - ProfileTypeItem(text = "I'm a Buyer", icon = R.drawable.ic_buyer) + ProfileTypeItem(text = "I'm a Buyer", icon = R.drawable.ic_buyer) { updateProfile("buyer") } Spacer(modifier = Modifier.height(16.dp)) - ProfileTypeItem(text = "I'm a Service Provider", icon = R.drawable.ic_service_provider) + ProfileTypeItem(text = "I'm a Service Provider", icon = R.drawable.ic_service_provider) { updateProfile("service_provider") } Spacer(modifier = Modifier.height(16.dp)) - ProfileTypeItem(text = "I'm a Mandi Host", icon = R.drawable.ic_mandi_host) + ProfileTypeItem(text = "I'm a Mandi Host", icon = R.drawable.ic_mandi_host) { /* TODO: Add user_type for Mandi Host */ } } } } @Composable -fun ProfileTypeItem(text: String, icon: Int) { +fun ProfileTypeItem(text: String, icon: Int, onClick: () -> Unit) { Row( modifier = Modifier .fillMaxWidth() .height(72.dp) .shadow(elevation = 1.dp, shape = RoundedCornerShape(16.dp)) .background(Color.White, RoundedCornerShape(16.dp)) - .clickable { /* TODO */ } + .clickable(onClick = onClick) .padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically ) { @@ -81,6 +105,6 @@ fun ProfileTypeItem(text: String, icon: Int) { @Composable fun CreateProfileScreenPreview() { LivingAi_LgTheme { - CreateProfileScreen(rememberNavController()) + CreateProfileScreen(rememberNavController(), "John Doe") } } diff --git a/app/src/main/java/com/example/livingai_lg/ui/login/LoginScreen.kt b/app/src/main/java/com/example/livingai_lg/ui/login/LoginScreen.kt index ed20b15..99c7fb0 100644 --- a/app/src/main/java/com/example/livingai_lg/ui/login/LoginScreen.kt +++ b/app/src/main/java/com/example/livingai_lg/ui/login/LoginScreen.kt @@ -1,8 +1,9 @@ - package com.example.livingai_lg.ui.login +import android.widget.Toast import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults @@ -10,9 +11,8 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview @@ -24,6 +24,8 @@ import com.example.livingai_lg.ui.theme.* @Composable fun LoginScreen(navController: NavController) { + val context = LocalContext.current + Box( modifier = Modifier .fillMaxSize() @@ -33,12 +35,7 @@ fun LoginScreen(navController: NavController) { ) ) ) { - // Decorative elements - Box( - modifier = Modifier.fillMaxSize() - ) { - // ... (decorative elements) - } + // Decorative elements... Column( modifier = Modifier @@ -99,6 +96,8 @@ fun LoginScreen(navController: NavController) { textAlign = TextAlign.Center ) Spacer(modifier = Modifier.height(48.dp)) + + // New User Button Button( onClick = { navController.navigate("signup") }, shape = RoundedCornerShape(16.dp), @@ -109,9 +108,12 @@ fun LoginScreen(navController: NavController) { ) { Text(text = "New user? Sign up", color = DarkerBrown, fontSize = 16.sp, fontWeight = FontWeight.Medium) } + Spacer(modifier = Modifier.height(16.dp)) + + // Existing User Button Button( - onClick = { /* TODO: Handle Sign in */ }, + onClick = { navController.navigate("signin") }, shape = RoundedCornerShape(16.dp), colors = ButtonDefaults.buttonColors(containerColor = TerraCotta), modifier = Modifier @@ -120,13 +122,18 @@ fun LoginScreen(navController: NavController) { ) { Text(text = "Already a user? Sign in", color = DarkerBrown, fontSize = 16.sp, fontWeight = FontWeight.Medium) } + Spacer(modifier = Modifier.height(24.dp)) + + // Guest Button Text( text = "Continue as Guest", color = MidBrown, fontSize = 16.sp, fontWeight = FontWeight.Bold, + modifier = Modifier.clickable { Toast.makeText(context, "Guest mode is not yet available", Toast.LENGTH_SHORT).show() } ) + Spacer(modifier = Modifier.weight(1.5f)) } } diff --git a/app/src/main/java/com/example/livingai_lg/ui/login/OtpScreen.kt b/app/src/main/java/com/example/livingai_lg/ui/login/OtpScreen.kt index 499a4da..7ce223d 100644 --- a/app/src/main/java/com/example/livingai_lg/ui/login/OtpScreen.kt +++ b/app/src/main/java/com/example/livingai_lg/ui/login/OtpScreen.kt @@ -1,24 +1,18 @@ - package com.example.livingai_lg.ui.login +import android.widget.Toast import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.LocalTextStyle -import androidx.compose.material3.Text -import androidx.compose.material3.TextField -import androidx.compose.material3.TextFieldDefaults -import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember +import androidx.compose.material3.* +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign @@ -27,11 +21,21 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.navigation.NavController import androidx.navigation.compose.rememberNavController +import com.example.livingai_lg.api.AuthApiClient +import com.example.livingai_lg.api.AuthManager +import com.example.livingai_lg.api.TokenManager import com.example.livingai_lg.ui.theme.* +import kotlinx.coroutines.launch @Composable -fun OtpScreen(navController: NavController) { +fun OtpScreen(navController: NavController, phoneNumber: String, name: String) { val otp = remember { mutableStateOf("") } + val context = LocalContext.current + val scope = rememberCoroutineScope() + val authManager = remember { AuthManager(context, AuthApiClient("http://10.0.2.2:3000"), TokenManager(context)) } + + // Flag to determine if this is a sign-in flow for an existing user. + val isSignInFlow = name == "existing_user" Box( modifier = Modifier @@ -54,36 +58,49 @@ fun OtpScreen(navController: NavController) { Spacer(modifier = Modifier.height(32.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceEvenly - ) { - for (i in 0..3) { - TextField( - value = if (otp.value.length > i) otp.value[i].toString() else "", - onValueChange = { if (it.length <= 1) { /* TODO */ } }, - modifier = Modifier - .width(60.dp) - .height(60.dp) - .shadow(elevation = 1.dp, shape = RoundedCornerShape(16.dp)), - shape = RoundedCornerShape(16.dp), - colors = TextFieldDefaults.colors( - focusedContainerColor = Color.White.copy(alpha = 0.9f), - unfocusedContainerColor = Color.White.copy(alpha = 0.9f), - disabledContainerColor = Color.White.copy(alpha = 0.9f), - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - ), - textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Center, fontSize = 24.sp), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) - ) - } - } + TextField( + value = otp.value, + onValueChange = { if (it.length <= 6) otp.value = it }, + modifier = Modifier + .fillMaxWidth() + .height(60.dp) + .shadow(elevation = 1.dp, shape = RoundedCornerShape(16.dp)), + shape = RoundedCornerShape(16.dp), + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.White.copy(alpha = 0.9f), + unfocusedContainerColor = Color.White.copy(alpha = 0.9f), + disabledContainerColor = Color.White.copy(alpha = 0.9f), + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + ), + textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Center, fontSize = 24.sp), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) + ) Spacer(modifier = Modifier.height(48.dp)) Button( - onClick = { navController.navigate("create_profile") }, + onClick = { + scope.launch { + authManager.login(phoneNumber, otp.value) + .onSuccess { response -> + if (isSignInFlow) { + // For existing users, always go to the success screen. + navController.navigate("success") { popUpTo("login") { inclusive = true } } + } else { + // For new users, check if a profile needs to be created. + if (response.needs_profile) { + navController.navigate("create_profile/$name") + } else { + navController.navigate("success") { popUpTo("login") { inclusive = true } } + } + } + } + .onFailure { + Toast.makeText(context, "Invalid or expired OTP", Toast.LENGTH_SHORT).show() + } + } + }, shape = RoundedCornerShape(16.dp), colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFFE9A00)), modifier = Modifier @@ -101,6 +118,6 @@ fun OtpScreen(navController: NavController) { @Composable fun OtpScreenPreview() { LivingAi_LgTheme { - OtpScreen(rememberNavController()) + OtpScreen(rememberNavController(), "+919876543210", "John Doe") } } diff --git a/app/src/main/java/com/example/livingai_lg/ui/login/SignInScreen.kt b/app/src/main/java/com/example/livingai_lg/ui/login/SignInScreen.kt new file mode 100644 index 0000000..c107bfb --- /dev/null +++ b/app/src/main/java/com/example/livingai_lg/ui/login/SignInScreen.kt @@ -0,0 +1,152 @@ +package com.example.livingai_lg.ui.login + +import android.widget.Toast +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Phone +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.navigation.NavController +import androidx.navigation.compose.rememberNavController +import com.example.livingai_lg.api.AuthApiClient +import com.example.livingai_lg.api.AuthManager +import com.example.livingai_lg.api.TokenManager +import com.example.livingai_lg.ui.theme.* +import kotlinx.coroutines.launch + +@Composable +fun SignInScreen(navController: NavController) { + val phoneNumber = remember { mutableStateOf("") } + val isPhoneNumberValid = remember(phoneNumber.value) { phoneNumber.value.length == 10 && phoneNumber.value.all { it.isDigit() } } + val context = LocalContext.current + val scope = rememberCoroutineScope() + val authManager = remember { AuthManager(context, AuthApiClient("http://10.0.2.2:3000"), TokenManager(context)) } + + Box( + modifier = Modifier + .fillMaxSize() + .background( + brush = Brush.linearGradient( + colors = listOf(LightCream, LighterCream, LightestGreen) + ) + ) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 36.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(140.dp)) + + Row { + Text("Farm", fontSize = 32.sp, fontWeight = FontWeight.Medium, color = Color(0xFFE17100)) + Text("Market", fontSize = 32.sp, fontWeight = FontWeight.Medium, color = Color.Black) + } + Text("Welcome back!", fontSize = 16.sp, color = Color(0xFF4A5565)) + + Spacer(modifier = Modifier.height(128.dp)) + + Text( + text = "Enter Phone Number", + fontSize = 16.sp, + color = Color(0xFF364153), + fontWeight = FontWeight.Medium, + modifier = Modifier.align(Alignment.Start).padding(start = 21.dp) + ) + Spacer(modifier = Modifier.height(8.dp)) + Row(modifier = Modifier.fillMaxWidth()) { + Box( + modifier = Modifier + .width(65.dp) + .height(52.dp) + .shadow(elevation = 1.dp, shape = RoundedCornerShape(16.dp)) + .background(color = Color.White.copy(alpha = 0.9f), shape = RoundedCornerShape(16.dp)) + .border(width = 1.dp, color = Color.Black.copy(alpha = 0.07f), shape = RoundedCornerShape(16.dp)), + contentAlignment = Alignment.Center + ) { + Text("+91", fontSize = 16.sp, color = Color(0xFF0A0A0A)) + } + Spacer(modifier = Modifier.width(6.dp)) + TextField( + value = phoneNumber.value, + onValueChange = { phoneNumber.value = it }, + placeholder = { Text("Enter your Phone Number", color = Color(0xFF99A1AF)) }, + leadingIcon = { Icon(Icons.Default.Phone, contentDescription = null, tint = Color(0xFF99A1AF)) }, + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.White.copy(alpha = 0.9f), + unfocusedContainerColor = Color.White.copy(alpha = 0.9f), + disabledContainerColor = Color.White.copy(alpha = 0.9f), + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + ), + isError = phoneNumber.value.isNotEmpty() && !isPhoneNumberValid, + shape = RoundedCornerShape(16.dp), + modifier = Modifier.weight(1f).height(52.dp).shadow(elevation = 1.dp, shape = RoundedCornerShape(16.dp)), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone), + singleLine = true + ) + } + if (phoneNumber.value.isNotEmpty() && !isPhoneNumberValid) { + Text( + text = "Please enter a valid 10-digit phone number", + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(start = 16.dp, top = 4.dp) + ) + } + + Spacer(modifier = Modifier.height(32.dp)) + + Button( + onClick = { + val fullPhoneNumber = "+91${phoneNumber.value}" + scope.launch { + authManager.requestOtp(fullPhoneNumber) + .onSuccess { + // For existing user, name is not needed, so we pass a placeholder + navController.navigate("otp/$fullPhoneNumber/existing_user") + } + .onFailure { + Toast.makeText(context, "Failed to send OTP: ${it.message}", Toast.LENGTH_LONG).show() + } + } + }, + enabled = isPhoneNumberValid, + shape = RoundedCornerShape(16.dp), + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFFFE9A00), + disabledContainerColor = Color(0xFFF8DDA7), + contentColor = Color.White, + disabledContentColor = Color.White.copy(alpha = 0.7f) + ), + modifier = Modifier.fillMaxWidth().height(56.dp).shadow(elevation = 4.dp, shape = RoundedCornerShape(16.dp)) + ) { + Text("Sign In", fontSize = 16.sp, fontWeight = FontWeight.Medium) + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun SignInScreenPreview() { + LivingAi_LgTheme { + SignInScreen(rememberNavController()) + } +} diff --git a/app/src/main/java/com/example/livingai_lg/ui/login/SignUpScreen.kt b/app/src/main/java/com/example/livingai_lg/ui/login/SignUpScreen.kt index e0489d3..c7788cf 100644 --- a/app/src/main/java/com/example/livingai_lg/ui/login/SignUpScreen.kt +++ b/app/src/main/java/com/example/livingai_lg/ui/login/SignUpScreen.kt @@ -1,29 +1,23 @@ - package com.example.livingai_lg.ui.login +import android.widget.Toast import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Phone -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.Text -import androidx.compose.material3.TextField -import androidx.compose.material3.TextFieldDefaults -import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember +import androidx.compose.material3.* +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextDecoration @@ -32,12 +26,21 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.navigation.NavController import androidx.navigation.compose.rememberNavController +import com.example.livingai_lg.api.AuthApiClient +import com.example.livingai_lg.api.AuthManager +import com.example.livingai_lg.api.TokenManager import com.example.livingai_lg.ui.theme.* +import kotlinx.coroutines.launch @Composable fun SignUpScreen(navController: NavController) { val name = remember { mutableStateOf("") } val phoneNumber = remember { mutableStateOf("") } + val isPhoneNumberValid = remember(phoneNumber.value) { phoneNumber.value.length == 10 && phoneNumber.value.all { it.isDigit() } } + val context = LocalContext.current + val scope = rememberCoroutineScope() + // Use 10.0.2.2 to connect to host machine's localhost from emulator + val authManager = remember { AuthManager(context, AuthApiClient("http://10.0.2.2:3000"), TokenManager(context)) } Box( modifier = Modifier @@ -48,12 +51,6 @@ fun SignUpScreen(navController: NavController) { ) ) ) { - // Decorative elements from LoginScreen - Box( - modifier = Modifier.fillMaxSize() - ) { - // ... - } Column( modifier = Modifier .fillMaxSize() @@ -129,19 +126,45 @@ fun SignUpScreen(navController: NavController) { focusedIndicatorColor = Color.Transparent, unfocusedIndicatorColor = Color.Transparent, ), + isError = phoneNumber.value.isNotEmpty() && !isPhoneNumberValid, shape = RoundedCornerShape(16.dp), modifier = Modifier.weight(1f).height(52.dp).shadow(elevation = 1.dp, shape = RoundedCornerShape(16.dp)), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone), singleLine = true ) } + if (phoneNumber.value.isNotEmpty() && !isPhoneNumberValid) { + Text( + text = "Please enter a valid 10-digit phone number", + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(start = 16.dp, top = 4.dp) + ) + } Spacer(modifier = Modifier.height(32.dp)) Button( - onClick = { navController.navigate("otp") }, + onClick = { + val fullPhoneNumber = "+91${phoneNumber.value}" + scope.launch { + authManager.requestOtp(fullPhoneNumber) + .onSuccess { + navController.navigate("otp/$fullPhoneNumber/${name.value}") + } + .onFailure { + Toast.makeText(context, "Failed to send OTP: ${it.message}", Toast.LENGTH_LONG).show() + } + } + }, + enabled = isPhoneNumberValid, shape = RoundedCornerShape(16.dp), - colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFFE9A00)), + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFFFE9A00), + disabledContainerColor = Color(0xFFF8DDA7), + contentColor = Color.White, + disabledContentColor = Color.White.copy(alpha = 0.7f) + ), modifier = Modifier.fillMaxWidth().height(56.dp).shadow(elevation = 4.dp, shape = RoundedCornerShape(16.dp)) ) { Text("Sign In", color = Color.White, fontSize = 16.sp, fontWeight = FontWeight.Medium) @@ -150,7 +173,7 @@ fun SignUpScreen(navController: NavController) { Spacer(modifier = Modifier.height(32.dp)) Row(modifier = Modifier.align(Alignment.CenterHorizontally)) { - Text("Don\'t have an account? ", color = Color(0xFF4A5565), fontSize = 16.sp) + Text("Don't have an account? ", color = Color(0xFF4A5565), fontSize = 16.sp) Text( text = "Sign up", color = Color(0xFFE17100), diff --git a/app/src/main/java/com/example/livingai_lg/ui/login/SuccessScreen.kt b/app/src/main/java/com/example/livingai_lg/ui/login/SuccessScreen.kt new file mode 100644 index 0000000..1cd8e50 --- /dev/null +++ b/app/src/main/java/com/example/livingai_lg/ui/login/SuccessScreen.kt @@ -0,0 +1,56 @@ +package com.example.livingai_lg.ui.login + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.sp +import com.example.livingai_lg.ui.theme.LightCream +import com.example.livingai_lg.ui.theme.LighterCream +import com.example.livingai_lg.ui.theme.LightestGreen +import com.example.livingai_lg.ui.theme.LivingAi_LgTheme + +@Composable +fun SuccessScreen() { + Box( + modifier = Modifier + .fillMaxSize() + .background( + brush = Brush.linearGradient( + colors = listOf(LightCream, LighterCream, LightestGreen) + ) + ), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = "Success!", + fontSize = 48.sp, + fontWeight = FontWeight.Bold + ) + Text( + text = "Your profile has been created.", + fontSize = 24.sp + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun SuccessScreenPreview() { + LivingAi_LgTheme { + SuccessScreen() + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e7e6b91..fa99242 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,13 +1,17 @@ [versions] -agp = "8.13.1" -kotlin = "2.0.21" -coreKtx = "1.10.1" +agp = "8.2.2" +kotlin = "2.0.0" +coreKtx = "1.13.1" junit = "4.13.2" -junitVersion = "1.1.5" -espressoCore = "3.5.1" -lifecycleRuntimeKtx = "2.6.1" -activityCompose = "1.8.0" -composeBom = "2024.09.00" +junitVersion = "1.2.1" +espressoCore = "3.6.1" +lifecycleRuntimeKtx = "2.8.3" +activityCompose = "1.9.0" +composeBom = "2024.06.00" +navigationCompose = "2.7.7" +ktor = "2.3.12" +kotlinxSerialization = "1.6.3" +securityCrypto = "1.1.0-alpha06" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -24,9 +28,22 @@ androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "u androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } +androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } + +# Ktor +ktor-client-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" } +ktor-client-cio = { group = "io.ktor", name = "ktor-client-cio", version.ref = "ktor" } +ktor-client-content-negotiation = { group = "io.ktor", name = "ktor-client-content-negotiation", version.ref = "ktor" } +ktor-serialization-kotlinx-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" } + +# Kotlinx Serialization +kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" } + +# AndroidX Security +androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } - +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }