Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

GOVUKAPP-1031: Previous searches #168

Merged
merged 24 commits into from
Jan 16, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
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
16 changes: 16 additions & 0 deletions feature/search/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ plugins {
alias(libs.plugins.compose)
alias(libs.plugins.hilt)
alias(libs.plugins.ksp)
alias(libs.plugins.realm)
alias(libs.plugins.kover)
}

Expand All @@ -30,6 +31,19 @@ android {
}
}

sonar {
properties {
property(
"sonar.coverage.exclusions",
properties["sonar.coverage.exclusions"].toString() + ",**/SearchEncryptionHelper.*,**/SearchRealmProvider.*"
)
property(
"sonar.cpd.exclusions",
properties["sonar.cpd.exclusions"].toString() + ",**/SearchEncryptionHelper.*,**/SearchRealmProvider.*"
)
}
}

dependencies {
implementation(projects.design)
implementation(projects.analytics)
Expand All @@ -41,9 +55,11 @@ dependencies {
implementation(libs.androidx.material3)
implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.hilt.android)
implementation(libs.androidx.datastore.preferences)

implementation(libs.retrofit)
implementation(libs.retrofit.gson)
implementation(libs.realm.base)

ksp(libs.hilt.compiler)

Expand Down
Original file line number Diff line number Diff line change
@@ -1,21 +1,26 @@
package uk.govuk.app.search

import uk.govuk.app.search.data.remote.model.Result
import uk.govuk.app.search.data.remote.model.SearchResult
import java.util.UUID

internal sealed class SearchUiState(
val uuid: UUID,
val searchTerm: String
) {
internal sealed class SearchUiState() {

internal class Default(
uuid: UUID,
searchTerm: String,
val searchResults: List<Result>
) : SearchUiState(uuid = uuid, searchTerm = searchTerm)
val previousSearches: List<String>
): SearchUiState()

internal class Results(
val searchTerm: String,
val searchResults: List<SearchResult>
) : SearchUiState()

internal class Empty(uuid: UUID, searchTerm: String) : SearchUiState(uuid = uuid, searchTerm = searchTerm)
internal sealed class Error(
val uuid: UUID
) : SearchUiState() {
internal class Empty(uuid: UUID, val searchTerm: String) : Error(uuid)

internal class Offline(uuid: UUID, searchTerm: String) : SearchUiState(uuid = uuid, searchTerm = searchTerm)
internal class Offline(uuid: UUID, val searchTerm: String) : Error(uuid)

internal class ServiceError(uuid: UUID, searchTerm: String) : SearchUiState(uuid = uuid, searchTerm = searchTerm)
internal class ServiceError(uuid: UUID) : Error(uuid)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import uk.govuk.app.analytics.AnalyticsClient
import uk.govuk.app.networking.domain.DeviceOfflineException
import uk.govuk.app.search.SearchUiState.*
import uk.govuk.app.search.data.SearchRepo
import uk.govuk.app.visited.Visited
import java.util.UUID
Expand All @@ -17,50 +18,59 @@ import javax.inject.Inject
internal class SearchViewModel @Inject constructor(
private val analyticsClient: AnalyticsClient,
private val visited: Visited,
private val repository: SearchRepo
private val searchRepo: SearchRepo
): ViewModel() {

companion object {
private const val SCREEN_CLASS = "SearchScreen"
private const val SCREEN_NAME = "Search"
private const val TITLE = "Search"
}

private val _uiState: MutableStateFlow<SearchUiState?> = MutableStateFlow(null)
val uiState = _uiState.asStateFlow()

init {
emitPreviousSearches()
}

fun onPageView() {
analyticsClient.screenView(
screenClass = SCREEN_CLASS,
screenName = SCREEN_NAME,
title = TITLE
)
}

private fun emitPreviousSearches() {
viewModelScope.launch {
_uiState.value = Default(searchRepo.fetchPreviousSearches())
}
}

private fun fetchSearchResults(searchTerm: String) {
viewModelScope.launch {
val id = UUID.randomUUID()
val searchResult = repository.performSearch(searchTerm)
val searchResult = searchRepo.performSearch(searchTerm.trim())
searchResult.onSuccess { result ->
if (result.results.isNotEmpty()) {
_uiState.value = SearchUiState.Default(
uuid = id,
_uiState.value = Results(
searchTerm = searchTerm,
searchResults = result.results
)
} else {
_uiState.value = SearchUiState.Empty(id, searchTerm)
_uiState.value = Error.Empty(id, searchTerm)
}
}
searchResult.onFailure { exception ->
_uiState.value = when (exception) {
is DeviceOfflineException -> SearchUiState.Offline(id, searchTerm)
else -> SearchUiState.ServiceError(id, searchTerm)
is DeviceOfflineException -> Error.Offline(id, searchTerm)
else -> Error.ServiceError(id)
}
}
}
}

companion object {
private const val SCREEN_CLASS = "SearchScreen"
private const val SCREEN_NAME = "Search"
private const val TITLE = "Search"
}

fun onPageView() {
analyticsClient.screenView(
screenClass = SCREEN_CLASS,
screenName = SCREEN_NAME,
title = TITLE
)
}

fun onSearch(searchTerm: String) {
fetchSearchResults(searchTerm)
analyticsClient.search(searchTerm)
Expand All @@ -74,6 +84,20 @@ internal class SearchViewModel @Inject constructor(
}

fun onClear() {
_uiState.value = null
emitPreviousSearches()
}

fun onRemoveAllPreviousSearches() {
viewModelScope.launch {
searchRepo.removeAllPreviousSearches()
}
emitPreviousSearches()
}

fun onRemovePreviousSearch(searchTerm: String) {
viewModelScope.launch {
searchRepo.removePreviousSearch(searchTerm)
}
emitPreviousSearches()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,42 @@ package uk.govuk.app.search.data

import uk.govuk.app.networking.domain.DeviceOfflineException
import uk.govuk.app.networking.domain.ServiceNotRespondingException
import uk.govuk.app.search.data.local.SearchLocalDataSource
import uk.govuk.app.search.data.remote.SearchApi
import uk.govuk.app.search.data.remote.model.SearchResponse
import uk.govuk.app.search.domain.SearchConfig
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class SearchRepo @Inject constructor(
private val searchApi: SearchApi
internal class SearchRepo @Inject constructor(
private val searchApi: SearchApi,
private val localDataSource: SearchLocalDataSource
) {

suspend fun fetchPreviousSearches(): List<String> {
return localDataSource.fetchPreviousSearches().map { it.searchTerm }
}

suspend fun removePreviousSearch(searchTerm: String) {
localDataSource.removePreviousSearch(searchTerm)
}

suspend fun removeAllPreviousSearches() {
localDataSource.removeAllPreviousSearches()
}

suspend fun performSearch(
searchTerm: String, count: Int = SearchConfig.DEFAULT_RESULTS_PER_PAGE
): Result<SearchResponse> {
localDataSource.insertOrUpdatePreviousSearch(searchTerm)

return try {
val response = searchApi.getSearchResults(searchTerm, count)
Result.success(response)
} catch (e: java.net.UnknownHostException) {
} catch (_: java.net.UnknownHostException) {
Result.failure(DeviceOfflineException())
} catch (e: retrofit2.HttpException) {
} catch (_: retrofit2.HttpException) {
Result.failure(ServiceNotRespondingException())
} catch (e: Exception) {
Result.failure(e)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package uk.govuk.app.search.data.local

import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.flow.firstOrNull
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
internal class SearchDataStore @Inject constructor(
private val dataStore: androidx.datastore.core.DataStore<androidx.datastore.preferences.core.Preferences>
) {

companion object {
internal const val REALM_SEARCH_KEY = "realm_search_key"
internal const val REALM_SEARCH_IV = "realm_search_iv"
}

internal suspend fun getRealmSearchKey(): String? {
return dataStore.data.firstOrNull()?.get(stringPreferencesKey(REALM_SEARCH_KEY))
}

internal suspend fun saveRealmSearchKey(key: String) {
dataStore.edit { preferences ->
preferences[stringPreferencesKey(REALM_SEARCH_KEY)] = key
}
}

internal suspend fun getRealmSearchIv(): String? {
return dataStore.data.firstOrNull()?.get(stringPreferencesKey(REALM_SEARCH_IV))
}

internal suspend fun saveRealmSearchIv(iv: String) {
dataStore.edit { preferences ->
preferences[stringPreferencesKey(REALM_SEARCH_IV)] = iv
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package uk.govuk.app.search.data.local

import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import java.security.KeyStore
import java.security.SecureRandom
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.SecretKeySpec
import javax.inject.Inject

internal class SearchEncryptionHelper @Inject constructor(
private val dataStore: SearchDataStore
) {

private companion object {
private const val KEYSTORE_KEY_ALIAS = "realm_search_key"
}

private val keyStore: KeyStore = KeyStore.getInstance("AndroidKeyStore")

init {
keyStore.load(null)
}

suspend fun getRealmKey(): ByteArray {
val encryptedRealmKey = dataStore.getRealmSearchKey()
val realmIv = dataStore.getRealmSearchIv()

return if (encryptedRealmKey != null && realmIv != null &&
keyStore.containsAlias(KEYSTORE_KEY_ALIAS)
) {
decryptRealmKey(
encryptedKeyString = encryptedRealmKey,
ivString = realmIv
).encoded
} else {
val keystoreKey = createKeystoreKey()
val realmEncryptionKey = createRealmEncryptionKey()
encryptAndSaveRealmKey(realmEncryptionKey, keystoreKey)
realmEncryptionKey
}
}

private fun decryptRealmKey(
encryptedKeyString: String,
ivString: String
): SecretKey {
val encryptedKey = Base64.decode(encryptedKeyString, Base64.DEFAULT)
val iv = Base64.decode(ivString, Base64.DEFAULT)

val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.DECRYPT_MODE, getKeystoreKey(), GCMParameterSpec(128, iv)) // Using the stored IV

val decryptedKeyBytes = cipher.doFinal(encryptedKey)
return SecretKeySpec(decryptedKeyBytes, "AES") // Convert to SecretKey
}

private fun getKeystoreKey(): SecretKey {
val secretKeyEntry = keyStore.getEntry(KEYSTORE_KEY_ALIAS, null) as KeyStore.SecretKeyEntry
return secretKeyEntry.secretKey
}

private fun createKeystoreKey(): SecretKey {
val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
val keyGenParameterSpec = KeyGenParameterSpec.Builder(
KEYSTORE_KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(256) // Generate a 256-bit key
.build()

keyGenerator.init(keyGenParameterSpec)
return keyGenerator.generateKey()
}

private fun createRealmEncryptionKey(): ByteArray {
val key = ByteArray(64) // 256 bits = 32 bytes
val secureRandom = SecureRandom()
secureRandom.nextBytes(key) // Fill the byte array with random bytes
return key
}

private suspend fun encryptAndSaveRealmKey(realmKey: ByteArray, keystoreKey: SecretKey) {
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, keystoreKey)

val iv = cipher.iv
val encryptedKey = cipher.doFinal(realmKey)

val encodedKey = Base64.encodeToString(encryptedKey, Base64.DEFAULT)
val encodedIv = Base64.encodeToString(iv, Base64.DEFAULT)

dataStore.saveRealmSearchKey(encodedKey)
dataStore.saveRealmSearchIv(encodedIv)
}
}
Loading
Loading