Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -76,4 +76,4 @@ jobs:
force-avd-creation: false
emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
disable-animations: true
script: ./gradlew connectedAndroidTest
script: ./gradlew connectedAndroidTest --daemon && killall -INT crashpad_handler || true
5 changes: 1 addition & 4 deletions .idea/deploymentTargetSelector.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion .idea/misc.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 15 additions & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
}

android {
Expand All @@ -10,7 +11,7 @@ android {

defaultConfig {
applicationId = "com.notifier.app"
minSdk = 24
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "1.0"
Expand All @@ -19,8 +20,12 @@ android {
}

buildTypes {
debug {
buildConfigField("String", "BASE_URL", "\"https://api.github.com/\"")
}
release {
isMinifyEnabled = false
buildConfigField("String", "BASE_URL", "\"https://api.github.com/\"")
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
Expand All @@ -38,6 +43,7 @@ android {
}

buildFeatures {
buildConfig = true
compose = true
}
}
Expand All @@ -51,11 +57,19 @@ dependencies {
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
implementation(libs.bundles.ktor)

testImplementation(libs.junit)
testImplementation(libs.truth)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.ktor.client.mock)
testImplementation(libs.mockk)

androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.ui.test.junit4)

debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
}
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package com.notifier.app.core.data.networking

import io.ktor.client.HttpClient
import io.ktor.client.engine.HttpClientEngine
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.defaultRequest
import io.ktor.client.plugins.logging.ANDROID
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logger
import io.ktor.client.plugins.logging.Logging
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.contentType
import io.ktor.http.headers
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json

/**
* Factory object for creating an instance of [HttpClient] with predefined configurations.
*/
object HttpClientFactory {
/**
* Creates and configures an instance of [HttpClient] with logging, JSON serialization,
* and default request headers.
*
* @param engine The HTTP client engine to use for network requests.
* @return A configured instance of [HttpClient].
*/
fun create(engine: HttpClientEngine): HttpClient = HttpClient(engine) {
// Enable logging for network requests and responses
install(Logging) {
logger = Logger.ANDROID
level = LogLevel.ALL
}

// Configure request timeouts
install(HttpTimeout) {
requestTimeoutMillis = 30000 // 30 seconds
connectTimeoutMillis = 15000 // 15 seconds
socketTimeoutMillis = 15000 // 15 seconds
}

// Configure JSON serialization/deserialization
install(ContentNegotiation) {
json(
Json {
ignoreUnknownKeys = true
}
)
}

// Set default request headers and properties
defaultRequest {
contentType(ContentType.Application.Json)
}

// TODO: Inject the token once dagger-hilt is setup.
headers {
append(HttpHeaders.Authorization, "Bearer ")
append("X-GitHub-Api-Version", "2022-11-28")
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.notifier.app.core.data.networking

import com.notifier.app.BuildConfig

/**
* Constructs a complete URL by ensuring it is prefixed with the base URL.
*
* This function checks whether the provided [url] already contains the base URL.
* If not, it appends the base URL accordingly.
*
* @param url The relative or absolute URL to be processed.
* @return The fully constructed URL with the appropriate base URL.
*/
fun constructUrl(url: String): String {
return when {
// If the URL already contains the base URL, return it as is
url.contains(BuildConfig.BASE_URL) -> url

// If the URL starts with "/", append it to the base URL after removing the leading slash
url.startsWith("/") -> BuildConfig.BASE_URL + url.drop(1)

// If the URL is a relative path without a leading slash, append it directly to the base URL
else -> BuildConfig.BASE_URL + url
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.notifier.app.core.data.networking

import com.notifier.app.core.domain.util.NetworkError
import com.notifier.app.core.domain.util.Result
import io.ktor.client.call.body
import io.ktor.client.statement.HttpResponse
import io.ktor.serialization.JsonConvertException

/**
* Converts an [HttpResponse] to a [Result] object, handling different HTTP status codes
* and potential serialization errors.
*
* @param T The expected response type.
* @param response The [HttpResponse] received from the network request.
* @return A [Result] containing either the parsed response data or a [NetworkError].
*/
suspend inline fun <reified T> responseToResult(
response: HttpResponse,
): Result<T, NetworkError> {
return when (response.status.value) {
in 200..299 -> {
try {
Result.Success(response.body<T>())
} catch (e: JsonConvertException) {
e.printStackTrace()
Result.Error(NetworkError.SERIALIZATION)
}
}

408 -> Result.Error(NetworkError.REQUEST_TIMEOUT)
429 -> Result.Error(NetworkError.TOO_MANY_REQUESTS)
in 500..599 -> Result.Error(NetworkError.SERVER_ERROR)
else -> Result.Error(NetworkError.UNKNOWN)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.notifier.app.core.data.networking

import com.notifier.app.core.domain.util.NetworkError
import com.notifier.app.core.domain.util.Result
import io.ktor.client.statement.HttpResponse
import io.ktor.util.network.UnresolvedAddressException
import kotlinx.coroutines.ensureActive
import kotlinx.serialization.SerializationException
import kotlin.coroutines.coroutineContext

/**
* Executes a network call safely, handling exceptions and returning a [Result].
*
* This function wraps a network call inside a try-catch block to handle common errors such as
* internet unavailability, serialization issues, and unknown errors. It ensures that the coroutine
* remains active before returning a result.
*
* @param T The expected response type.
* @param execute A lambda function that performs the network request and returns an [HttpResponse].
* @return A [Result] containing either the successful response data or a [NetworkError].
*/
suspend inline fun <reified T> safeCall(
execute: () -> HttpResponse,
): Result<T, NetworkError> {
val response = try {
execute()
} catch (e: UnresolvedAddressException) {
return Result.Error(NetworkError.NO_INTERNET)
} catch (e: SerializationException) {
return Result.Error(NetworkError.SERIALIZATION)
} catch (e: Exception) {
coroutineContext.ensureActive()
return Result.Error(NetworkError.UNKNOWN)
}

return responseToResult(response)
}
Empty file.
10 changes: 10 additions & 0 deletions app/src/main/java/com/notifier/app/core/domain/util/Error.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.notifier.app.core.domain.util

/**
* A base interface for representing different types of errors in the application.
*
* Implementations of this interface should define specific error categories,
* such as network errors, validation errors, or business logic errors.
* This allows for a standardized approach to error handling throughout the application.
*/
interface Error
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.notifier.app.core.domain.util

/**
* Enum representing different types of network-related errors.
*/
enum class NetworkError : Error {
/** The request timed out before receiving a response. */
REQUEST_TIMEOUT,

/** Too many requests were sent in a short period (rate limiting). */
TOO_MANY_REQUESTS,

/** No internet connection is available. */
NO_INTERNET,

/** A server error occurred (HTTP 5xx status codes). */
SERVER_ERROR,

/** Serialization error occurred while parsing the response. */
SERIALIZATION,

/** An unknown error occurred. */
UNKNOWN,
}
91 changes: 91 additions & 0 deletions app/src/main/java/com/notifier/app/core/domain/util/Result.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package com.notifier.app.core.domain.util

/**
* A type alias representing an error type in the domain layer.
*
* This alias is used to standardize error handling throughout the domain logic.
*/
typealias DomainError = Error

/**
* A sealed interface representing the result of an operation that can either be successful
* or return an error.
*
* @param D The type of successful data.
* @param E The type of error, which must extend [Error].
*/
sealed interface Result<out D, out E : Error> {
/**
* Represents a successful operation result containing the expected data.
*
* @param data The successfully retrieved data.
*/
data class Success<out D>(val data: D) : Result<D, Nothing>

/**
* Represents an error result containing an error of type [E].
*
* @param error The error that occurred.
*/
data class Error<out E : DomainError>(val error: E) : Result<Nothing, E>
}

/**
* Transforms the successful data inside a [Result] using the given mapping function.
*
* @param map A function to transform the success data.
* @return A new [Result] with the transformed data if successful; otherwise, returns the original error.
*/
inline fun <T, E : Error, R> Result<T, E>.map(map: (T) -> R): Result<R, E> {
return when (this) {
is Result.Error -> Result.Error(error)
is Result.Success -> Result.Success(map(data))
}
}

/**
* Converts a [Result] into an [EmptyResult], which discards the success data but retains the error.
*
* @return A [Result] with `Unit` as its success type.
*/
fun <T, E : Error> Result<T, E>.asEmptyDataResult(): EmptyResult<E> {
return map { }
}

/**
* Executes the given action if the [Result] is successful.
*
* @param action A function to execute if the result is [Result.Success].
* @return The original [Result] to allow method chaining.
*/
inline fun <T, E : Error> Result<T, E>.onSuccess(action: (T) -> Unit): Result<T, E> {
return when (this) {
is Result.Error -> this
is Result.Success -> {
action(data)
this
}
}
}

/**
* Executes the given action if the [Result] contains an error.
*
* @param action A function to execute if the result is [Result.Error].
* @return The original [Result] to allow method chaining.
*/
inline fun <T, E : Error> Result<T, E>.onError(action: (E) -> Unit): Result<T, E> {
return when (this) {
is Result.Error -> {
action(error)
this
}

is Result.Success -> this
}
}

/**
* A type alias for a [Result] that contains no success data but may contain an error.
*/
typealias EmptyResult<E> = Result<Unit, E>
Empty file.
Loading