Merge branch 'simple_sub_system' into 'master'

Simple sub system

See merge request ti/2023-2024/s4/mobile-security/students/joren-schipman/pokesearch!3
This commit is contained in:
Schipman Joren 2024-04-30 10:00:48 +00:00
commit 6b7cd89f86
14 changed files with 284 additions and 52 deletions

View File

@ -1,11 +1,11 @@
package com.ti.mobpo package com.ti.mobpo
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.createSavedStateHandle
import androidx.lifecycle.viewmodel.CreationExtras import androidx.lifecycle.viewmodel.CreationExtras
import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory import androidx.lifecycle.viewmodel.viewModelFactory
import com.ti.mobpo.ui.pokesearch.PokeSearchViewModel import com.ti.mobpo.ui.viewmodels.FavouritesViewModel
import com.ti.mobpo.ui.viewmodels.PokeSearchViewModel
object AppViewModelProvider { object AppViewModelProvider {
val Factory = viewModelFactory { val Factory = viewModelFactory {
@ -13,9 +13,17 @@ object AppViewModelProvider {
PokeSearchViewModel( PokeSearchViewModel(
pokesearchApplication().appContainer.favouritesRepository pokesearchApplication().appContainer.favouritesRepository
) )
}
initializer {
FavouritesViewModel(
pokesearchApplication().appContainer.favouritesRepository, pokesearchApplication().featureManager
)
} }
} }
} }
fun CreationExtras.pokesearchApplication(): PokeSearch = fun CreationExtras.pokesearchApplication(): PokeSearch =
(this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as PokeSearch) (this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as PokeSearch)

View File

@ -3,17 +3,26 @@ package com.ti.mobpo
import android.app.Application import android.app.Application
import com.ti.mobpo.data.AppContainer import com.ti.mobpo.data.AppContainer
import com.ti.mobpo.data.AppDataContainer import com.ti.mobpo.data.AppDataContainer
import com.ti.mobpo.ui.util.FeatureManager
class PokeSearch : Application() { class PokeSearch : Application() {
lateinit var appContainer: AppContainer lateinit var appContainer: AppContainer
private set private set
lateinit var featureManager: FeatureManager
private set
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
appContainer = createAppContainer() appContainer = createAppContainer()
featureManager = createFeatrureManager()
} }
private fun createAppContainer(): AppContainer { private fun createAppContainer(): AppContainer {
return AppDataContainer(this) return AppDataContainer(this)
} }
private fun createFeatrureManager(): FeatureManager {
return FeatureManager(this)
}
} }

View File

@ -2,8 +2,6 @@ package com.ti.mobpo.data
import androidx.room.Entity import androidx.room.Entity
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import com.ti.mobpo.model.Sprites
import com.ti.mobpo.model.Type
@Entity(tableName = "favourites") @Entity(tableName = "favourites")
data class Favourite( data class Favourite(

View File

@ -14,9 +14,8 @@ interface FavouriteDao {
@Delete @Delete
suspend fun delete(favourite: Favourite) suspend fun delete(favourite: Favourite)
@Query("SELECT * from favourites ORDER BY id ASC")
@Query("SELECT id FROM favourites") fun getAllItems(): Flow<List<Favourite>>
fun getAllFavoriteIds(): Flow<List<Int>>
@Query("SELECT EXISTS(SELECT 1 FROM favourites WHERE id = :id LIMIT 1)") @Query("SELECT EXISTS(SELECT 1 FROM favourites WHERE id = :id LIMIT 1)")
suspend fun isFavourite(id: Int): Boolean suspend fun isFavourite(id: Int): Boolean

View File

@ -9,7 +9,7 @@ interface FavouritesRepository {
/** /**
* Retrieve all the items from the the given data source. * Retrieve all the items from the the given data source.
*/ */
fun getAllFavoriteIds(): Flow<List<Int>> fun getAllItems(): Flow<List<Favourite>>
/** /**
* Insert item in the data source * Insert item in the data source

View File

@ -3,8 +3,8 @@ package com.ti.mobpo.data
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
class OfflineFavouritesRepository(private val favouriteDao: FavouriteDao) : FavouritesRepository { class OfflineFavouritesRepository(private val favouriteDao: FavouriteDao) : FavouritesRepository {
override fun getAllFavoriteIds(): Flow<List<Int>> { override fun getAllItems(): Flow<List<Favourite>> {
return favouriteDao.getAllFavoriteIds() return favouriteDao.getAllItems()
} }
override suspend fun insertItem(item: Favourite) { override suspend fun insertItem(item: Favourite) {
favouriteDao.insert(item) favouriteDao.insert(item)

View File

@ -0,0 +1,5 @@
package com.ti.mobpo.model
import com.ti.mobpo.data.Favourite
data class FavouriteUiState(val favourites: List<Favourite> = listOf())

View File

@ -20,7 +20,6 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@ -31,8 +30,11 @@ import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import com.ti.mobpo.ui.screens.Favourites import com.ti.mobpo.AppViewModelProvider
import com.ti.mobpo.ui.screens.FavoritesScreen
import com.ti.mobpo.ui.screens.PokeSearchScreen import com.ti.mobpo.ui.screens.PokeSearchScreen
import com.ti.mobpo.ui.viewmodels.FavouritesViewModel
import com.ti.mobpo.ui.viewmodels.PokeSearchViewModel
@Composable @Composable
fun Navigation() { fun Navigation() {
@ -61,6 +63,9 @@ fun Navigation() {
val updatedSelectedIndex = items.indexOfFirst { it.route == currentRoute } val updatedSelectedIndex = items.indexOfFirst { it.route == currentRoute }
selectedItemIndex = rememberUpdatedState(updatedSelectedIndex).value selectedItemIndex = rememberUpdatedState(updatedSelectedIndex).value
val pokeSearchVM = viewModel<PokeSearchViewModel>(factory = AppViewModelProvider.Factory)
val favoritesVM = viewModel<FavouritesViewModel>(factory = AppViewModelProvider.Factory)
Scaffold ( Scaffold (
bottomBar = { bottomBar = {
NavigationBar { NavigationBar {
@ -104,11 +109,11 @@ fun Navigation() {
ExitTransition.None ExitTransition.None
}) { }) {
composable(route = Screen.PokeSearch.route) { composable(route = Screen.PokeSearch.route) {
PokeSearchScreen() PokeSearchScreen(pokeSearchVM)
} }
composable( composable(
route = Screen.Favourites.route) { route = Screen.Favourites.route) {
Favourites() FavoritesScreen(favoritesVM)
} }
} }
} }
@ -122,6 +127,6 @@ fun Navigation() {
@Composable @Composable
fun NavigationPreview() { fun NavigationPreview() {
Surface { Surface {
PokeSearchScreen() PokeSearchScreen(viewModel(factory = AppViewModelProvider.Factory))
} }
} }

View File

@ -1,3 +0,0 @@
package com.ti.mobpo.ui.pokesearch
data class PokeSearchUiState(val searchQuery: String = "")

View File

@ -1,10 +1,89 @@
package com.ti.mobpo.ui.screens package com.ti.mobpo.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.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.unit.dp
import com.ti.mobpo.ui.viewmodels.FavouritesViewModel
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
@Composable @Composable
fun Favourites() { fun FavoritesScreen(viewModel: FavouritesViewModel) {
println("Favourites") Favorites(viewModel)
Text("Favourites Page") }
@Composable
fun Favorites(favoritesViewModel: FavouritesViewModel) {
LaunchedEffect(Unit) {
favoritesViewModel.loadFavourites()
}
val favorites by favoritesViewModel.pokemonDetails.collectAsState()
Column(
verticalArrangement = Arrangement.Top,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxSize()
.padding(20.dp)
) {
Spacer(modifier = Modifier.height(16.dp))
// Show AlertDialog if access check failed
if (favoritesViewModel.accessCheckFailed.value) {
AlertDialog(
onDismissRequest = {
// Dismiss the dialog
favoritesViewModel.accessCheckFailed.value = false
},
title = {
Text("Access Denied")
},
text = {
Text("You do not have access to this feature.")
},
confirmButton = {
Button(
onClick = {
favoritesViewModel.accessCheckFailed.value = false
}
) {
Text("OK")
}
}
)
}
favorites?.let { favoritesList ->
if (favoritesList.isNotEmpty()) {
LazyVerticalGrid(
columns = GridCells.Adaptive(150.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
items(items = favoritesList, key = { pokemon -> pokemon.id }) { pokemon ->
PokemonCard(pokemon, toggleFavorite = { pokemonId ->
favoritesViewModel.toggleFavorite(pokemonId)
})
}
}
} else {
Text("No favorites found")
}
}
}
} }

View File

@ -1,6 +1,6 @@
package com.ti.mobpo.ui.screens package com.ti.mobpo.ui.screens
import com.ti.mobpo.ui.pokesearch.PokeSearchViewModel import com.ti.mobpo.ui.viewmodels.PokeSearchViewModel
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@ -19,6 +19,7 @@ import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.outlined.FavoriteBorder import androidx.compose.material.icons.outlined.FavoriteBorder
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -26,9 +27,6 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
@ -45,18 +43,18 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.ti.mobpo.model.PokemonDetails import com.ti.mobpo.model.PokemonDetails
import coil.compose.AsyncImage import coil.compose.AsyncImage
import coil.request.ImageRequest import coil.request.ImageRequest
import com.ti.mobpo.AppViewModelProvider
import com.ti.mobpo.capitalizeFirstLetterAfterHyphens import com.ti.mobpo.capitalizeFirstLetterAfterHyphens
import com.ti.mobpo.ui.theme.MobileSecurityTheme import com.ti.mobpo.ui.theme.MobileSecurityTheme
@Composable @Composable
fun PokeSearchScreen(viewModel: PokeSearchViewModel = viewModel(factory = AppViewModelProvider.Factory)) { fun PokeSearchScreen(viewModel: PokeSearchViewModel) {
PokeSearch(viewModel) PokeSearch(viewModel)
} }
@Composable @Composable
fun PokeSearch(pokeSearchViewModel: PokeSearchViewModel) { fun PokeSearch(pokeSearchViewModel: PokeSearchViewModel) {
val searchResults by pokeSearchViewModel.pokemonDetails.collectAsState() val searchResults by pokeSearchViewModel.pokemonDetails.collectAsState()
val isLoading by pokeSearchViewModel.isLoading.collectAsState()
Column( Column(
verticalArrangement = Arrangement.Top, verticalArrangement = Arrangement.Top,
@ -71,6 +69,9 @@ fun PokeSearch(pokeSearchViewModel: PokeSearchViewModel) {
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
if(isLoading) {
CircularProgressIndicator()
} else {
searchResults?.let { results -> searchResults?.let { results ->
if (results.isNotEmpty()) { if (results.isNotEmpty()) {
LazyVerticalGrid( LazyVerticalGrid(
@ -90,11 +91,11 @@ fun PokeSearch(pokeSearchViewModel: PokeSearchViewModel) {
} }
} }
} }
}
@Composable @Composable
fun PokemonCard(pokemon: PokemonDetails, toggleFavorite: (Int) -> Unit) { fun PokemonCard(pokemon: PokemonDetails, toggleFavorite: (Int) -> Unit) {
var isFavourite by remember { mutableStateOf(false) }
Card( Card(
shape = MaterialTheme.shapes.medium, shape = MaterialTheme.shapes.medium,
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), elevation = CardDefaults.cardElevation(defaultElevation = 8.dp),
@ -130,7 +131,8 @@ fun PokemonCard(pokemon: PokemonDetails, toggleFavorite: (Int) -> Unit) {
) )
IconButton( IconButton(
onClick = { toggleFavorite(pokemon.id) }, onClick = { toggleFavorite(pokemon.id) },
modifier = Modifier.wrapContentSize() modifier = Modifier
.wrapContentSize()
.layout { measurable, constraints -> .layout { measurable, constraints ->
if (constraints.maxHeight == Constraints.Infinity) { if (constraints.maxHeight == Constraints.Infinity) {
layout(0, 0) {} layout(0, 0) {}

View File

@ -0,0 +1,18 @@
package com.ti.mobpo.ui.util
import android.content.Context
class FeatureManager(private val context: Context) {
private val PREFS_NAME = "FeaturePrefs"
private val KEY_PAID_FEATURE_ENABLED = "paid_feature_enabled"
fun hasAccessToPaidFeature(): Boolean {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getBoolean(KEY_PAID_FEATURE_ENABLED, false)
}
fun setPaidFeatureEnabled(enabled: Boolean) {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit().putBoolean(KEY_PAID_FEATURE_ENABLED, enabled).apply()
}
}

View File

@ -0,0 +1,82 @@
package com.ti.mobpo.ui.viewmodels
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.ti.mobpo.data.Favourite
import com.ti.mobpo.data.FavouritesRepository
import com.ti.mobpo.model.FavouriteUiState
import com.ti.mobpo.model.PokemonDetails
import com.ti.mobpo.network.PokeApi
import com.ti.mobpo.ui.util.FeatureManager
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import java.io.IOException
class FavouritesViewModel(private val favouritesRepository: FavouritesRepository,
private val featureManager: FeatureManager
) : ViewModel() {
private val service = PokeApi.retrofitService
private val _pokemonDetails = MutableStateFlow<List<PokemonDetails>?>(null)
val pokemonDetails: StateFlow<List<PokemonDetails>?> = _pokemonDetails.asStateFlow()
val accessCheckFailed: MutableState<Boolean> = mutableStateOf(false)
fun loadFavourites() {
// featureManager.setPaidFeatureEnabled(false) enable and disable acccess
// Ugly workaround to make sure all the favourites are loaded before displaying them
viewModelScope.launch {
if(!featureManager.hasAccessToPaidFeature()) {
accessCheckFailed.value = true
return@launch
}
try {
val favouritesList: StateFlow<FavouriteUiState> =
favouritesRepository.getAllItems().map { FavouriteUiState(it) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = FavouriteUiState()
)
favouritesList.collect { state ->
val detailsList = mutableListOf<PokemonDetails>()
for (favourite in state.favourites) {
val details = service.getPokemonDetails(favourite.id)
detailsList.add(details.copy(isFavorite = true))
}
_pokemonDetails.value = detailsList
}
} catch (e: IOException) {
/* Handle error */
}
}
}
fun toggleFavorite(pokemonId: Int) {
_pokemonDetails.value = _pokemonDetails.value?.map { pokemon ->
if (pokemon.id == pokemonId) {
pokemon.copy(isFavorite = !pokemon.isFavorite)
} else {
pokemon
}
}
viewModelScope.launch {
if (_pokemonDetails.value != null) {
val pokemon = _pokemonDetails.value!!.find { it.id == pokemonId }
pokemon?.let {
if (it.isFavorite) {
favouritesRepository.insertItem(Favourite(it.id, it.name))
} else {
favouritesRepository.deleteItem(Favourite(it.id, it.name))
}
}
}
}
}
}

View File

@ -1,10 +1,11 @@
package com.ti.mobpo.ui.pokesearch package com.ti.mobpo.ui.viewmodels
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.ti.mobpo.data.Favourite import com.ti.mobpo.data.Favourite
import com.ti.mobpo.data.FavouritesRepository import com.ti.mobpo.data.FavouritesRepository
import com.ti.mobpo.model.PokemonDetails import com.ti.mobpo.model.PokemonDetails
import com.ti.mobpo.model.PokemonSpecies
import com.ti.mobpo.network.PokeApi import com.ti.mobpo.network.PokeApi
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -13,27 +14,56 @@ import kotlinx.coroutines.launch
import java.io.IOException import java.io.IOException
private const val SHOW_LIMIT = 20
class PokeSearchViewModel(private val favouritesRepository: FavouritesRepository) : ViewModel() { class PokeSearchViewModel(private val favouritesRepository: FavouritesRepository) : ViewModel() {
private val service = PokeApi.retrofitService; private val service = PokeApi.retrofitService
private val _pokemonDetails = MutableStateFlow<List<PokemonDetails>?>(null) private val _pokemonDetails = MutableStateFlow<List<PokemonDetails>?>(null)
val pokemonDetails: StateFlow<List<PokemonDetails>?> = _pokemonDetails.asStateFlow() val pokemonDetails: StateFlow<List<PokemonDetails>?> = _pokemonDetails.asStateFlow()
private val _initialPokemonList = MutableStateFlow<List<PokemonSpecies>?>(null)
fun search(query: String) { private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
init {
fetchPokemonSpecies()
}
private fun fetchPokemonSpecies() {
viewModelScope.launch { viewModelScope.launch {
try { try {
val response = service.getPokemon() val response = service.getPokemon()
val filteredList = response.results.filter { it.name.contains(query, ignoreCase = true) } _initialPokemonList.value = response.results.sortedBy { it.name.lowercase() }
} catch (e: IOException) {
/*TODO*/
}
}
}
fun search(query: String) {
_isLoading.value = true
viewModelScope.launch {
try {
val firstIndex = _initialPokemonList.value?.indexOfFirst { it.name.startsWith(query, ignoreCase = true) }
val lastIndex = _initialPokemonList.value?.indexOfLast { it.name.startsWith(query, ignoreCase = true) }
val detailsList = mutableListOf<PokemonDetails>() val detailsList = mutableListOf<PokemonDetails>()
for (pokemonSpecies in filteredList) {
if (firstIndex != null && lastIndex != null) {
val endIndex = minOf(firstIndex + SHOW_LIMIT, lastIndex + 1)
val startIndex = maxOf(firstIndex, endIndex - SHOW_LIMIT)
for (index in startIndex until endIndex) {
val pokemonSpecies = _initialPokemonList.value!![index]
val details = service.getPokemonDetails(extractPokemonId(pokemonSpecies.url)) val details = service.getPokemonDetails(extractPokemonId(pokemonSpecies.url))
val isFavorite = favouritesRepository.isFavourite(details.id) val isFavorite = favouritesRepository.isFavourite(details.id)
detailsList.add(details.copy(isFavorite = isFavorite)) detailsList.add(details.copy(isFavorite = isFavorite))
} }
}
_pokemonDetails.value = detailsList _pokemonDetails.value = detailsList
} catch (e: IOException) { } catch (e: IOException) {
/* Handle error */ /* Handle error */
} finally {
_isLoading.value = false
} }
} }
} }