Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,13 @@ android {
}

buildTypes {
debug {
// Emulator ile local backend geliştirme
buildConfigField 'String', 'API_BASE_URL', '"http://10.0.2.2:3000/api"'
}
release {
// Demo günü Railway/public backend URL buraya gelecek
buildConfigField 'String', 'API_BASE_URL', '"https://YOUR-RAILWAY-DOMAIN/api"'
minifyEnabled false
proguardFiles(
getDefaultProguardFile('proguard-android-optimize.txt'),
Expand All @@ -38,6 +44,7 @@ android {

buildFeatures {
compose true
buildConfig true
}

packaging {
Expand All @@ -50,7 +57,6 @@ android {
dependencies {
def composeBom = platform('androidx.compose:compose-bom:2026.03.00')

implementation 'androidx.navigation:navigation-compose:2.9.7'
implementation composeBom
androidTestImplementation composeBom

Expand Down
3 changes: 3 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

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

<application
android:allowBackup="true"
android:label="@string/app_name"
android:usesCleartextTraffic="true"
android:supportsRtl="true"
android:theme="@style/Theme.Neph">

Expand Down
10 changes: 9 additions & 1 deletion android/app/src/main/java/com/neph/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,17 @@ import androidx.activity.compose.setContent
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import androidx.navigation.compose.rememberNavController
import com.neph.features.auth.data.AuthSessionStore
import com.neph.features.profile.data.ProfileRepository
import com.neph.navigation.AppNavGraph
import com.neph.navigation.Routes
import com.neph.ui.theme.NephTheme

class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
AuthSessionStore.initialize(applicationContext)
ProfileRepository.initialize(applicationContext)
setContent {
NephApp()
}
Expand All @@ -25,7 +29,11 @@ fun NephApp() {
val navController = rememberNavController()
AppNavGraph(
navController = navController,
startDestination = Routes.Welcome.route
startDestination = if (AuthSessionStore.getAccessToken().isNullOrBlank()) {
Routes.Welcome.route
} else {
Routes.Profile.route
}
)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.neph.core.network

class ApiException(
override val message: String,
val status: Int,
val code: String? = null
) : Exception(message)
130 changes: 130 additions & 0 deletions android/app/src/main/java/com/neph/core/network/JsonHttpClient.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
package com.neph.core.network

import com.neph.BuildConfig
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONException
import org.json.JSONObject
import java.io.IOException
import java.net.HttpURLConnection
import java.net.URL
import java.net.UnknownHostException

object JsonHttpClient {
private const val ConnectTimeoutMillis = 15_000
private const val ReadTimeoutMillis = 20_000

suspend fun request(
path: String,
method: String = "GET",
body: JSONObject? = null,
token: String? = null
): JSONObject = withContext(Dispatchers.IO) {
val connection = (URL(buildUrl(path)).openConnection() as HttpURLConnection).apply {
requestMethod = method
connectTimeout = ConnectTimeoutMillis
readTimeout = ReadTimeoutMillis
doInput = true
setRequestProperty("Accept", "application/json")

if (!token.isNullOrBlank()) {
setRequestProperty("Authorization", "Bearer ${token.trim()}")
}

if (body != null) {
doOutput = true
setRequestProperty("Content-Type", "application/json")
}
}

try {
if (body != null) {
connection.outputStream.use { output ->
output.write(body.toString().toByteArray(Charsets.UTF_8))
}
}

val status = connection.responseCode
val raw = readText(
if (status in 200..299) connection.inputStream else connection.errorStream
)

if (status !in 200..299) {
throw buildApiException(status, raw)
}

if (raw.isBlank()) {
return@withContext JSONObject()
}

try {
JSONObject(raw)
} catch (_: JSONException) {
throw ApiException(
message = "Unexpected server response.",
status = status,
code = "INVALID_RESPONSE"
)
}
} catch (error: ApiException) {
throw error
} catch (_: UnknownHostException) {
throw ApiException(
message = "Could not reach the server. Please check your connection and try again.",
status = 0,
code = "NETWORK_ERROR"
)
} catch (_: IOException) {
throw ApiException(
message = "Could not reach the server. Please check your connection and try again.",
status = 0,
code = "NETWORK_ERROR"
)
} finally {
connection.disconnect()
}
}

private fun buildUrl(path: String): String {
val normalizedBase = BuildConfig.API_BASE_URL.removeSuffix("/")
val normalizedPath = if (path.startsWith("/")) path else "/$path"
return "$normalizedBase$normalizedPath"
}

private fun readText(stream: java.io.InputStream?): String {
if (stream == null) {
return ""
}

return stream.bufferedReader(Charsets.UTF_8).use { it.readText() }
}

private fun buildApiException(status: Int, raw: String): ApiException {
if (raw.isBlank()) {
return ApiException(message = "Request failed.", status = status)
}

return try {
val json = JSONObject(raw)
ApiException(
message = extractErrorMessage(json),
status = status,
code = json.optString("code").takeIf { it.isNotBlank() }
)
} catch (_: JSONException) {
ApiException(message = raw.trim(), status = status)
}
}

private fun extractErrorMessage(json: JSONObject): String {
val directKeys = listOf("message", "detail", "error")
for (key in directKeys) {
val value = json.optString(key)
if (value.isNotBlank()) {
return value
}
}

return "Request failed."
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
package com.neph.features.auth.data

import com.neph.core.network.ApiException
import com.neph.core.network.JsonHttpClient
import com.neph.features.profile.data.ProfileData
import com.neph.features.profile.data.ProfileRepository
import org.json.JSONObject
import java.net.URLEncoder
import kotlinx.coroutines.CancellationException

enum class LoginDestination {
PROFILE,
COMPLETE_PROFILE
}

object AuthRepository {
suspend fun signup(
email: String,
password: String,
acceptedTerms: Boolean
): String {
val normalizedEmail = email.trim()
val response = JsonHttpClient.request(
path = "/auth/signup",
method = "POST",
body = JSONObject()
.put("email", normalizedEmail)
.put("password", password)
.put("acceptedTerms", acceptedTerms)
)

AuthSessionStore.setPendingVerificationEmail(normalizedEmail)
ProfileRepository.clearProfile()
ProfileRepository.saveProfile(
ProfileData(
email = normalizedEmail
)
)

return response.optString("message").ifBlank {
"Account created successfully. Please verify your email."
}
}

suspend fun login(
email: String,
password: String,
rememberMe: Boolean
): LoginDestination {
val normalizedEmail = email.trim()
val response = JsonHttpClient.request(
path = "/auth/login",
method = "POST",
body = JSONObject()
.put("email", normalizedEmail)
.put("password", password)
)

val accessToken = response.optString("accessToken")
if (accessToken.isBlank()) {
throw ApiException(
message = "Login succeeded but no access token was returned.",
status = 200,
code = "INVALID_RESPONSE"
)
}

val user = response.optJSONObject("user")
val userEmail = user?.optString("email")?.ifBlank { normalizedEmail } ?: normalizedEmail

AuthSessionStore.saveAccessToken(accessToken, rememberMe)
ProfileRepository.clearProfile()
ProfileRepository.saveProfile(
ProfileData(email = userEmail)
)

return try {
ProfileRepository.fetchAndCacheRemoteProfile()
AuthSessionStore.clearPendingVerificationEmail()
LoginDestination.PROFILE
} catch (cancellationException: CancellationException) {
AuthSessionStore.clearAccessToken()
ProfileRepository.clearProfile()
throw cancellationException
} catch (error: ApiException) {
when (error.status) {
404 -> {
AuthSessionStore.clearPendingVerificationEmail()
LoginDestination.COMPLETE_PROFILE
}
401 -> {
AuthSessionStore.clearAccessToken()
ProfileRepository.clearProfile()
throw error
}
else -> {
AuthSessionStore.clearAccessToken()
ProfileRepository.clearProfile()
throw error
}
}
} catch (error: Exception) {
AuthSessionStore.clearAccessToken()
ProfileRepository.clearProfile()
throw error
}
}

suspend fun verifyEmail(tokenOrLink: String): String {
val token = extractVerificationToken(tokenOrLink)
val encodedToken = URLEncoder.encode(token, Charsets.UTF_8.name())

val response = JsonHttpClient.request(
path = "/auth/verify-email?token=$encodedToken"
)

AuthSessionStore.clearPendingVerificationEmail()
return response.optString("message").ifBlank { "Email verified successfully." }
}

suspend fun resendVerification(): String {
val email = AuthSessionStore.getPendingVerificationEmail()
?: throw ApiException(
message = "No signup email is available. Please sign up again or log in after verifying from your email.",
status = 400,
code = "MISSING_EMAIL"
)

val response = JsonHttpClient.request(
path = "/auth/resend-verification",
method = "POST",
body = JSONObject().put("email", email)
)

return response.optString("message").ifBlank {
"Verification email sent. Please check your inbox."
}
}

fun logout() {
AuthSessionStore.clearAccessToken()
AuthSessionStore.clearPendingVerificationEmail()
ProfileRepository.clearProfile()
}

private fun extractVerificationToken(tokenOrLink: String): String {
val trimmed = tokenOrLink.trim()
if (trimmed.isBlank()) {
throw ApiException(
message = "Paste the verification link or token from your email.",
status = 400,
code = "VALIDATION_ERROR"
)
}

val tokenPrefix = "token="
val tokenIndex = trimmed.indexOf(tokenPrefix)
if (tokenIndex >= 0) {
return trimmed.substring(tokenIndex + tokenPrefix.length)
.substringBefore('&')
.trim()
}

return trimmed
}
}
Loading