Skip to content
Closed
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
8 changes: 8 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,14 @@ detekt {
}

dependencies {
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.google.gson)

// Orbit MVI
implementation(libs.orbit.viewmodel)
implementation(libs.orbit.compose)
testImplementation(libs.orbit.test)

//core
implementation(libs.androidx.ktx.core)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
package com.fingerprintjs.android.fpjs_pro_demo.di

import com.fingerprintjs.android.fpjs_pro_demo.ui.screens.drn.DrnViewModel
import com.fingerprintjs.android.fpjs_pro_demo.ui.screens.home.viewmodel.HomeViewModel
import com.fingerprintjs.android.fpjs_pro_demo.ui.screens.settings.details.SettingsDetailsViewModel
import com.fingerprintjs.android.fpjs_pro_demo.ui.screens.settings.main.SettingsViewModel

interface ViewModelProvidingComponent {
val homeViewModel: HomeViewModel
val drnViewModel: DrnViewModel
val settingsDetailsViewModel: SettingsDetailsViewModel
val settingsViewModel: SettingsViewModel
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package com.fingerprintjs.android.fpjs_pro_demo.di.modules

import android.content.Context
import com.fingerprintjs.android.fpjs_pro_demo.di.AppScope
import com.fingerprintjs.android.fpjs_pro_demo.ui.component.view.flag.Country
import com.fingerprintjs.android.fpjs_pro_demo.ui.component.view.flag.FlagSpriteManager
import dagger.Module
import dagger.Provides
import okhttp3.OkHttpClient
Expand Down Expand Up @@ -29,4 +32,12 @@ class AppModule {
.readTimeout(timeout, timeUnit)
.build()
}

@Provides
@AppScope
fun provideFlagSpriteManager(context: Context): FlagSpriteManager =
FlagSpriteManager(
context = context,
flagsCount = Country.count
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package com.fingerprintjs.android.fpjs_pro_demo.domain.drn

import com.google.gson.annotations.SerializedName
import java.util.Date

data class DrnData(
@SerializedName("data")
val data: Drn
)

data class Drn(
@SerializedName("regionalActivity")
val regionalActivity: RegionalActivity,

@SerializedName("suspectScore")
val suspectScore: SuspectScore,

@SerializedName("timestamps")
val timestamps: Timestamps,
) {
data class RegionalActivity(
@SerializedName("startDate")
val startDate: Date,

@SerializedName("endDate")
val endDate: Date,

@SerializedName("countries")
val countries: List<Country>,
) {
data class Country(
@SerializedName("code")
val code: String,

@SerializedName("detectors")
val detectors: List<Detector>
) {
data class Detector(
@SerializedName("type")
val type: Type,

@SerializedName("activityPercentage")
val activityPercentage: Float
) {
enum class Type {
@SerializedName("origin")
ORIGIN,

@SerializedName("ip")
IP
}
}
}
}

data class SuspectScore(
@SerializedName("startDate")
val startDate: Date,

@SerializedName("endDate")
val endDate: Date,

@SerializedName("minimum")
val minimum: Minimum,

@SerializedName("maximum")
val maximum: Maximum,
) {
sealed class Signal {
data class Vpn(
@SerializedName("timezoneMismatch")
val timezoneMismatch: Int? = null,

@SerializedName("publicVPN")
val publicVpn: Int? = null,

@SerializedName("auxiliaryMobile")
val auxiliaryMobile: Int? = null,
) : Signal()

data class IpBLocklist(
@SerializedName("emailSpam")
val emailSpam: Int? = null,

@SerializedName("attackSource")
val attackSource: Int? = null,
) : Signal()

data class SimpleSignal(
val key: String,
val value: Int,
) : Signal()
}

data class Minimum(
val value: Int,
val signals: List<Signal>,
)

data class Maximum(
val percentile: Float,
val value: Int,
val signals: List<Signal>,
)
}

data class Timestamps(
@SerializedName("firstSeenAt")
val firstSeenAt: Date,

@SerializedName("lastSeenAt")
val lastSeenAt: Date,
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.fingerprintjs.android.fpjs_pro_demo.domain.drn

import java.io.IOException

sealed interface DrnError {
data object EndpointInfoNotSetInApp : DrnError
data class NetworkError(val cause: IOException) : DrnError
data object ParseError : DrnError
data object Unknown : DrnError

sealed interface ApiError : DrnError {
data object VisitorNotFound : ApiError
data object UnknownApiError : ApiError
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
package com.fingerprintjs.android.fpjs_pro_demo.domain.drn

import com.fingerprintjs.android.fpjs_pro_demo.network.HttpClient.Error
import com.fingerprintjs.android.fpjs_pro_demo.network.HttpClient.Response
import com.github.michaelbull.result.Err
import com.github.michaelbull.result.Result
import com.github.michaelbull.result.andThen
import com.github.michaelbull.result.mapError
import com.github.michaelbull.result.runCatching
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.google.gson.JsonDeserializationContext
import com.google.gson.JsonDeserializer
import com.google.gson.JsonElement
import com.google.gson.reflect.TypeToken
import java.lang.reflect.Type


private const val DATE_FORMAT = "yyyy-MM-dd'T'HH:mm:ss'Z'"
private const val CODE_VISITOR_ID_NOT_FOUND = 404

private val gson: Gson = GsonBuilder()
.setDateFormat(DATE_FORMAT)
.registerTypeAdapter(
object : TypeToken<Drn.SuspectScore.Minimum>() {}.type,
MinimumDeserializer()
)
.registerTypeAdapter(
object : TypeToken<Drn.SuspectScore.Maximum>() {}.type,
MaximumDeserializer()
)
.create()

private fun parseBody(body: String): Result<Drn, Unit> {
return runCatching {
gson.fromJson(body, DrnData::class.java).data
}.mapError { }
}

fun Result<Response, Error<*>>.parseDRN(): DrnResponse =
mapError {
when (it) {
is Error.IO -> DrnError.NetworkError(cause = it.cause)
is Error.Unknown -> DrnError.Unknown
}
}.andThen {
if (it.isSuccessful) {
val body = it.body
if (body == null) {
Err(DrnError.ParseError)
} else {
parseBody(body).mapError { DrnError.ParseError }
}
} else {
if (it.code == CODE_VISITOR_ID_NOT_FOUND) {
Err(DrnError.ApiError.VisitorNotFound)
} else {
Err(DrnError.ApiError.UnknownApiError)
}
}
}

class MinimumDeserializer : JsonDeserializer<Drn.SuspectScore.Minimum> {
override fun deserialize(
json: JsonElement?,
typeOfT: Type?,
context: JsonDeserializationContext?
): Drn.SuspectScore.Minimum {
val jsonObject = json?.asJsonObject
?: throw NullPointerException("MinimumDeserializer cannot parse null object")
if (context == null) {
throw NullPointerException("context cannot be null")
}

val value = jsonObject.get("value").asInt
val signals: List<Drn.SuspectScore.Signal> = SignalListDeserializer().deserialize(
json = jsonObject.get("signals"),
typeOfT = List::class.java,
context = context
)

return Drn.SuspectScore.Minimum(value, signals)
}
}

class MaximumDeserializer : JsonDeserializer<Drn.SuspectScore.Maximum> {
override fun deserialize(
json: JsonElement?,
typeOfT: Type?,
context: JsonDeserializationContext?
): Drn.SuspectScore.Maximum {
val jsonObject = json?.asJsonObject
?: throw NullPointerException("MinimumDeserializer cannot parse null object")
if (context == null) {
throw NullPointerException("context cannot be null")
}

val value = jsonObject.get("value").asInt
val percentile = jsonObject.get("percentile").asFloat
val signals: List<Drn.SuspectScore.Signal> = SignalListDeserializer().deserialize(
json = jsonObject.get("signals"),
typeOfT = List::class.java,
context = context
)

return Drn.SuspectScore.Maximum(percentile, value, signals)
}
}

class SignalListDeserializer : JsonDeserializer<List<Drn.SuspectScore.Signal>> {
override fun deserialize(
json: JsonElement?,
typeOfT: Type?,
context: JsonDeserializationContext?
): List<Drn.SuspectScore.Signal> {
val jsonObject = json?.asJsonObject
?: throw NullPointerException("SignalListAdapter cannot parse null object")
if (context == null) {
throw NullPointerException("context cannot be null")
}

return jsonObject.keySet().map { key ->
when (key) {
"vpn" -> context.deserialize(
jsonObject.get("vpn"),
Drn.SuspectScore.Signal.Vpn::class.java
) as Drn.SuspectScore.Signal.Vpn

"ipBLocklist" -> context.deserialize(
jsonObject.get("ipBLocklist"),
Drn.SuspectScore.Signal.IpBLocklist::class.java
) as Drn.SuspectScore.Signal.IpBLocklist

else -> Drn.SuspectScore.Signal.SimpleSignal(
key = key,
value = jsonObject.get(key).asInt
)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package com.fingerprintjs.android.fpjs_pro_demo.domain.drn

import androidx.core.net.toUri
import com.fingerprintjs.android.fpjs_pro_demo.domain.custom_api_keys.CustomApiKeysUseCase
import com.fingerprintjs.android.fpjs_pro_demo.domain.identification.IdentificationProvider
import com.fingerprintjs.android.fpjs_pro_demo.network.HttpClient
import com.github.michaelbull.result.Err
import com.github.michaelbull.result.flatMap
import com.github.michaelbull.result.mapError
import kotlinx.coroutines.flow.first
import javax.inject.Inject

class DrnProvider @Inject constructor(
private val httpClient: HttpClient,
private val customApiKeysUseCase: CustomApiKeysUseCase,
private val identificationProvider: IdentificationProvider,
) {
suspend fun getDrn(): DrnResponse {
val keys = customApiKeysUseCase.state.first()
return if (!keys.enabled) {
Err(DrnError.EndpointInfoNotSetInApp)
} else {
identificationProvider.getVisitorId()
.mapError { DrnError.Unknown }
.flatMap { getDrn(it.visitorId, keys.secret) }
}
}

private suspend fun getDrn(visitorId: String, secret: String): DrnResponse {
val headers = mutableMapOf<String, String>()
val url = URL.format(visitorId).toUri().toString()
headers[HEADER_AUTHORIZATION] = BEARER.format(secret)
headers[HEADER_API_VERSION] = API_VERSION

return httpClient.request(
url = url,
headers = headers,
).parseDRN()
}

companion object {
private const val URL =
"https://drn-api.fpjs.io/drn/%s?signals=regional_activity,suspect_score,timestamps"
private const val HEADER_AUTHORIZATION = "Authorization"
private const val HEADER_API_VERSION = "X-API-Version"
private const val BEARER = "Bearer %s"
private const val API_VERSION = "2024-09-01"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.fingerprintjs.android.fpjs_pro_demo.domain.drn

import com.github.michaelbull.result.Result

typealias DrnResponse = Result<Drn, DrnError>
Loading