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
2 changes: 2 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ on:

env:
NDK_VERSION: "30.0.14904198"
INTEGRITY_BACKEND_URL: ${{ secrets.INTEGRITY_BACKEND_URL }}
CLOUD_PROJECT_NUMBER: ${{ secrets.CLOUD_PROJECT_NUMBER }}

jobs:
release:
Expand Down
25 changes: 25 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import com.android.build.api.dsl.ApplicationExtension
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import java.util.Properties

plugins {
alias(libs.plugins.android.application)
Expand All @@ -23,6 +24,21 @@ configure<ApplicationExtension> {
versionCode = 98
versionName = "0.9.8"

val localProperties = Properties().apply {
val localPropsFile = rootProject.file("local.properties")
if (localPropsFile.exists()) {
localPropsFile.inputStream().use { load(it) }
}
}

val integrityBackendUrl = System.getenv("INTEGRITY_BACKEND_URL")
?: localProperties.getProperty("integrity.backend.url") ?: ""
val cloudProjectNumber = System.getenv("CLOUD_PROJECT_NUMBER")?.toLongOrNull()
?: localProperties.getProperty("cloud.project.number")?.toLongOrNull() ?: 0L

buildConfigField("String", "INTEGRITY_BACKEND_URL", "\"$integrityBackendUrl\"")
buildConfigField("Long", "CLOUD_PROJECT_NUMBER", "${cloudProjectNumber}L")

testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

Expand Down Expand Up @@ -151,6 +167,15 @@ dependencies {
implementation(libs.kotlinx.serialization.json)
implementation(libs.mlkit.genai.prompt)

// Play Integrity
implementation(libs.play.integrity)

// Networking (for Play Integrity backend verification)
implementation(libs.retrofit)
implementation(libs.retrofit.kotlinx.serialization)
implementation(libs.okhttp)
implementation(libs.okhttp.logging)

testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
androidTestImplementation(libs.androidx.junit)
Expand Down
17 changes: 16 additions & 1 deletion app/proguard-rules.pro
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,19 @@
-keep class android.telephony.SmsMessage { *; }

# Keep SmsReceiver
-keep class dev.lanthoor.spendly.receivers.SmsReceiver { *; }
-keep class dev.lanthoor.spendly.receivers.SmsReceiver { *; }

# Play Integrity
-keep class com.google.android.play.core.integrity.** { *; }
-dontwarn com.google.android.play.core.integrity.**

# Retrofit + OkHttp
-keepattributes Signature, InnerClasses, EnclosingMethod
-keepattributes RuntimeVisibleAnnotations, RuntimeVisibleParameterAnnotations
-keepclassmembers,allowshrinking,allowobfuscation interface * {
@retrofit2.http.* <methods>;
}
-dontwarn javax.annotation.**
-dontwarn kotlin.Unit
-dontwarn retrofit2.KotlinJsonAdapterFactory
-keep class dev.lanthoor.spendly.data.remote.dto.** { *; }
61 changes: 36 additions & 25 deletions app/src/main/java/dev/lanthoor/spendly/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -37,22 +37,26 @@ import com.adamglin.phosphoricons.regular.Gear
import com.adamglin.phosphoricons.regular.ListBullets
import com.adamglin.phosphoricons.regular.Plus
import dagger.hilt.android.AndroidEntryPoint
import dev.lanthoor.spendly.domain.model.IntegrityVerdict
import dev.lanthoor.spendly.domain.repository.InitializationState
import dev.lanthoor.spendly.ui.components.AddTransactionBottomSheet
import dev.lanthoor.spendly.ui.components.LockScreen
import dev.lanthoor.spendly.ui.navigation.Screen
import dev.lanthoor.spendly.ui.navigation.SpendlyNavHost
import dev.lanthoor.spendly.ui.screens.IntegrityBlockScreen
import dev.lanthoor.spendly.ui.screens.SplashScreen
import dev.lanthoor.spendly.ui.screens.settings.SettingsViewModel
import dev.lanthoor.spendly.ui.theme.SpendlyTheme
import dev.lanthoor.spendly.ui.viewmodels.AppLockViewModel
import dev.lanthoor.spendly.ui.viewmodels.InitializationViewModel
import dev.lanthoor.spendly.ui.viewmodels.IntegrityViewModel
import dev.lanthoor.spendly.utils.BiometricAuthManager

@AndroidEntryPoint
class MainActivity : FragmentActivity() {
private val settingsViewModel: SettingsViewModel by viewModels()
private val appLockViewModel: AppLockViewModel by viewModels()
private val integrityViewModel: IntegrityViewModel by viewModels()
private val initializationViewModel: InitializationViewModel by viewModels()

// Notification permission launcher for Android 13+
Expand Down Expand Up @@ -81,40 +85,47 @@ class MainActivity : FragmentActivity() {

setContent {
val theme by settingsViewModel.theme.collectAsStateWithLifecycle()
val integrityVerdict by integrityViewModel.verdict.collectAsStateWithLifecycle()
val initializationState by initializationViewModel.initializationState.collectAsStateWithLifecycle()

SpendlyTheme(theme = theme) {
when (initializationState) {
is InitializationState.Loading -> {
// Show splash screen while initializing
SplashScreen(
initializationState = initializationState,
onRetry = { initializationViewModel.retry() }
when (integrityVerdict) {
is IntegrityVerdict.Red -> {
IntegrityBlockScreen(
reason = (integrityVerdict as IntegrityVerdict.Red).reason,
onRetry = { integrityViewModel.retry() }
)
}

is InitializationState.Error -> {
// Show splash screen with error and retry button
SplashScreen(
initializationState = initializationState,
onRetry = { initializationViewModel.retry() }
)
}
else -> {
when (initializationState) {
is InitializationState.Loading -> {
SplashScreen(
initializationState = initializationState,
onRetry = { initializationViewModel.retry() }
)
}

is InitializationState.Success -> {
// Initialization complete, show main app with optional lock overlay
val isLocked by appLockViewModel.isLocked.collectAsStateWithLifecycle()
is InitializationState.Error -> {
SplashScreen(
initializationState = initializationState,
onRetry = { initializationViewModel.retry() }
)
}

Box {
SpendlyApp()
is InitializationState.Success -> {
val isLocked by appLockViewModel.isLocked.collectAsStateWithLifecycle()

// Show lock screen overlay if app is locked
// (isLocked is already computed from isLockEnabled && shouldLock)
if (isLocked) {
LockScreen(
onAuthenticationSuccess = { appLockViewModel.unlock() },
biometricAuthManager = BiometricAuthManager(context = this@MainActivity)
)
Box {
SpendlyApp()

if (isLocked) {
LockScreen(
onAuthenticationSuccess = { appLockViewModel.unlock() },
biometricAuthManager = BiometricAuthManager(context = this@MainActivity)
)
}
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package dev.lanthoor.spendly.data.remote

import dev.lanthoor.spendly.data.remote.dto.IntegrityRequest
import dev.lanthoor.spendly.data.remote.dto.IntegrityResponseDto
import retrofit2.http.Body
import retrofit2.http.POST

interface BackendIntegrityApi {
@POST("api/v1/integrity/verify")
suspend fun verifyIntegrity(@Body request: IntegrityRequest): IntegrityResponseDto
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package dev.lanthoor.spendly.data.remote.dto

import kotlinx.serialization.Serializable

@Serializable
data class IntegrityRequest(
val token: String,
val packageName: String,
)

@Serializable
data class IntegrityResponseDto(
val deviceRecognitionVerdict: List<String>,
val accountDetails: AccountDetailsDto? = null,
val appRecognitionVerdict: String? = null,
)

@Serializable
data class AccountDetailsDto(
val appLicensingVerdict: String,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package dev.lanthoor.spendly.data.repository

import android.content.Context
import android.util.Base64
import com.google.android.play.core.integrity.IntegrityManagerFactory
import com.google.android.play.core.integrity.IntegrityTokenRequest
import com.google.android.play.core.integrity.IntegrityTokenResponse
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.Lazy
import dev.lanthoor.spendly.BuildConfig
import dev.lanthoor.spendly.data.remote.BackendIntegrityApi
import dev.lanthoor.spendly.data.remote.dto.IntegrityRequest
import dev.lanthoor.spendly.data.remote.dto.IntegrityResponseDto
import dev.lanthoor.spendly.domain.model.IntegrityVerdict
import dev.lanthoor.spendly.domain.repository.PlayIntegrityRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.security.SecureRandom
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException

@Singleton
class PlayIntegrityRepositoryImpl @Inject constructor(
@ApplicationContext private val context: Context,
private val backendApi: Lazy<BackendIntegrityApi>,
) : PlayIntegrityRepository {

override suspend fun checkIntegrity(): IntegrityVerdict = withContext(Dispatchers.IO) {
if (BuildConfig.DEBUG) return@withContext IntegrityVerdict.Green

val cloudProjectNumber = BuildConfig.CLOUD_PROJECT_NUMBER
val manager = IntegrityManagerFactory.create(context)

val nonceBytes = ByteArray(32).also { SecureRandom().nextBytes(it) }
val nonce = Base64.encodeToString(nonceBytes, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)

val request = IntegrityTokenRequest.builder()
.setCloudProjectNumber(cloudProjectNumber)
.setNonce(nonce)
.build()

val tokenResponse: IntegrityTokenResponse = suspendCancellableCoroutine { cont ->
manager.requestIntegrityToken(request)
.addOnSuccessListener { result -> cont.resume(result) }
.addOnFailureListener { exception -> cont.resumeWithException(exception) }
}
val token = tokenResponse.token()

val classicVerdict = tryClassicPath(token)
if (classicVerdict != null) return@withContext classicVerdict

parseLocalVerdict(token)
}

private suspend fun tryClassicPath(token: String): IntegrityVerdict? {
return try {
val response = backendApi.get().verifyIntegrity(
IntegrityRequest(token = token, packageName = context.packageName)
)
mapServerVerdict(response)
} catch (_: Exception) {
null
}
}

private fun mapServerVerdict(response: IntegrityResponseDto): IntegrityVerdict {
val deviceVerdicts = response.deviceRecognitionVerdict.toSet()
val appVerdict = response.appRecognitionVerdict

if (!deviceVerdicts.contains("MEETS_DEVICE_INTEGRITY") &&
!deviceVerdicts.contains("MEETS_BASIC_INTEGRITY")
) {
return IntegrityVerdict.Red("Device integrity check failed")
}

if (appVerdict != null && appVerdict != "PLAY_RECOGNIZED") {
return IntegrityVerdict.Red("App not recognized by Play Store")
}

return if (deviceVerdicts.contains("MEETS_DEVICE_INTEGRITY")) {
IntegrityVerdict.Green
} else {
IntegrityVerdict.Yellow
}
}

private fun parseLocalVerdict(token: String): IntegrityVerdict {
return try {
val parts = token.split(".")
if (parts.size < 2) return IntegrityVerdict.Unknown

val payload = String(Base64.decode(parts[1], Base64.URL_SAFE))
val json = JSONObject(payload)

val deviceVerdict = json.optJSONObject("deviceIntegrity")
?.optJSONArray("deviceRecognitionVerdict")
val deviceVerdicts = if (deviceVerdict != null) {
(0 until deviceVerdict.length()).map { deviceVerdict.getString(it) }.toSet()
} else {
emptySet()
}

val appVerdict = json.optJSONObject("appIntegrity")
?.optString("appRecognitionVerdict")
.takeIf { it?.isNotEmpty() == true }

if (appVerdict != null && appVerdict != "PLAY_RECOGNIZED") {
return IntegrityVerdict.Red("App not recognized by Play Store")
}

when {
deviceVerdicts.contains("MEETS_DEVICE_INTEGRITY") -> IntegrityVerdict.Green
deviceVerdicts.contains("MEETS_BASIC_INTEGRITY") -> IntegrityVerdict.Yellow
else -> IntegrityVerdict.Red("Device integrity check failed")
}
} catch (_: Exception) {
IntegrityVerdict.Unknown
}
}
}
20 changes: 20 additions & 0 deletions app/src/main/java/dev/lanthoor/spendly/di/IntegrityModule.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package dev.lanthoor.spendly.di

import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dev.lanthoor.spendly.data.repository.PlayIntegrityRepositoryImpl
import dev.lanthoor.spendly.domain.repository.PlayIntegrityRepository
import javax.inject.Singleton

@Module
@InstallIn(SingletonComponent::class)
abstract class IntegrityModule {

@Binds
@Singleton
abstract fun bindPlayIntegrityRepository(
impl: PlayIntegrityRepositoryImpl,
): PlayIntegrityRepository
}
Loading
Loading