I am learning in State in jetpack compose. I found that State holders as source of truth. So created my some data can you guys guide me if I am doing wrong here.
PairViewModel.kt
class PairViewModel : ViewModel() {
var isBluetoothEnabled = mutableStateOf(false)
private set
fun onBluetoothEnable(value: Boolean) {
isBluetoothEnabled.value = value
}
}
PairScreen.kt
class PairScreenState(context: Context, viewModel: PairViewModel) {
private val bluetoothManager: BluetoothManager = context.getSystemService(BluetoothManager::class.java)
private val bluetoothAdapter: BluetoothAdapter by lazy {
bluetoothManager.adapter
}
init {
viewModel.onBluetoothEnable(bluetoothAdapter.isEnabled)
}
fun checkBluetoothStatus(bluetoothStatus: MutableState<Boolean>): BroadcastReceiver {
return object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == BluetoothAdapter.ACTION_STATE_CHANGED) {
when (intent.getIntExtra(
BluetoothAdapter.EXTRA_STATE,
BluetoothAdapter.ERROR
)) {
BluetoothAdapter.STATE_OFF -> {
bluetoothStatus.value = false
}
BluetoothAdapter.STATE_ON -> {
bluetoothStatus.value = true
}
}
}
}
}
}
}
#Composable
fun rememberPairScreenState(
context: Context,
viewModel: PairViewModel
) = remember {
PairScreenState(context, viewModel)
}
#Composable
fun PairContent(
context: Context = LocalContext.current,
viewModel: PairViewModel = getViewModel(),
rememberPairScreenState: PairScreenState = rememberPairScreenState(context, viewModel),
) {
AnimatedVisibility(visible = true) {
AppBarScaffold() {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
) {
rememberPairScreenState.checkBluetoothStatus(viewModel.isBluetoothEnabled).apply {
context.registerReceiver(this, IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED))
}
if (viewModel.isBluetoothEnabled.value) {
println(">> Enable >>>")
} else {
println(">> Disable >>>")
}
}
}
}
}
#Preview(showBackground = true)
#Composable
fun PairContentPreview() {
PairContent()
}
I am using Bluetooth as example to understand state holder in my use case. Please guide me if you find anything wrong in my code. Thanks
Ill try my best here, I get where you're coming from, having a code that it's hard to verify if its the proper way of doing "yet", regardless of how many source materials you review like in github, sometimes references just doesn't exist yet right?
For State hoisting/handling, its good to follow the principles coming from the community. So the way I handle State Hoisting, is thinking of its purpose
So if its just something that needs to be local within the #Composable
remember {...}
If its something that deals with multiple logic and values, State class
class PersonState(val personParam: Person) {
.....
}
#Composable
fun rememberPersonState(person: Person) = remember(key1= person) {
PersonState(person)
}
If its something that deals with repository, network calls, use-cases where persistence is a major part of the requirement, ViewModel, and lifecyle is something you have to be aware of. ViewModel
class PersonScreenViewModel {
/..RepositoryStateFlows../
/..Data structural updates../
}
So far this mindset and approach helped me a bit when deciding how would I hoist my states.
As for your PairScreenState, consider this use-case solution coming from this post Detect if Soft Keyboard is Open or Close, where you can detect if the keyboard is open or not
I would have your BlueTooth usecase where I would implement it as a Composable utility function and returns a State where I can define a DisposableEffect, though this code is not working but I think you'll get my point here.
enum class BlueTooth {
ON, OFF
}
#Composable
fun BlueToothAsState(): State<BlueTooth> {
val blueToothState = remember { mutableStateOf(BlueTooth.OFF) }
DisposableEffect(view) {
var mReceiver : BroadcastReceiver? = object : BroadcastReceiver() {
/.../
when (intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.ERROR)) {
BluetoothAdapter.STATE_OFF -> {
blueToothState = BlueTooth.OFF
}
BluetoothAdapter.STATE_ON -> {
blueToothState = BlueTooth.ON
}
}
}
}
onDispose {
mReceiver = null
}
}
return blueToothState
}
As for the other parts of the code, I don't think you need it here if its just always set to true
AnimatedVisibility(visible = true)
Related
I have the following setup.
I have a screen with a list of items (PlantsScreen). When clicking on an item from the list I will be navigated to another screen (AddEditPlantScreen). After editing and saving the item and navigating back to the listScreen, I want to show the updated list of items. But the list is not displaying the updated list but the list before the edit of the item.
In order to have a single source of truth, I am fetching the data from a node.js Back-End and then saving it to the local repository (Room). I think I need to refresh the state in the ViewModel to fetch the updated list from my repository.
I know I can use a Job to do this, but it throws me an error. Is this the correct approach when returning a Flow?
If yes, how can I achieve this.
If not, what alternative approach do I have?
plantsListViewModel.kt
private val _state = mutableStateOf<PlantsState>(PlantsState())
val state: State<PlantsState> = _state
init {
getPlants(true, "")
}
private fun getPlants(fetchFromBackend: Boolean, query: String) {
viewModelScope.launch {
plantRepository.getPlants(fetchFromBackend, query)
.collect { result ->
when (result) {
is Resource.Success -> {
result.data?.let { plants ->
_state.value = state.value.copy(
plants = plants,
)
}
}
}
}
}
}
Here is my repository where I fetch the items in the list from.
// plantsRepository.kt
override suspend fun getPlants(
fetchFromBackend: Boolean,
query: String
): Flow<Resource<List<Plant>>> {
return flow {
emit(Resource.Loading(true))
val localPlants = dao.searchPlants(query)
emit(
Resource.Success(
data = localPlants.map { it.toPlant() },
)
)
val isDbEmpty = localPlants.isEmpty() && query.isBlank()
val shouldLoadFromCache = !isDbEmpty && !fetchFromBackend
if (shouldLoadFromCache) {
emit(Resource.Loading(false))
return#flow
}
val response = plantApi.getPlants().plants
dao.clearPlants()
dao.insertPlants(
response.map { it.toPlantEntity() }
)
emit(Resource.Success(
data = dao.searchPlants("").map { it.toPlant() }
))
emit(Resource.Loading(false))
}
}
The full code for reference can be found here:
https://gitlab.com/fiehra/plants
Thank you!
You actually have two sources of truth: One is the room database, the other the _state object in the view model.
To reduce this to a single source of truth you need to move the collection of the flow to the compose function where the data is needed. You will do this using the extension function StateFlow.collectAsStateWithLifecycle() from the artifact androidx.lifecycle:lifecycle-runtime-compose. This will automatically subscribe and unsubscribe the flow when your composable enters and leaves the composition.
Since you want the business logic to stay in the view model you have to apply it before the flow is collected. The idea is to only transform the flow in the view model:
class PlantsViewModel {
private var fetchFromBackend: Boolean by mutableStateOf(true)
private var query: String by mutableStateOf("")
#OptIn(ExperimentalCoroutinesApi::class)
val state: StateFlow<PlantsState> =
snapshotFlow { fetchFromBackend to query }
.flatMapLatest { plantRepository.getPlants(it.first, it.second) }
.mapLatest(PlantsState::of)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = PlantsState.Loading,
)
// ...
}
If you want other values for fetchFromBackend and query you just need to update the variables; the flow will automatically recalculate the state object. It can be as simple as just calling something like this:
fun requestPlant(fetchFromBackend: Boolean, query: String) {
this.fetchFromBackend = fetchFromBackend
this.query = query
}
The logic to create a PlantsState from a result can then be done somewhere else in the view model. Replace your PlantsViewModel.getPlants() with this and place it at file level outside of the PlantsViewModel class:
private fun PlantsState.Companion.of(result: Resource<List<Plant>>): PlantsState = when (result) {
is Resource.Success -> {
result.data?.let { plants ->
PlantsState.Success(
plants = plants,
)
} ?: TODO("handle case where result.data is null")
}
is Resource.Error -> {
PlantsState.Error("an error occurred")
}
is Resource.Loading -> {
PlantsState.Loading
}
}
With the PlantsState class replaced by this:
sealed interface PlantsState {
object Loading : PlantsState
data class Success(
val plants: List<Plant> = emptyList(),
val plantOrder: PlantOrder = PlantOrder.Name(OrderType.Descending),
val isOrderSectionVisible: Boolean = false,
) : PlantsState
data class Error(
val error: String,
) : PlantsState
companion object
}
Then, wherever you need the state (in PlantsScreen f.e.), you can get a state object with
val state by viewModel.state.collectAsStateWithLifecycle()
Thanks to kotlin flows state will always contain the most current data from the room database, and thanks to the compose magic your composables will always update when anything in the state object updates, so that you really only have one single source of truth.
Additionally:
PlantRepository.getPlants() should not be marked as a suspend function because it just creates a flow and won't block; long running data retrieval will be done in the collector.
You will need to manually import androidx.compose.runtime.getValue and the androidx.compose.runtime.setValue for some of the delegates to work.
After #Leviathan was able to point me in the right direction i refactored my code by changing the return types of my repository functions, implementing use cases and returning a Flow<List<Plant>> instead of Flow<Resource<List<Plant>>> for simplicity purposes.
Further removed the suspend marker of the functions in the PlantDao.kt and PlantRepository.kt as pointed out by Leviathan.
// PlantRepositoryImplementation.kt
override fun getPlants(
fetchFromBackend: Boolean,
query: String
): Flow<List<Plant>> {
return flow {
if (fetchFromBackend) {
val response = plantApi.getPlants().plants
dao.clearPlants()
dao.insertPlants(
response.map { it.toPlantEntity() }
)
val localPlants = dao.searchPlants(query)
localPlants.collect { plants ->
emit(plants.map { it.toPlant() })
return#collect
}
} else {
val localPlants = dao.searchPlants(query)
localPlants.collect { plants ->
emit(plants.map { it.toPlant() })
return#collect
}
}
}
}
I started using a Job and GetPlants usecase in my viewModel like this:
// PlantsViewModel.kt
private fun getPlants(plantOrder: PlantOrder, fetchFromBackend: Boolean, query: String) {
getPlantsJob?.cancel()
getPlantsJob = plantUseCases.getPlants(plantOrder, fetchFromBackend, query)
.onEach { plants ->
_state.value = state.value.copy(
plants = plants,
plantOrder = plantOrder
)
}.launchIn(viewModelScope)
I also had to remove the suspend in the PlantDao.kt
// PlantDao.kt
fun searchPlants(query: String): Flow<List<PlantEntity>>
This is the code for my GetPlants usecase:
// GetPlantsUsecase.kt
class GetPlants
(
private val repository: PlantRepository,
) {
operator fun invoke(
plantOrder: PlantOrder = PlantOrder.Name(OrderType.Descending),
fetchFromBackend: Boolean,
query: String
): Flow<List<Plant>> {
return repository.getPlants(fetchFromBackend, query).map { plants ->
when (plantOrder.orderType) {
is OrderType.Ascending -> {
// logic for sorting
}
}
is OrderType.Descending -> {
// logic for sorting
}
}
}
}
}
I've this screen:
#Composable
fun SetNewPasswordScreen(
viewModel: SetNewPasswordViewModel,
navigateToSetDetails: () -> Unit,
) {
val state by viewModel.state.collectAsState()
if (state.passwordConfirmed) {
LaunchedEffect(Unit) {
navigateToSetDetails()
}
} else {
SetNewPasswordScreen(
confirmNewPassword = viewModel::confirmNewPassword
)
}
}
and this is the view model:
#HiltViewModel
class SetNewPasswordViewModel #Inject constructor(
private val setNewPassword: SetNewPassword,
) : ViewModel() {
val state: StateFlow<SetNewPasswordViewState> = setNewPassword.flow
.map { hasSetNewPassword ->
SetNewPasswordViewState(passwordConfirmed = hasSetNewPassword)
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
initialValue = SetNewPasswordViewState.Empty
)
fun confirmNewPassword(newPassword: String, repeatedNewPassword: String) {
viewModelScope.launch {
setNewPassword(SetNewPassword.Params(newPassword, repeatedNewPassword))
}
}
}
My problem is when I try to navigate back from
SetDetailsScreen
to
SetNewPasswordScreen
I'm getting true for state.passwordConfirmed again, and I'm stuck in a loop.
I've thought about resetting passwordConfirmed right after the if statement, or using previousBackStackEntry in SetDetailsScreen to store that password should be confirmed again, but it doesn't seem right to do so every time I want to navigate back to a screen that got a result. What's the best practice for this kind of situation?
So I have a ViewModel I'm trying to unit test. It is using the stateIn operator. I found this documentation about how to test stateflows created using the stateIn operator https://developer.android.com/kotlin/flow/test but the mapLatest never triggers even though I'm collecting the flow.
class DeviceConfigurationViewModel(
val systemDetails: SystemDetails,
val step: AddDeviceStep.ConfigureDeviceStep,
val service: DeviceRemoteService
) : ViewModel(), DeviceConfigurationModel {
#OptIn(ExperimentalCoroutinesApi::class)
private val _state: StateFlow<DeviceConfigurationModel.State> =
service.state
.mapLatest { state ->
when (state) {
DeviceRemoteService.State.Connecting -> {
DeviceConfigurationModel.State.Connecting
}
is DeviceRemoteService.State.ConnectedState.Connected -> {
state.sendCommand(step.toCommand(systemDetails))
DeviceConfigurationModel.State.Connected
}
is DeviceRemoteService.State.ConnectedState.CommandSent -> {
DeviceConfigurationModel.State.Configuring
}
is DeviceRemoteService.State.ConnectedState.MessageReceived -> {
transformMessage(state)
}
is DeviceRemoteService.State.Disconnected -> {
transformDisconnected(state)
}
}
}
.distinctUntilChanged()
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5000), // Keep it alive for a bit if the app is backgrounded
DeviceConfigurationModel.State.Disconnected
)
override val state: StateFlow<DeviceConfigurationModel.State>
get() = _state
private fun transformDisconnected(
state: DeviceRemoteService.State.Disconnected
): DeviceConfigurationModel.State {
return if (state.hasCause) {
DeviceConfigurationModel.State.UnableToConnect(state)
} else {
state.connect()
DeviceConfigurationModel.State.Connecting
}
}
private fun transformMessage(state: DeviceRemoteService.State.ConnectedState.MessageReceived): DeviceConfigurationModel.State {
return when (val message = state.message) {
is Message.AddedToProject -> DeviceConfigurationModel.State.Configured
is Message.ConfigWifiMessage -> {
if (!message.values.success) {
DeviceConfigurationModel.State.Error(
message.values.errorCode,
state,
step.toCommand(systemDetails)
)
} else {
DeviceConfigurationModel.State.Configuring
}
}
}
}
}
And here's my unit test. The mapLatest never seems to get triggered even though I'm collecting the flow. I'm using the suggestions here https://developer.android.com/kotlin/flow/test
#OptIn(ExperimentalCoroutinesApi::class)
class DeviceConfigurationViewModelTest {
private val disconnectedService = mock<DisconnectedService>()
private val deviceServiceState: MutableStateFlow<DeviceRemoteService.State> =
MutableStateFlow(DeviceRemoteService.State.Disconnected(disconnectedService, Exception()))
private val deviceService = mock<DeviceRemoteService> {
on { state } doReturn deviceServiceState
}
private val systemDetails = mock<SystemDetails> {
on { controllerAddress } doReturn "192.168.1.112"
on { controllerName } doReturn "000FFF962FE7"
}
private val step = AddDeviceDeviceStep.ConfigureDeviceStep(
44,
"Thou Shalt Not Covet Thy Neighbor’s Wifi",
"testing616"
)
private lateinit var viewModel: DeviceConfigurationViewModel
#Before
fun setup() {
viewModel = DeviceConfigurationViewModel(systemDetails, step, deviceService)
}
#Test
fun testDeviceServiceDisconnectWithCauseMapsToUnableToConnect() =
runTest {
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.state.collect() }
deviceServiceState.emit(
DeviceRemoteService.State.Disconnected(Exception("Something bad happened"))
)
assertThat(viewModel.state.value).isInstanceOf(DeviceConfigurationModel.State.UnableToConnect::class.java)
collectJob.cancel()
}
}
I believe this is happening because the viewModelScope uses a hardcoded Main dispatcher under the hood.
You can follow the instructions here in the Android documentation to see how you can to set the Main dispatcher for tests.
Right now I have an Event class in the ViewModel that is exposed as a Flow this way:
abstract class BaseViewModel() : ViewModel() {
...
private val eventChannel = Channel<Event>(Channel.BUFFERED)
val eventsFlow = eventChannel.receiveAsFlow()
fun sendEvent(event: Event) {
viewModelScope.launch {
eventChannel.send(event)
}
}
sealed class Event {
data class NavigateTo(val destination: Int): Event()
data class ShowSnackbarResource(val resource: Int): Event()
data class ShowSnackbarString(val message: String): Event()
}
}
And this is the composable managing it:
#Composable
fun SearchScreen(
viewModel: SearchViewModel
) {
val events = viewModel.eventsFlow.collectAsState(initial = null)
val snackbarHostState = remember { SnackbarHostState() }
val coroutineScope = rememberCoroutineScope()
Box(
modifier = Modifier
.fillMaxHeight()
.fillMaxWidth()
) {
Column(
modifier = Modifier
.padding(all = 24.dp)
) {
SearchHeader(viewModel = viewModel)
SearchContent(
viewModel = viewModel,
modifier = Modifier.padding(top = 24.dp)
)
when(events.value) {
is NavigateTo -> TODO()
is ShowSnackbarResource -> {
val resources = LocalContext.current.resources
val message = (events.value as ShowSnackbarResource).resource
coroutineScope.launch {
snackbarHostState.showSnackbar(
message = resources.getString(message)
)
}
}
is ShowSnackbarString -> {
coroutineScope.launch {
snackbarHostState.showSnackbar(
message = (events.value as ShowSnackbarString).message
)
}
}
}
}
SnackbarHost(
hostState = snackbarHostState,
modifier = Modifier.align(Alignment.BottomCenter)
)
}
}
I followed the pattern for single events with Flow from here.
My problem is, the event is handled correctly only the first time (SnackBar is shown correctly). But after that, seems like the events are not collected anymore. At least until I leave the screen and come back. And in that case, all events are triggered consecutively.
Can't see what I'm doing wrong. When debugged, events are sent to the Channel correctly, but seems like the state value is not updated in the composable.
Rather than placing your logic right inside composable place them inside
// Runs only on initial composition
LaunchedEffect(key1 = Unit) {
viewModel.eventsFlow.collectLatest { value ->
when(value) {
// Handle events
}
}
}
And also rather than using it as state just collect value from flow in LaunchedEffect block. This is how I implemented single event in my application
Here's a modified version of Jack's answer, as an extension function following new guidelines for safer flow collection.
#Composable
inline fun <reified T> Flow<T>.observeWithLifecycle(
lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
minActiveState: Lifecycle.State = Lifecycle.State.STARTED,
noinline action: suspend (T) -> Unit
) {
LaunchedEffect(key1 = Unit) {
lifecycleOwner.lifecycleScope.launch {
flowWithLifecycle(lifecycleOwner.lifecycle, minActiveState).collect(action)
}
}
}
Usage:
viewModel.flow.observeWithLifecycle { value ->
//Use the collected value
}
I'm not sure how you manage to compile the code, because I get an error on launch.
Calls to launch should happen inside a LaunchedEffect and not composition
Usually you can use LaunchedEffect which is already running in the coroutine scope, so you don't need coroutineScope.launch. Read more about side effects in documentation.
A little kotlin advice: when using when in types, you don't need to manually cast the variable to a type with as. In cases like this, you can declare val along with your variable to prevent Smart cast to ... is impossible, because ... is a property that has open or custom getter error:
val resources = LocalContext.current.resources
val event = events.value // allow Smart cast
LaunchedEffect(event) {
when (event) {
is BaseViewModel.Event.NavigateTo -> TODO()
is BaseViewModel.Event.ShowSnackbarResource -> {
val message = event.resource
snackbarHostState.showSnackbar(
message = resources.getString(message)
)
}
is BaseViewModel.Event.ShowSnackbarString -> {
snackbarHostState.showSnackbar(
message = event.message
)
}
}
}
This code has one problem: if you send the same event many times, it will not be shown because LaunchedEffect will not be restarted: event as key is the same.
You can solve this problem in different ways. Here are some of them:
Replace data class with class: now events will be compared by pointer, not by fields.
Add a random id to the data class, so that each new element is not equal to another:
data class ShowSnackbarResource(val resource: Int, val id: UUID = UUID.randomUUID()) : Event()
Note that the coroutine LaunchedEffect will be canceled when a new event occurs. And since showSnackbar is a suspend function, the previous snackbar will be hidden to display the new one. If you run showSnackbar on coroutineScope.launch (still doing it inside LaunchedEffect), the new snackbar will wait until the previous snackbar disappears before it appears.
Another option, which seems cleaner to me, is to reset the state of the event because you have already reacted to it. You can add another event to do this:
object Clean : Event()
And send it after the snackbar disappears:
LaunchedEffect(event) {
when (event) {
is BaseViewModel.Event.NavigateTo -> TODO()
is BaseViewModel.Event.ShowSnackbarResource -> {
val message = event.resource
snackbarHostState.showSnackbar(
message = resources.getString(message)
)
}
is BaseViewModel.Event.ShowSnackbarString -> {
snackbarHostState.showSnackbar(
message = event.message
)
}
null, BaseViewModel.Event.Clean -> return#LaunchedEffect
}
viewModel.sendEvent(BaseViewModel.Event.Clean)
}
But in this case, if you send the same event while the previous one has not yet disappeared, it will be ignored as before. This can be perfectly normal, depending on the structure of your application, but to prevent this you can show it on coroutineScope as before.
Also, check out the more general solution implemented in the JetNews compose app example. I suggest you download the project and inspect it starting from location where the snackbar is displayed.
https://github.com/Kotlin-Android-Open-Source/Jetpack-Compose-MVI-Coroutines-Flow/blob/master/core-ui/src/main/java/com/hoc/flowmvi/core_ui/rememberFlowWithLifecycle.kt
#Suppress("ComposableNaming")
#Composable
fun <T> Flow<T>.collectInLaunchedEffectWithLifecycle(
vararg keys: Any?,
lifecycle: Lifecycle = LocalLifecycleOwner.current.lifecycle,
minActiveState: Lifecycle.State = Lifecycle.State.STARTED,
collector: suspend CoroutineScope.(T) -> Unit
) {
val flow = this
val currentCollector by rememberUpdatedState(collector)
LaunchedEffect(flow, lifecycle, minActiveState, *keys) {
withContext(Dispatchers.Main.immediate) {
lifecycle.repeatOnLifecycle(minActiveState) {
flow.collect { currentCollector(it) }
}
}
}
}
class ViewModel {
val singleEvent: Flow<E> = eventChannel.receiveAsFlow()
}
#Composable fun Demo() {
val snackbarHostState by rememberUpdatedState(LocalSnackbarHostState.current)
val scope = rememberCoroutineScope()
viewModel.singleEvent.collectInLaunchedEffectWithLifecycle { event ->
when (event) {
SingleEvent.Refresh.Success -> {
scope.launch {
snackbarHostState.showSnackbar("Refresh successfully")
}
}
is SingleEvent.Refresh.Failure -> {
scope.launch {
snackbarHostState.showSnackbar("Failed to refresh")
}
}
is SingleEvent.GetUsersError -> {
scope.launch {
snackbarHostState.showSnackbar("Failed to get users")
}
}
is SingleEvent.RemoveUser.Success -> {
scope.launch {
snackbarHostState.showSnackbar("Removed '${event.user.fullName}'")
}
}
is SingleEvent.RemoveUser.Failure -> {
scope.launch {
snackbarHostState.showSnackbar("Failed to remove '${event.user.fullName}'")
}
}
}
}
}
Here's a modified version of Soroush Lotfi answer making sure we also stop flow collection whenever the composable is not visible anymore: just replace the LaunchedEffect with a DisposableEffect
#Composable
inline fun <reified T> Flow<T>.observeWithLifecycle(
lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
minActiveState: Lifecycle.State = Lifecycle.State.STARTED,
noinline action: suspend (T) -> Unit
) {
DisposableEffect(Unit) {
val job = lifecycleOwner.lifecycleScope.launch {
flowWithLifecycle(lifecycleOwner.lifecycle, minActiveState).collect(action)
}
onDispose {
job.cancel()
}
}
}
I am writing a custom android lint to help to check if the private attributes match naming convention.
I used the test cases to verify my implementation. I used a method called isPrivateOrParameterInPrivateMethod() to check if it is private or not but it seems return true everytime I run it.
And I cannot find any documentation about this method (org.jetbrains.kotlin.asJava.classesisPrivateOrParameterInPrivateMethod). If I used it incorrectly, I would like to know.
Appreciate any comment or advice
class PrivateVariableMPrefixDetector : Detector(), Detector.UastScanner {
override fun getApplicableUastTypes() = listOf<Class<out UElement>>(UVariable::class.java)
override fun createUastHandler(context: JavaContext) =
NamingPatternHandler(context)
class NamingPatternHandler(private val context: JavaContext) : UElementHandler() {
override fun visitVariable(node: UVariable) {
node.takeIf { it.isPrivateOrParameterInPrivateMethod() }
?.takeUnless { node.name?.first()?.equals('m') ?: false }
?.run {
process(node, node)
}
}
private fun process(scope: UElement, declaration: PsiNamedElement) {
context.report(
ISSUE_PRIVATE_VAR_PREFIX_WITH_M,
scope,
context.getNameLocation(scope),
"${declaration.name} is not named with prefix m"
)
}
}
}
Test Case
#Test
fun should_not_warn_when_public_variable_is_not_stated_with_m_prefix() {
TestLintTask.lint()
.files(
TestFiles.kt(
"""
class Foo {
val binding
}
"""
).indented()
)
.issues(ISSUE_PRIVATE_VAR_PREFIX_WITH_M)
.run()
.expectClean()
}
Updated on 13/12/2020
There is a class JavaEvaluator inside the JavaContext, and I found some useful method to check the explicit modifier for the variable which suits my cases
class MyHandler(private val context: JavaContext) : UElementHandler() {
override fun visitField(node: UField) {
val isConstant = node.isFinal && node.isStatic
val isEnumConstant = node is UEnumConstant
if (!isConstant && !isEnumConstant) {
node.takeIf {
context.evaluator.hasModifiers(node, KtTokens.PRIVATE_KEYWORD)
}?.run {
process(node, node)
}
}
}
}
Outdated
After putting an effort on it, I found the following solution works. Although I still dun quite understand the meaning of node.sourcePsi, i will make it work first. Appreciate any advice or suggestion
node.takeIf { node.sourcePsi?.text?.startsWith("private") ?: false }
?.takeUnless { node.name.first() == 'm' && node.name.getOrNull(1)?.isUpperCase() ?: false }
?.run {
process(node, node)
}