From 16f6cfc79b83b744a2e9bc4a282665fc9cd8954d Mon Sep 17 00:00:00 2001 From: Lallu Anthoor Date: Sun, 24 May 2026 12:00:47 +0530 Subject: [PATCH] feat(integrity): add Play Integrity API integration Hybrid approach: tries backend-verified classic path first, falls back to on-device standard request when offline. Blocks app on Red verdict (rooted device / sideloaded APK). Green/yellow/unknown verdicts proceed normally. Secrets priority: CI env vars -> local.properties -> safe defaults. All checks skipped in debug builds for frictionless local/AVD testing. Requires: GCP Cloud Function backend for token decryption. --- .github/workflows/release.yml | 2 + app/build.gradle.kts | 25 ++++ app/proguard-rules.pro | 17 ++- .../java/dev/lanthoor/spendly/MainActivity.kt | 61 +++++---- .../data/remote/BackendIntegrityApi.kt | 11 ++ .../spendly/data/remote/dto/IntegrityDto.kt | 21 +++ .../repository/PlayIntegrityRepositoryImpl.kt | 124 ++++++++++++++++++ .../lanthoor/spendly/di/IntegrityModule.kt | 20 +++ .../dev/lanthoor/spendly/di/NetworkModule.kt | 55 ++++++++ .../spendly/domain/model/IntegrityVerdict.kt | 8 ++ .../repository/PlayIntegrityRepository.kt | 7 + .../ui/screens/IntegrityBlockScreen.kt | 111 ++++++++++++++++ .../ui/viewmodels/IntegrityViewModel.kt | 44 +++++++ app/src/main/res/values/strings.xml | 6 + gradle/libs.versions.toml | 11 +- 15 files changed, 496 insertions(+), 27 deletions(-) create mode 100644 app/src/main/java/dev/lanthoor/spendly/data/remote/BackendIntegrityApi.kt create mode 100644 app/src/main/java/dev/lanthoor/spendly/data/remote/dto/IntegrityDto.kt create mode 100644 app/src/main/java/dev/lanthoor/spendly/data/repository/PlayIntegrityRepositoryImpl.kt create mode 100644 app/src/main/java/dev/lanthoor/spendly/di/IntegrityModule.kt create mode 100644 app/src/main/java/dev/lanthoor/spendly/di/NetworkModule.kt create mode 100644 app/src/main/java/dev/lanthoor/spendly/domain/model/IntegrityVerdict.kt create mode 100644 app/src/main/java/dev/lanthoor/spendly/domain/repository/PlayIntegrityRepository.kt create mode 100644 app/src/main/java/dev/lanthoor/spendly/ui/screens/IntegrityBlockScreen.kt create mode 100644 app/src/main/java/dev/lanthoor/spendly/ui/viewmodels/IntegrityViewModel.kt diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7f14862..871ede7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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: diff --git a/app/build.gradle.kts b/app/build.gradle.kts index bf5dc8b..bace7d6 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) @@ -23,6 +24,21 @@ configure { 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" } @@ -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) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index a99cef7..827d326 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -47,4 +47,19 @@ -keep class android.telephony.SmsMessage { *; } # Keep SmsReceiver --keep class dev.lanthoor.spendly.receivers.SmsReceiver { *; } \ No newline at end of file +-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.* ; +} +-dontwarn javax.annotation.** +-dontwarn kotlin.Unit +-dontwarn retrofit2.KotlinJsonAdapterFactory +-keep class dev.lanthoor.spendly.data.remote.dto.** { *; } \ No newline at end of file diff --git a/app/src/main/java/dev/lanthoor/spendly/MainActivity.kt b/app/src/main/java/dev/lanthoor/spendly/MainActivity.kt index b36eece..e055a43 100644 --- a/app/src/main/java/dev/lanthoor/spendly/MainActivity.kt +++ b/app/src/main/java/dev/lanthoor/spendly/MainActivity.kt @@ -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+ @@ -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) + ) + } + } } } } diff --git a/app/src/main/java/dev/lanthoor/spendly/data/remote/BackendIntegrityApi.kt b/app/src/main/java/dev/lanthoor/spendly/data/remote/BackendIntegrityApi.kt new file mode 100644 index 0000000..37bec89 --- /dev/null +++ b/app/src/main/java/dev/lanthoor/spendly/data/remote/BackendIntegrityApi.kt @@ -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 +} diff --git a/app/src/main/java/dev/lanthoor/spendly/data/remote/dto/IntegrityDto.kt b/app/src/main/java/dev/lanthoor/spendly/data/remote/dto/IntegrityDto.kt new file mode 100644 index 0000000..d10cb28 --- /dev/null +++ b/app/src/main/java/dev/lanthoor/spendly/data/remote/dto/IntegrityDto.kt @@ -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, + val accountDetails: AccountDetailsDto? = null, + val appRecognitionVerdict: String? = null, +) + +@Serializable +data class AccountDetailsDto( + val appLicensingVerdict: String, +) diff --git a/app/src/main/java/dev/lanthoor/spendly/data/repository/PlayIntegrityRepositoryImpl.kt b/app/src/main/java/dev/lanthoor/spendly/data/repository/PlayIntegrityRepositoryImpl.kt new file mode 100644 index 0000000..b80edf7 --- /dev/null +++ b/app/src/main/java/dev/lanthoor/spendly/data/repository/PlayIntegrityRepositoryImpl.kt @@ -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, +) : 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 + } + } +} diff --git a/app/src/main/java/dev/lanthoor/spendly/di/IntegrityModule.kt b/app/src/main/java/dev/lanthoor/spendly/di/IntegrityModule.kt new file mode 100644 index 0000000..4637f52 --- /dev/null +++ b/app/src/main/java/dev/lanthoor/spendly/di/IntegrityModule.kt @@ -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 +} diff --git a/app/src/main/java/dev/lanthoor/spendly/di/NetworkModule.kt b/app/src/main/java/dev/lanthoor/spendly/di/NetworkModule.kt new file mode 100644 index 0000000..52d805d --- /dev/null +++ b/app/src/main/java/dev/lanthoor/spendly/di/NetworkModule.kt @@ -0,0 +1,55 @@ +package dev.lanthoor.spendly.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dev.lanthoor.spendly.BuildConfig +import dev.lanthoor.spendly.data.remote.BackendIntegrityApi +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import retrofit2.converter.kotlinx.serialization.asConverterFactory +import java.util.concurrent.TimeUnit +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object NetworkModule { + + @Provides + @Singleton + fun provideOkHttpClient(): OkHttpClient { + return OkHttpClient.Builder() + .connectTimeout(5, TimeUnit.SECONDS) + .readTimeout(5, TimeUnit.SECONDS) + .addInterceptor( + HttpLoggingInterceptor().apply { + level = if (BuildConfig.DEBUG) { + HttpLoggingInterceptor.Level.BODY + } else { + HttpLoggingInterceptor.Level.NONE + } + } + ) + .build() + } + + @Provides + @Singleton + fun provideRetrofit(client: OkHttpClient): Retrofit { + return Retrofit.Builder() + .baseUrl(BuildConfig.INTEGRITY_BACKEND_URL) + .client(client) + .addConverterFactory(Json.asConverterFactory("application/json".toMediaType())) + .build() + } + + @Provides + @Singleton + fun provideBackendIntegrityApi(retrofit: Retrofit): BackendIntegrityApi { + return retrofit.create(BackendIntegrityApi::class.java) + } +} diff --git a/app/src/main/java/dev/lanthoor/spendly/domain/model/IntegrityVerdict.kt b/app/src/main/java/dev/lanthoor/spendly/domain/model/IntegrityVerdict.kt new file mode 100644 index 0000000..0cf6bfe --- /dev/null +++ b/app/src/main/java/dev/lanthoor/spendly/domain/model/IntegrityVerdict.kt @@ -0,0 +1,8 @@ +package dev.lanthoor.spendly.domain.model + +sealed class IntegrityVerdict { + data object Green : IntegrityVerdict() + data object Yellow : IntegrityVerdict() + data class Red(val reason: String) : IntegrityVerdict() + data object Unknown : IntegrityVerdict() +} diff --git a/app/src/main/java/dev/lanthoor/spendly/domain/repository/PlayIntegrityRepository.kt b/app/src/main/java/dev/lanthoor/spendly/domain/repository/PlayIntegrityRepository.kt new file mode 100644 index 0000000..1f06800 --- /dev/null +++ b/app/src/main/java/dev/lanthoor/spendly/domain/repository/PlayIntegrityRepository.kt @@ -0,0 +1,7 @@ +package dev.lanthoor.spendly.domain.repository + +import dev.lanthoor.spendly.domain.model.IntegrityVerdict + +interface PlayIntegrityRepository { + suspend fun checkIntegrity(): IntegrityVerdict +} diff --git a/app/src/main/java/dev/lanthoor/spendly/ui/screens/IntegrityBlockScreen.kt b/app/src/main/java/dev/lanthoor/spendly/ui/screens/IntegrityBlockScreen.kt new file mode 100644 index 0000000..ee3192d --- /dev/null +++ b/app/src/main/java/dev/lanthoor/spendly/ui/screens/IntegrityBlockScreen.kt @@ -0,0 +1,111 @@ +package dev.lanthoor.spendly.ui.screens + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.adamglin.PhosphorIcons +import com.adamglin.phosphoricons.Regular +import com.adamglin.phosphoricons.regular.ShieldWarning +import dev.lanthoor.spendly.R + +@Composable +fun IntegrityBlockScreen( + reason: String?, + onRetry: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxSize() + .padding(32.dp) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + imageVector = PhosphorIcons.Regular.ShieldWarning, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.error, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = stringResource(R.string.integrity_block_title), + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = stringResource(R.string.integrity_block_message), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + if (!reason.isNullOrBlank()) { + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = stringResource(R.string.integrity_block_error_label), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = reason, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.error, + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = stringResource(R.string.integrity_block_action), + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Button(onClick = onRetry) { + Text(stringResource(R.string.integrity_block_retry)) + } + } +} + +@Preview +@Composable +private fun IntegrityBlockScreenPreview() { + IntegrityBlockScreen( + reason = "Integrity API error (-2): The project number used in the API call is invalid.", + onRetry = {}, + ) +} diff --git a/app/src/main/java/dev/lanthoor/spendly/ui/viewmodels/IntegrityViewModel.kt b/app/src/main/java/dev/lanthoor/spendly/ui/viewmodels/IntegrityViewModel.kt new file mode 100644 index 0000000..75ade59 --- /dev/null +++ b/app/src/main/java/dev/lanthoor/spendly/ui/viewmodels/IntegrityViewModel.kt @@ -0,0 +1,44 @@ +package dev.lanthoor.spendly.ui.viewmodels + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import dev.lanthoor.spendly.domain.model.IntegrityVerdict +import dev.lanthoor.spendly.domain.repository.PlayIntegrityRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject + +@HiltViewModel +class IntegrityViewModel @Inject constructor( + private val repository: PlayIntegrityRepository, +) : ViewModel() { + + private val _verdict = MutableStateFlow(IntegrityVerdict.Unknown) + val verdict: StateFlow = _verdict.asStateFlow() + + init { + checkIntegrity() + } + + fun checkIntegrity() { + viewModelScope.launch { + _verdict.value = try { + withContext(Dispatchers.IO) { + repository.checkIntegrity() + } + } catch (e: Exception) { + IntegrityVerdict.Red(e.message ?: "Integrity check failed") + } + } + } + + fun retry() { + _verdict.value = IntegrityVerdict.Unknown + checkIntegrity() + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3482769..724d204 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -426,4 +426,10 @@ File size: %2$s Spending by Category Top %1$d No expense data available + + Security Check Failed + This device cannot run Spendly because it doesn\'t meet security requirements. This may be due to a rooted device, custom ROM, or the app being installed outside the Play Store. + Error details: + If you believe this is a mistake, try reinstalling the app from the Google Play Store. If the issue persists, contact support at support@lanthoor.dev. + Retry diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 70bc3fc..2a14f23 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,7 +8,7 @@ junitVersion = "1.3.0" espressoCore = "3.7.0" lifecycleRuntimeKtx = "2.10.0" activityCompose = "1.13.0" -composeBom = "2026.05.00" +composeBom = "2026.05.01" orchestrator = "1.6.1" fragment = "1.8.9" room = "2.8.4" @@ -28,6 +28,9 @@ biometric = "1.4.0-alpha07" kotlinxSerialization = "1.11.0" guava = "33.6.0-android" mlkitGenAiPrompt = "1.0.0-beta2" +play-integrity = "1.6.0" +retrofit = "3.0.0" +okhttp = "5.3.2" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -93,6 +96,12 @@ kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx- guava = { group = "com.google.guava", name = "guava", version.ref = "guava" } mlkit-genai-prompt = { group = "com.google.mlkit", name = "genai-prompt", version.ref = "mlkitGenAiPrompt" } +play-integrity = { module = "com.google.android.play:integrity", version.ref = "play-integrity" } +retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } +retrofit-kotlinx-serialization = { module = "com.squareup.retrofit2:converter-kotlinx-serialization", version.ref = "retrofit" } +okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } +okhttp-logging = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" } + [plugins] android-application = { id = "com.android.application", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp" }