Upadated COde with authentication working

This commit is contained in:
Chandresh Kerkar 2025-12-19 23:24:09 +05:30
parent 6628daed50
commit 078bc02c36
11 changed files with 961 additions and 418 deletions

View File

@ -14,6 +14,7 @@
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.LivingAi_Lg" android:theme="@style/Theme.LivingAi_Lg"
android:usesCleartextTraffic="true" android:usesCleartextTraffic="true"
android:networkSecurityConfig="@xml/network_security_config"
tools:targetApi="31"> tools:targetApi="31">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"

View File

@ -2,6 +2,7 @@ package com.example.livingai_lg.api
import android.content.Context import android.content.Context
import android.os.Build import android.os.Build
import android.provider.Settings
import com.example.livingai_lg.BuildConfig import com.example.livingai_lg.BuildConfig
import io.ktor.client.* import io.ktor.client.*
import io.ktor.client.call.* import io.ktor.client.call.*
@ -11,6 +12,7 @@ import io.ktor.client.plugins.auth.*
import io.ktor.client.plugins.auth.providers.* import io.ktor.client.plugins.auth.providers.*
import io.ktor.client.plugins.contentnegotiation.* import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.* import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.* import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.* import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@ -28,6 +30,8 @@ class AuthApiClient(private val context: Context) {
prettyPrint = true prettyPrint = true
isLenient = true isLenient = true
ignoreUnknownKeys = true ignoreUnknownKeys = true
coerceInputValues = true // Coerce missing fields to default values
encodeDefaults = false
}) })
} }
@ -74,15 +78,50 @@ class AuthApiClient(private val context: Context) {
} }
suspend fun verifyOtp(phoneNumber: String, code: String, deviceId: String): Result<VerifyOtpResponse> = runCatching { suspend fun verifyOtp(phoneNumber: String, code: String, deviceId: String): Result<VerifyOtpResponse> = runCatching {
// Trim and validate code (ensure no whitespace, exactly 6 digits)
val trimmedCode = code.trim()
// Debug: Log the request details
android.util.Log.d("AuthApiClient", "Verify OTP Request:")
android.util.Log.d("AuthApiClient", " phone_number: $phoneNumber")
android.util.Log.d("AuthApiClient", " code (original): '$code' (length: ${code.length})")
android.util.Log.d("AuthApiClient", " code (trimmed): '$trimmedCode' (length: ${trimmedCode.length})")
android.util.Log.d("AuthApiClient", " device_id: $deviceId")
// Create request object with trimmed code
val request = VerifyOtpRequest(phoneNumber, trimmedCode, deviceId, getDeviceInfo())
val response: VerifyOtpResponse = client.post("auth/verify-otp") { val response: VerifyOtpResponse = client.post("auth/verify-otp") {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody(VerifyOtpRequest(phoneNumber, code, deviceId, getDeviceInfo())) // Send code as string - backend validation requires string type and bcrypt.compare needs string
setBody(request)
}.body() }.body()
tokenManager.saveTokens(response.accessToken, response.refreshToken) tokenManager.saveTokens(response.accessToken, response.refreshToken)
response response
} }
suspend fun signup(request: SignupRequest): Result<SignupResponse> = runCatching {
val response = client.post("auth/signup") {
contentType(ContentType.Application.Json)
setBody(request.copy(deviceId = getDeviceId(), deviceInfo = getDeviceInfo()))
}
// Handle both success and error responses
if (response.status.isSuccess()) {
response.body<SignupResponse>()
} else {
// Try to parse error response as SignupResponse (for 409 conflicts with user_exists flag)
try {
response.body<SignupResponse>()
} catch (e: Exception) {
// If parsing fails, throw an exception with the status and message
val errorBody = response.bodyAsText()
throw Exception("Signup failed: ${response.status} - $errorBody")
}
}
}
suspend fun updateProfile(name: String, userType: String): Result<User> = runCatching { suspend fun updateProfile(name: String, userType: String): Result<User> = runCatching {
client.put("users/me") { client.put("users/me") {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
@ -115,4 +154,8 @@ class AuthApiClient(private val context: Context) {
timezone = TimeZone.getDefault().id timezone = TimeZone.getDefault().id
) )
} }
private fun getDeviceId(): String {
return Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID)
}
} }

View File

@ -16,7 +16,22 @@ class AuthManager(
val deviceId = getDeviceId() val deviceId = getDeviceId()
return apiClient.verifyOtp(phoneNumber, code, deviceId) return apiClient.verifyOtp(phoneNumber, code, deviceId)
.onSuccess { response -> .onSuccess { response ->
tokenManager.saveTokens(response.accessToken, response.refreshToken) response.accessToken?.let { accessToken ->
response.refreshToken?.let { refreshToken ->
tokenManager.saveTokens(accessToken, refreshToken)
}
}
}
}
suspend fun signup(signupRequest: SignupRequest): Result<SignupResponse> {
return apiClient.signup(signupRequest)
.onSuccess { response ->
response.accessToken?.let { accessToken ->
response.refreshToken?.let { refreshToken ->
tokenManager.saveTokens(accessToken, refreshToken)
}
}
} }
} }

View File

@ -23,7 +23,7 @@ data class DeviceInfo(
@Serializable @Serializable
data class VerifyOtpRequest( data class VerifyOtpRequest(
@SerialName("phone_number") val phoneNumber: String, @SerialName("phone_number") val phoneNumber: String,
val code: String, val code: String, // Changed from Int to String - backend expects string for bcrypt comparison
@SerialName("device_id") val deviceId: String, @SerialName("device_id") val deviceId: String,
@SerialName("device_info") val deviceInfo: DeviceInfo? = null @SerialName("device_info") val deviceInfo: DeviceInfo? = null
) )
@ -37,6 +37,34 @@ data class VerifyOtpResponse(
) )
// endregion // endregion
// region: Signup
@Serializable
data class SignupRequest(
val name: String,
@SerialName("phone_number") val phoneNumber: String,
val state: String? = null,
val district: String? = null,
@SerialName("city_village") val cityVillage: String? = null,
@SerialName("device_id") val deviceId: String? = null,
@SerialName("device_info") val deviceInfo: DeviceInfo? = null
)
@Serializable
data class SignupResponse(
val success: Boolean,
val user: User? = null,
@SerialName("access_token") val accessToken: String? = null,
@SerialName("refresh_token") val refreshToken: String? = null,
@SerialName("needs_profile") val needsProfile: Boolean? = null,
@SerialName("is_new_account") val isNewAccount: Boolean? = null,
@SerialName("is_new_device") val isNewDevice: Boolean? = null,
@SerialName("active_devices_count") val activeDevicesCount: Int? = null,
@SerialName("location_id") val locationId: String? = null,
val message: String? = null,
@SerialName("user_exists") val userExists: Boolean? = null
)
// endregion
// region: Token Refresh // region: Token Refresh
@Serializable @Serializable
data class RefreshRequest(@SerialName("refresh_token") val refreshToken: String) data class RefreshRequest(@SerialName("refresh_token") val refreshToken: String)
@ -59,9 +87,11 @@ data class UpdateProfileRequest(
data class User( data class User(
val id: String, val id: String,
@SerialName("phone_number") val phoneNumber: String, @SerialName("phone_number") val phoneNumber: String,
val name: String?, val name: String? = null,
val role: String, val role: String? = null, // Made nullable - backend can return null for new users
@SerialName("user_type") val userType: String? @SerialName("user_type") val userType: String? = null, // Made nullable with default - backend may not return this
@SerialName("created_at") val createdAt: String? = null,
@SerialName("country_code") val countryCode: String? = null
) )
@Serializable @Serializable

View File

@ -0,0 +1,192 @@
# Signup API Endpoint
## POST /auth/signup
Creates a new user account with name, phone number, and optional location information.
### Request Body
```json
{
"name": "John Doe", // Required: User's name (string, max 100 chars)
"phone_number": "+919876543210", // Required: Phone number in E.164 format
"state": "Maharashtra", // Optional: State name (string, max 100 chars)
"district": "Mumbai", // Optional: District name (string, max 100 chars)
"city_village": "Andheri", // Optional: City/Village name (string, max 150 chars)
"device_id": "device-123", // Optional: Device identifier
"device_info": { // Optional: Device information
"platform": "android",
"model": "Samsung Galaxy S21",
"os_version": "Android 13",
"app_version": "1.0.0",
"language_code": "en",
"timezone": "Asia/Kolkata"
}
}
```
### Success Response (201 Created)
```json
{
"success": true,
"user": {
"id": "uuid-here",
"phone_number": "+919876543210",
"name": "John Doe",
"country_code": "+91",
"created_at": "2024-01-15T10:30:00Z"
},
"access_token": "jwt-access-token",
"refresh_token": "jwt-refresh-token",
"needs_profile": true,
"is_new_account": true,
"is_new_device": true,
"active_devices_count": 1,
"location_id": "uuid-of-location" // null if no location provided
}
```
### Error Responses
#### 400 Bad Request - Validation Error
```json
{
"error": "name is required"
}
```
#### 409 Conflict - User Already Exists
```json
{
"success": false,
"message": "User with this phone number already exists. Please sign in instead.",
"user_exists": true
}
```
#### 403 Forbidden - IP Blocked
```json
{
"success": false,
"message": "Access denied from this location."
}
```
#### 500 Internal Server Error
```json
{
"success": false,
"message": "Internal server error"
}
```
### Features
1. **User Existence Check**: Automatically checks if a user with the phone number already exists
2. **Phone Number Encryption**: Phone numbers are encrypted before storing in database
3. **Location Creation**: If state/district/city_village provided, creates a location entry
4. **Token Issuance**: Automatically issues access and refresh tokens
5. **Device Tracking**: Records device information for security
6. **Audit Logging**: Logs signup events for security monitoring
### Example Usage
#### cURL
```bash
curl -X POST http://localhost:3000/auth/signup \
-H "Content-Type: application/json" \
-d '{
"name": "John Doe",
"phone_number": "+919876543210",
"state": "Maharashtra",
"district": "Mumbai",
"city_village": "Andheri",
"device_id": "android-device-123",
"device_info": {
"platform": "android",
"model": "Samsung Galaxy S21",
"os_version": "Android 13"
}
}'
```
#### JavaScript/TypeScript
```javascript
const response = await fetch('/auth/signup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'John Doe',
phone_number: '+919876543210',
state: 'Maharashtra',
district: 'Mumbai',
city_village: 'Andheri',
device_id: 'android-device-123',
device_info: {
platform: 'android',
model: 'Samsung Galaxy S21',
os_version: 'Android 13'
}
})
});
const data = await response.json();
if (data.success) {
// Store tokens
localStorage.setItem('access_token', data.access_token);
localStorage.setItem('refresh_token', data.refresh_token);
}
```
#### Kotlin/Android
```kotlin
data class SignupRequest(
val name: String,
val phone_number: String,
val state: String? = null,
val district: String? = null,
val city_village: String? = null,
val device_id: String? = null,
val device_info: Map<String, String?>? = null
)
data class SignupResponse(
val success: Boolean,
val user: User,
val access_token: String,
val refresh_token: String,
val needs_profile: Boolean,
val is_new_account: Boolean,
val is_new_device: Boolean,
val active_devices_count: Int,
val location_id: String?
)
// Usage
val request = SignupRequest(
name = "John Doe",
phone_number = "+919876543210",
state = "Maharashtra",
district = "Mumbai",
city_village = "Andheri",
device_id = getDeviceId(),
device_info = mapOf(
"platform" to "android",
"model" to Build.MODEL,
"os_version" to Build.VERSION.RELEASE
)
)
val response = apiClient.post<SignupResponse>("/auth/signup", request)
```
### Notes
- Phone number must be in E.164 format (e.g., `+919876543210`)
- If phone number is 10 digits without `+`, it will be normalized to `+91` prefix
- Location fields are optional - user can be created without location
- If user already exists, returns 409 Conflict with `user_exists: true`
- All phone numbers are encrypted in the database for security
- Country code is automatically extracted from phone number

View File

@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@ -74,6 +75,9 @@ object AppScreen {
fun otp(phone: String, name: String) = fun otp(phone: String, name: String) =
"$OTP/$phone/$name" "$OTP/$phone/$name"
fun otpWithSignup(phone: String, name: String, state: String, district: String, village: String) =
"$OTP/$phone/$name/$state/$district/$village"
fun createProfile(name: String) = fun createProfile(name: String) =
"$CREATE_PROFILE/$name" "$CREATE_PROFILE/$name"
@ -109,25 +113,30 @@ fun AppNavigation(
authState: AuthState authState: AuthState
) { ) {
val navController = rememberNavController() val navController = rememberNavController()
var isLoggedIn = false;
// Always start with AUTH (landing screen) - this ensures LandingScreen opens first
when (authState) { // We'll navigate to MAIN only if user is authenticated (handled by LaunchedEffect)
is AuthState.Unauthenticated -> {isLoggedIn = false; }
is AuthState.Authenticated -> {isLoggedIn = true;}
is AuthState.Unknown -> {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
}
NavHost( NavHost(
navController = navController, navController = navController,
startDestination = if (isLoggedIn) Graph.MAIN else Graph.AUTH startDestination = Graph.AUTH
) { ) {
authNavGraph(navController) authNavGraph(navController)
mainNavGraph(navController) mainNavGraph(navController)
} }
// Navigate to MAIN graph if user is authenticated
LaunchedEffect(authState) {
if (authState is AuthState.Authenticated) {
val currentRoute = navController.currentBackStackEntry?.destination?.route
// Only navigate if we're not already in the MAIN graph
if (currentRoute?.startsWith(Graph.MAIN) != true) {
navController.navigate(Graph.MAIN) {
// Clear back stack to prevent going back to auth screens
popUpTo(Graph.AUTH) { inclusive = true }
}
}
}
}
// MainNavGraph(navController) // MainNavGraph(navController)
// AuthNavGraph(navController) // AuthNavGraph(navController)
// when (authState) { // when (authState) {

View File

@ -1,71 +1,192 @@
package com.example.livingai_lg.ui.navigation package com.example.livingai_lg.ui.navigation
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.NavType import androidx.navigation.NavType
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.navigation import androidx.navigation.compose.navigation
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument import androidx.navigation.navArgument
import com.example.livingai_lg.ui.screens.SaleArchiveScreen import kotlinx.coroutines.CoroutineScope
import com.example.livingai_lg.ui.screens.auth.LandingScreen import kotlinx.coroutines.Dispatchers
import com.example.livingai_lg.ui.screens.auth.OtpScreen import kotlinx.coroutines.delay
import com.example.livingai_lg.ui.screens.auth.SignInScreen import kotlinx.coroutines.launch
import com.example.livingai_lg.ui.screens.auth.SignUpScreen import com.example.livingai_lg.ui.screens.SaleArchiveScreen
import com.example.livingai_lg.ui.screens.auth.LandingScreen
fun NavGraphBuilder.authNavGraph(navController: NavController) { import com.example.livingai_lg.ui.screens.auth.OtpScreen
navigation( import com.example.livingai_lg.ui.screens.auth.SignInScreen
route = Graph.AUTH, import com.example.livingai_lg.ui.screens.auth.SignUpScreen
startDestination = AppScreen.LANDING
) { fun NavGraphBuilder.authNavGraph(navController: NavController) {
composable(AppScreen.LANDING) { navigation(
LandingScreen( route = Graph.AUTH,
onSignUpClick = { navController.navigate(AppScreen.SIGN_UP) }, startDestination = AppScreen.LANDING
onSignInClick = { navController.navigate(AppScreen.SIGN_IN) }, ) {
onGuestClick = { navController.navigate(Graph.main(AppScreen.createProfile("guest"))) } composable(AppScreen.LANDING) {
) LandingScreen(
} onSignUpClick = { navController.navigate(AppScreen.SIGN_UP) },
onSignInClick = { navController.navigate(AppScreen.SIGN_IN) },
composable(AppScreen.SIGN_IN) { onGuestClick = { navController.navigate(Graph.main(AppScreen.createProfile("guest"))) }
SignInScreen( )
onSignUpClick = { navController.navigate(AppScreen.SIGN_UP){ }
popUpTo(AppScreen.SIGN_IN) { inclusive = true }
} }, composable(AppScreen.SIGN_IN) {
onSignInClick = { phone, name -> SignInScreen(
navController.navigate(AppScreen.otp(phone,name)) onSignUpClick = { navController.navigate(AppScreen.SIGN_UP){
} popUpTo(AppScreen.SIGN_IN) { inclusive = true }
) } },
} onSignInClick = { phone, name ->
navController.navigate(AppScreen.otp(phone,name))
composable(AppScreen.SIGN_UP) { }
SignUpScreen( )
onSignUpClick = { phone, name -> }
navController.navigate(AppScreen.otp(phone,name))
}, composable(AppScreen.SIGN_UP) {
onSignInClick = { SignUpScreen(
navController.navigate(AppScreen.SIGN_IN) { onSignUpClick = { phone, name, state, district, village ->
popUpTo(AppScreen.SIGN_UP) { inclusive = true } navController.navigate(AppScreen.otpWithSignup(phone, name, state, district, village))
} },
} onSignInClick = {
) navController.navigate(AppScreen.SIGN_IN) {
} popUpTo(AppScreen.SIGN_UP) { inclusive = true }
}
composable( }
"${AppScreen.OTP}/{phoneNumber}/{name}", )
arguments = listOf( }
navArgument("phoneNumber") { type = NavType.StringType },
navArgument("name") { type = NavType.StringType } composable(
) "${AppScreen.OTP}/{phoneNumber}/{name}",
) { backStackEntry -> arguments = listOf(
OtpScreen( navArgument("phoneNumber") { type = NavType.StringType },
phoneNumber = backStackEntry.arguments?.getString("phoneNumber") ?: "", navArgument("name") { type = NavType.StringType }
name = backStackEntry.arguments?.getString("name") ?: "", )
onCreateProfile = {name -> navController.navigate(Graph.main(AppScreen.createProfile(name)))}, ) { backStackEntry ->
onSuccess = { navController.navigate(Graph.auth(AppScreen.chooseService("1")))} OtpScreen(
) phoneNumber = backStackEntry.arguments?.getString("phoneNumber") ?: "",
} name = backStackEntry.arguments?.getString("name") ?: "",
} onCreateProfile = {name ->
} android.util.Log.d("AuthNavGraph", "Navigating to create profile with name: $name")
// Navigate to main graph first without popping, then navigate to specific route
try {
// Navigate to main graph (this will use its start destination)
navController.navigate(Graph.MAIN) {
// Don't pop the AUTH graph yet - keep the graph structure
launchSingleTop = true
}
// Then navigate to the specific route after a short delay
CoroutineScope(Dispatchers.Main).launch {
delay(200)
try {
navController.navigate(AppScreen.createProfile(name)) {
// Now pop the AUTH graph after we're in the MAIN graph
popUpTo(Graph.AUTH) { inclusive = true }
}
} catch (e: Exception) {
android.util.Log.e("AuthNavGraph", "Secondary navigation error: ${e.message}", e)
}
}
} catch (e: Exception) {
android.util.Log.e("AuthNavGraph", "Navigation error: ${e.message}", e)
}
},
onSuccess = {
android.util.Log.d("AuthNavGraph", "Navigating to choose service")
// Navigate to main graph first without popping, then navigate to specific route
try {
// Navigate to main graph (this will use its start destination)
navController.navigate(Graph.MAIN) {
// Don't pop the AUTH graph yet - keep the graph structure
launchSingleTop = true
}
// Then navigate to the specific route after a short delay
CoroutineScope(Dispatchers.Main).launch {
delay(200)
try {
navController.navigate(AppScreen.chooseService("1")) {
// Now pop the AUTH graph after we're in the MAIN graph
popUpTo(Graph.AUTH) { inclusive = true }
}
} catch (e: Exception) {
android.util.Log.e("AuthNavGraph", "Secondary navigation error: ${e.message}", e)
}
}
} catch (e: Exception) {
android.util.Log.e("AuthNavGraph", "Navigation error: ${e.message}", e)
}
}
)
}
composable(
"${AppScreen.OTP}/{phoneNumber}/{name}/{state}/{district}/{village}",
arguments = listOf(
navArgument("phoneNumber") { type = NavType.StringType },
navArgument("name") { type = NavType.StringType },
navArgument("state") { type = NavType.StringType },
navArgument("district") { type = NavType.StringType },
navArgument("village") { type = NavType.StringType }
)
) { backStackEntry ->
OtpScreen(
phoneNumber = backStackEntry.arguments?.getString("phoneNumber") ?: "",
name = backStackEntry.arguments?.getString("name") ?: "",
signupState = backStackEntry.arguments?.getString("state"),
signupDistrict = backStackEntry.arguments?.getString("district"),
signupVillage = backStackEntry.arguments?.getString("village"),
onCreateProfile = {name ->
android.util.Log.d("AuthNavGraph", "Navigating to create profile with name: $name")
// Navigate to main graph first without popping, then navigate to specific route
try {
// Navigate to main graph (this will use its start destination)
navController.navigate(Graph.MAIN) {
// Don't pop the AUTH graph yet - keep the graph structure
launchSingleTop = true
}
// Then navigate to the specific route after a short delay
CoroutineScope(Dispatchers.Main).launch {
delay(200)
try {
navController.navigate(AppScreen.createProfile(name)) {
// Now pop the AUTH graph after we're in the MAIN graph
popUpTo(Graph.AUTH) { inclusive = true }
}
} catch (e: Exception) {
android.util.Log.e("AuthNavGraph", "Secondary navigation error: ${e.message}", e)
}
}
} catch (e: Exception) {
android.util.Log.e("AuthNavGraph", "Navigation error: ${e.message}", e)
}
},
onSuccess = {
android.util.Log.d("AuthNavGraph", "Navigating to choose service")
// Navigate to main graph first without popping, then navigate to specific route
try {
// Navigate to main graph (this will use its start destination)
navController.navigate(Graph.MAIN) {
// Don't pop the AUTH graph yet - keep the graph structure
launchSingleTop = true
}
// Then navigate to the specific route after a short delay
CoroutineScope(Dispatchers.Main).launch {
delay(200)
try {
navController.navigate(AppScreen.chooseService("1")) {
// Now pop the AUTH graph after we're in the MAIN graph
popUpTo(Graph.AUTH) { inclusive = true }
}
} catch (e: Exception) {
android.util.Log.e("AuthNavGraph", "Secondary navigation error: ${e.message}", e)
}
}
} catch (e: Exception) {
android.util.Log.e("AuthNavGraph", "Navigation error: ${e.message}", e)
}
}
)
}
}
}

View File

@ -44,7 +44,7 @@ fun NavGraphBuilder.mainNavGraph(navController: NavController) {
navigation( navigation(
route = Graph.MAIN, route = Graph.MAIN,
startDestination = AppScreen.createProfile("guest") startDestination = AppScreen.BUY_ANIMALS
){ ){

View File

@ -1,325 +1,436 @@
package com.example.livingai_lg.ui.screens.auth package com.example.livingai_lg.ui.screens.auth
import android.widget.Toast import android.widget.Toast
import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.LocalTextStyle
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Shadow import androidx.compose.ui.graphics.Shadow
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.example.livingai_lg.api.AuthApiClient import com.example.livingai_lg.api.AuthApiClient
import com.example.livingai_lg.api.AuthManager import com.example.livingai_lg.api.AuthManager
import com.example.livingai_lg.api.TokenManager import com.example.livingai_lg.api.SignupRequest
import com.example.livingai_lg.ui.components.backgrounds.DecorativeBackground import com.example.livingai_lg.api.TokenManager
import kotlin.math.min import com.example.livingai_lg.ui.components.backgrounds.DecorativeBackground
import androidx.compose.ui.focus.FocusRequester import kotlin.math.min
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.input.key.* import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.input.key.*
import androidx.compose.ui.unit.Dp import androidx.compose.ui.platform.LocalFocusManager
import kotlinx.coroutines.launch import androidx.compose.ui.unit.Dp
import androidx.compose.ui.input.key.Key import kotlinx.coroutines.launch
import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.onPreviewKeyEvent
@Composable
fun OtpScreen( @Composable
phoneNumber: String, fun OtpScreen(
name: String, phoneNumber: String,
onSuccess: () -> Unit = {}, name: String,
onCreateProfile: (name: String) -> Unit = {}, onSuccess: () -> Unit = {},
) { onCreateProfile: (name: String) -> Unit = {},
val otp = remember { mutableStateOf("") } // Optional signup data for signup flow
val context = LocalContext.current.applicationContext signupState: String? = null,
val scope = rememberCoroutineScope() signupDistrict: String? = null,
val authManager = remember { AuthManager(context, AuthApiClient(context), TokenManager(context)) } signupVillage: String? = null,
) {
// Flag to determine if this is a sign-in flow for an existing user. val otp = remember { mutableStateOf("") }
val isSignInFlow = name == "existing_user" val context = LocalContext.current.applicationContext
val scope = rememberCoroutineScope()
BoxWithConstraints( val authManager = remember { AuthManager(context, AuthApiClient(context), TokenManager(context)) }
modifier = Modifier
.fillMaxSize() // Flag to determine if this is a sign-in flow for an existing user.
.background( val isSignInFlow = name == "existing_user"
Brush.linearGradient( // Flag to determine if this is a signup flow (has signup data)
listOf( val isSignupFlow = !isSignInFlow && (signupState != null || signupDistrict != null || signupVillage != null)
Color(0xFFFFFBEA),
Color(0xFFFDFBE8), BoxWithConstraints(
Color(0xFFF7FEE7) modifier = Modifier
) .fillMaxSize()
) .background(
) Brush.linearGradient(
) { listOf(
DecorativeBackground() Color(0xFFFFFBEA),
Color(0xFFFDFBE8),
val screenW = maxWidth.value Color(0xFFF7FEE7)
val screenH = maxHeight.value )
)
// Figma design reference size from Flutter widget )
val designW = 393.39f ) {
val designH = 852.53f DecorativeBackground()
val scale = min(screenW / designW, screenH / designH) val screenW = maxWidth.value
val screenH = maxHeight.value
fun s(v: Float) = (v * scale).dp // dp scaling
fun fs(v: Float) = (v * scale).sp // font scaling // Figma design reference size from Flutter widget
Column( val designW = 393.39f
Modifier.fillMaxSize().padding(horizontal = 12.dp), val designH = 852.53f
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally val scale = min(screenW / designW, screenH / designH)
) {
// --------------------------- fun s(v: Float) = (v * scale).dp // dp scaling
// "Enter OTP" Title fun fs(v: Float) = (v * scale).sp // font scaling
// --------------------------- Column(
Text( Modifier.fillMaxSize().padding(horizontal = 12.dp),
text = "Enter OTP", verticalArrangement = Arrangement.Center,
color = Color(0xFF927B5E), horizontalAlignment = Alignment.CenterHorizontally
fontSize = fs(20f), ) {
fontWeight = FontWeight.Medium, // ---------------------------
modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp), // "Enter OTP" Title
textAlign = TextAlign.Center, // ---------------------------
style = LocalTextStyle.current.copy( Text(
shadow = Shadow( text = "Enter OTP",
color = Color.Black.copy(alpha = 0.25f), color = Color(0xFF927B5E),
offset = Offset(0f, s(4f).value), fontSize = fs(20f),
blurRadius = s(4f).value fontWeight = FontWeight.Medium,
) modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp),
) textAlign = TextAlign.Center,
) style = LocalTextStyle.current.copy(
shadow = Shadow(
// --------------------------- color = Color.Black.copy(alpha = 0.25f),
// OTP 4-Box Input Row offset = Offset(0f, s(4f).value),
// --------------------------- blurRadius = s(4f).value
Row( )
Modifier.fillMaxWidth().padding(horizontal = 12.dp), )
horizontalArrangement = Arrangement.spacedBy(s(17f)) )
) {
OtpInputRow( // ---------------------------
otpLength = 6, // OTP 4-Box Input Row
scale = scale, // ---------------------------
otp = otp.value, Row(
onOtpChange = { if (it.length <= 6) otp.value = it } Modifier.fillMaxWidth().padding(horizontal = 12.dp),
) horizontalArrangement = Arrangement.spacedBy(s(17f))
} ) {
// --------------------------- OtpInputRow(
// Continue Button otpLength = 6,
// --------------------------- scale = scale,
Box( otp = otp.value,
modifier = Modifier onOtpChange = { if (it.length <= 6) otp.value = it }
.fillMaxWidth().padding(vertical = 16.dp, horizontal = 48.dp) )
.size(s(279.25f), s(55.99f)) }
.shadow( // ---------------------------
elevation = s(6f), // Continue Button
ambientColor = Color.Black.copy(alpha = 0.10f), // ---------------------------
shape = RoundedCornerShape(s(16f)), Box(
) modifier = Modifier
.shadow( .fillMaxWidth().padding(vertical = 16.dp, horizontal = 48.dp)
elevation = s(15f), .size(s(279.25f), s(55.99f))
ambientColor = Color.Black.copy(alpha = 0.10f), .shadow(
shape = RoundedCornerShape(s(16f)), elevation = s(6f),
) ambientColor = Color.Black.copy(alpha = 0.10f),
.background( shape = RoundedCornerShape(s(16f)),
Brush.horizontalGradient( )
listOf(Color(0xFFFD9900), Color(0xFFE17100)) .shadow(
), elevation = s(15f),
shape = RoundedCornerShape(s(16f)) ambientColor = Color.Black.copy(alpha = 0.10f),
) shape = RoundedCornerShape(s(16f)),
.clickable( )
indication = LocalIndication.current, .background(
interactionSource = remember { MutableInteractionSource() }, Brush.horizontalGradient(
onClick = { listOf(Color(0xFFFD9900), Color(0xFFE17100))
scope.launch { ),
authManager.login(phoneNumber, otp.value) shape = RoundedCornerShape(s(16f))
.onSuccess { response -> )
if (isSignInFlow) { .clickable(
// For existing users, always go to the success screen. indication = LocalIndication.current,
onSuccess() interactionSource = remember { MutableInteractionSource() },
} else { onClick = {
// For new users, check if a profile needs to be created. scope.launch {
if (response.needsProfile) { if (isSignupFlow) {
onCreateProfile(name) // For signup flow: Verify OTP first, then call signup API to update user with name/location
} else { android.util.Log.d("OTPScreen", "Signup flow: Verifying OTP...")
onSuccess() authManager.login(phoneNumber, otp.value)
} .onSuccess { verifyResponse ->
} android.util.Log.d("OTPScreen", "OTP verified successfully. Calling signup API...")
} // OTP verified successfully - user is now logged in
.onFailure { // Now call signup API to update user with name and location
Toast.makeText( val signupRequest = SignupRequest(
context, name = name,
"Invalid or expired OTP", phoneNumber = phoneNumber,
Toast.LENGTH_SHORT state = signupState,
).show() district = signupDistrict,
} cityVillage = signupVillage
} )
} authManager.signup(signupRequest)
), .onSuccess { signupResponse ->
contentAlignment = Alignment.Center android.util.Log.d("OTPScreen", "Signup API response: success=${signupResponse.success}, userExists=${signupResponse.userExists}, needsProfile=${signupResponse.needsProfile}")
) { // Signup API response - check if successful or user exists
Text( if (signupResponse.success || signupResponse.userExists == true) {
"Continue", // Success - user is created/updated and logged in
color = Color.White, // Check if profile needs completion
fontSize = fs(16f), val needsProfile = signupResponse.needsProfile == true || verifyResponse.needsProfile
fontWeight = FontWeight.Medium android.util.Log.d("OTPScreen", "Signup successful. needsProfile=$needsProfile, navigating...")
) try {
} if (needsProfile) {
} android.util.Log.d("OTPScreen", "Navigating to create profile screen with name: $name")
} onCreateProfile(name)
} } else {
android.util.Log.d("OTPScreen", "Navigating to success screen")
@Composable onSuccess()
fun OtpInputRow( }
otpLength: Int, } catch (e: Exception) {
scale: Float, android.util.Log.e("OTPScreen", "Navigation error: ${e.message}", e)
otp: String, Toast.makeText(context, "Navigation error: ${e.message}", Toast.LENGTH_LONG).show()
onOtpChange: (String) -> Unit }
) { } else {
BoxWithConstraints( // Signup failed but OTP was verified - user is logged in
modifier = Modifier.fillMaxWidth(), // Navigate to success anyway since verify-otp succeeded
contentAlignment = Alignment.Center android.util.Log.d("OTPScreen", "Signup API returned false, but OTP verified. Navigating anyway...")
) { val needsProfile = verifyResponse.needsProfile
val maxRowWidth = maxWidth try {
if (needsProfile) {
val spacing = (12f * scale).dp android.util.Log.d("OTPScreen", "Navigating to create profile screen with name: $name")
val totalSpacing = spacing * (otpLength - 1) onCreateProfile(name)
} else {
val boxWidth = ((maxRowWidth - totalSpacing) / otpLength) android.util.Log.d("OTPScreen", "Navigating to success screen")
.coerceAtMost((66f * scale).dp) onSuccess()
}
val focusRequesters = remember { } catch (e: Exception) {
List(otpLength) { FocusRequester() } android.util.Log.e("OTPScreen", "Navigation error: ${e.message}", e)
} Toast.makeText(context, "Navigation error: ${e.message}", Toast.LENGTH_LONG).show()
}
Row( // Show warning if signup update failed
horizontalArrangement = Arrangement.spacedBy(spacing), val errorMsg = signupResponse.message
verticalAlignment = Alignment.CenterVertically if (errorMsg != null) {
) { Toast.makeText(context, "Profile update: $errorMsg", Toast.LENGTH_SHORT).show()
repeat(otpLength) { index -> }
OtpBox( }
index = index, }
otp = otp, .onFailure { signupError ->
scale = scale, android.util.Log.e("OTPScreen", "Signup API failed: ${signupError.message}", signupError)
width = boxWidth, // 👈 fixed width // Signup API failed but OTP was verified - user is logged in
focusRequester = focusRequesters[index], // Navigate to success anyway since verify-otp succeeded
onRequestFocus = { val needsProfile = verifyResponse.needsProfile
val firstEmpty = otp.length.coerceAtMost(otpLength - 1) android.util.Log.d("OTPScreen", "Navigating despite signup failure. needsProfile=$needsProfile")
focusRequesters[firstEmpty].requestFocus() try {
}, if (needsProfile) {
onNextFocus = { android.util.Log.d("OTPScreen", "Navigating to create profile screen with name: $name")
if (index + 1 < otpLength) focusRequesters[index + 1].requestFocus() onCreateProfile(name)
}, } else {
onPrevFocus = { android.util.Log.d("OTPScreen", "Navigating to success screen")
if (index - 1 >= 0) focusRequesters[index - 1].requestFocus() onSuccess()
}, }
onChange = onOtpChange } catch (e: Exception) {
) android.util.Log.e("OTPScreen", "Navigation error: ${e.message}", e)
} Toast.makeText(context, "Navigation error: ${e.message}", Toast.LENGTH_LONG).show()
} }
} // Show warning about signup failure
} val errorMsg = signupError.message ?: "Profile update failed"
Toast.makeText(context, "Warning: $errorMsg", Toast.LENGTH_SHORT).show()
}
}
.onFailure { error ->
@Composable android.util.Log.e("OTPScreen", "OTP verification failed: ${error.message}", error)
private fun OtpBox( Toast.makeText(
index: Int, context,
otp: String, "Invalid or expired OTP",
scale: Float, Toast.LENGTH_SHORT
width: Dp, ).show()
focusRequester: FocusRequester, }
onRequestFocus: () -> Unit, } else {
onNextFocus: () -> Unit, // For sign-in flow: Just verify OTP and login
onPrevFocus: () -> Unit, authManager.login(phoneNumber, otp.value)
onChange: (String) -> Unit .onSuccess { response ->
) { android.util.Log.d("OTPScreen", "Sign-in OTP verified. needsProfile=${response.needsProfile}")
val boxH = 52f * scale try {
val radius = 16f * scale if (isSignInFlow) {
// For existing users, always go to the success screen.
val char = otp.getOrNull(index)?.toString() ?: "" android.util.Log.d("OTPScreen", "Existing user - navigating to success")
onSuccess()
Box( } else {
modifier = Modifier // For new users, check if a profile needs to be created.
.size(width, boxH.dp) if (response.needsProfile) {
.shadow((4f * scale).dp, RoundedCornerShape(radius.dp)) android.util.Log.d("OTPScreen", "New user needs profile - navigating to create profile with name: $name")
.background(Color.White, RoundedCornerShape(radius.dp)) onCreateProfile(name)
.clickable { onRequestFocus() }, } else {
contentAlignment = Alignment.Center android.util.Log.d("OTPScreen", "New user - navigating to success")
) { onSuccess()
BasicTextField( }
value = char, }
onValueChange = { new -> } catch (e: Exception) {
when { android.util.Log.e("OTPScreen", "Navigation error: ${e.message}", e)
// DIGIT ENTERED Toast.makeText(context, "Navigation error: ${e.message}", Toast.LENGTH_LONG).show()
new.matches(Regex("\\d")) -> { }
val updated = otp.padEnd(index + 1, ' ').toMutableList() }
updated[index] = new.first() .onFailure { error ->
onChange(updated.joinToString("").trim()) android.util.Log.e("OTPScreen", "OTP verification failed: ${error.message}", error)
onNextFocus() Toast.makeText(
} context,
"Invalid or expired OTP",
// BACKSPACE WHEN CHARACTER EXISTS Toast.LENGTH_SHORT
new.isEmpty() && char.isNotEmpty() -> { ).show()
val updated = otp.toMutableList() }
updated.removeAt(index) }
onChange(updated.joinToString("")) }
} }
} ),
}, contentAlignment = Alignment.Center
modifier = Modifier ) {
.focusRequester(focusRequester) Text(
.onPreviewKeyEvent { event -> "Continue",
if (event.type == KeyEventType.KeyDown && color = Color.White,
event.key == Key.Backspace && fontSize = fs(16f),
char.isEmpty() && fontWeight = FontWeight.Medium
index > 0 )
) { }
val updated = otp.toMutableList() }
updated.removeAt(index - 1) // 👈 clear previous box }
onChange(updated.joinToString("")) }
onPrevFocus()
true @Composable
} fun OtpInputRow(
else { otpLength: Int,
false scale: Float,
} otp: String,
}, onOtpChange: (String) -> Unit
textStyle = LocalTextStyle.current.copy( ) {
fontSize = (24f * scale).sp, BoxWithConstraints(
fontWeight = FontWeight.Medium, modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center contentAlignment = Alignment.Center
), ) {
keyboardOptions = KeyboardOptions( val maxRowWidth = maxWidth
keyboardType = KeyboardType.NumberPassword
), val spacing = (12f * scale).dp
singleLine = true val totalSpacing = spacing * (otpLength - 1)
)
val boxWidth = ((maxRowWidth - totalSpacing) / otpLength)
.coerceAtMost((66f * scale).dp)
} val focusRequesters = remember {
} List(otpLength) { FocusRequester() }
}
Row(
horizontalArrangement = Arrangement.spacedBy(spacing),
verticalAlignment = Alignment.CenterVertically
) {
repeat(otpLength) { index ->
OtpBox(
index = index,
otp = otp,
scale = scale,
width = boxWidth, // 👈 fixed width
focusRequester = focusRequesters[index],
onRequestFocus = {
val firstEmpty = otp.length.coerceAtMost(otpLength - 1)
focusRequesters[firstEmpty].requestFocus()
},
onNextFocus = {
if (index + 1 < otpLength) focusRequesters[index + 1].requestFocus()
},
onPrevFocus = {
if (index - 1 >= 0) focusRequesters[index - 1].requestFocus()
},
onChange = onOtpChange
)
}
}
}
}
@Composable
private fun OtpBox(
index: Int,
otp: String,
scale: Float,
width: Dp,
focusRequester: FocusRequester,
onRequestFocus: () -> Unit,
onNextFocus: () -> Unit,
onPrevFocus: () -> Unit,
onChange: (String) -> Unit
) {
val boxH = 52f * scale
val radius = 16f * scale
val char = otp.getOrNull(index)?.toString() ?: ""
Box(
modifier = Modifier
.size(width, boxH.dp)
.shadow((4f * scale).dp, RoundedCornerShape(radius.dp))
.background(Color.White, RoundedCornerShape(radius.dp))
.clickable { onRequestFocus() },
contentAlignment = Alignment.Center
) {
BasicTextField(
value = char,
onValueChange = { new ->
when {
// DIGIT ENTERED
new.matches(Regex("\\d")) -> {
val updated = otp.padEnd(index + 1, ' ').toMutableList()
updated[index] = new.first()
onChange(updated.joinToString("").trim())
onNextFocus()
}
// BACKSPACE WHEN CHARACTER EXISTS
new.isEmpty() && char.isNotEmpty() -> {
val updated = otp.toMutableList()
updated.removeAt(index)
onChange(updated.joinToString(""))
}
}
},
modifier = Modifier
.focusRequester(focusRequester)
.onPreviewKeyEvent { event ->
if (event.type == KeyEventType.KeyDown &&
event.key == Key.Backspace &&
char.isEmpty() &&
index > 0
) {
val updated = otp.toMutableList()
updated.removeAt(index - 1) // 👈 clear previous box
onChange(updated.joinToString(""))
onPrevFocus()
true
}
else {
false
}
},
textStyle = LocalTextStyle.current.copy(
fontSize = (24f * scale).sp,
fontWeight = FontWeight.Medium,
textAlign = TextAlign.Center
),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.NumberPassword
),
singleLine = true
)
}
}

View File

@ -17,6 +17,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.example.livingai_lg.api.AuthApiClient import com.example.livingai_lg.api.AuthApiClient
import com.example.livingai_lg.api.AuthManager import com.example.livingai_lg.api.AuthManager
import com.example.livingai_lg.api.SignupRequest
import com.example.livingai_lg.api.TokenManager import com.example.livingai_lg.api.TokenManager
import com.example.livingai_lg.ui.components.backgrounds.DecorativeBackground import com.example.livingai_lg.ui.components.backgrounds.DecorativeBackground
import com.example.livingai_lg.ui.components.DropdownInput import com.example.livingai_lg.ui.components.DropdownInput
@ -52,7 +53,7 @@ private fun String.isValidPhoneNumber(): Boolean {
@Composable @Composable
fun SignUpScreen( fun SignUpScreen(
onSignInClick: () -> Unit = {}, onSignInClick: () -> Unit = {},
onSignUpClick: (phone: String, name: String) -> Unit = {_,_->} onSignUpClick: (phone: String, name: String, state: String, district: String, village: String) -> Unit = {_,_,_,_,_->}
) { ) {
var formData by remember { mutableStateOf(SignUpFormData()) } var formData by remember { mutableStateOf(SignUpFormData()) }
@ -168,9 +169,12 @@ fun SignUpScreen(
onClick = { onClick = {
val fullPhoneNumber = "+91${formData.phoneNumber}" val fullPhoneNumber = "+91${formData.phoneNumber}"
scope.launch { scope.launch {
// Request OTP first before allowing signup
authManager.requestOtp(fullPhoneNumber) authManager.requestOtp(fullPhoneNumber)
.onSuccess { .onSuccess {
onSignUpClick(fullPhoneNumber,formData.name) // OTP sent successfully, navigate to OTP screen with signup data
// Pass signup form data through the callback
onSignUpClick(fullPhoneNumber, formData.name, formData.state, formData.district, formData.village)
} }
.onFailure { .onFailure {
Toast.makeText(context, "Failed to send OTP: ${it.message}", Toast.LENGTH_LONG).show() Toast.makeText(context, "Failed to send OTP: ${it.message}", Toast.LENGTH_LONG).show()

View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<!-- Allow cleartext traffic for localhost/emulator (10.0.2.2) -->
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">10.0.2.2</domain>
<domain includeSubdomains="true">localhost</domain>
<domain includeSubdomains="true">127.0.0.1</domain>
</domain-config>
<!-- For production, use HTTPS only -->
<base-config cleartextTrafficPermitted="false">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
</network-security-config>