Callback transformed to suspend function loops infinitely when runBlocking - android

I am rewriting a java class to kotlin replacing callback with a suspend function. This is my java code:
#IgnoreExtraProperties
public class DeviceType {
public String manufacturer;
public String marketName;
public String model;
public DeviceType(String manufacturer, String marketName, String model) {
this.manufacturer = manufacturer;
this.marketName = marketName;
this.model = model;
}
public DeviceType(){}
public DeviceType(Context context) {
DeviceName.with(context).request(new DeviceName.Callback() {
#Override
public void onFinished(DeviceName.DeviceInfo info, Exception error) {
if (error == null) {
manufacturer = info.manufacturer;
marketName = info.marketName;
model = info.model;
} else
Log.e("DeviceType: ", error.getMessage());
}
});
}
#Override
public String toString() {
if (model == null) {
return "No device type recognized!";
} else {
if (marketName.equals(model))
return manufacturer + " " +marketName;
else
return manufacturer + " " +marketName+ " (" +model+ ")";
}
}
DeviceName class belongs to library AndroidDeviceNames.
Below is my new code in Kotlin:
#IgnoreExtraProperties
data class DeviceType(
var manufacturer: String? = null,
var marketName: String? = null,
var model: String? = null
) {
constructor(context: Context) : this(
context.deviceType()?.manufacturer,
context.deviceType()?.marketName,
context.deviceType()?.model
)
override fun toString(): String {
val stringSuffix =
if (marketName == model)
""
else
" ($model)"
return model?.let { "$manufacturer $marketName$stringSuffix" }
?: "No device type recognized!"
}
}
/**
* return DeviceType "from" UI Context
*/
fun Context.deviceType(): DeviceType? = runBlocking {
withContext(Dispatchers.IO) {
/*
delay(1000L)
DeviceType("Nokia","Banana","R2D2")
^
This works!
*/
DeviceName
.with(this#deviceType)
.awaitWith(this#deviceType)
// ^ that doesn't!
}
}
suspend fun DeviceName.Request.awaitWith(context: Context): DeviceType? = suspendCoroutine { cont ->
DeviceName.with(context).request { info, error ->
if (error == null) {
cont.resume(DeviceType(
info.manufacturer,
info.marketName,
info.model
))
} else
cont.resumeWithException(Throwable(error.message))
.let {
Log.e(
"FirebaseUserData",
"DeviceName.Request.awaitWith(): $error.message"
)
}
}
}
Executing deviceType().toString()) in MainActivity makes infinite looping in runBlocking() function.
The fundamental question is of course "why my implementation of awaitWith() does not work?", but I am also interested, taking first steps in kotlin and coroutines, if I should provide additional solutions for exception handling, as I read the "coroutines may hide exceptions".
And one more question:
Is Dispatcher.IO here OK? DeviceName gets data from Google API json query.
Should I use that dispatcher type also for coroutines related to firebase DB?

First of all, responding to the question's title, the loop is happening because the constructor is calling Context.deviceType() that calls DeviceName.Request.awaitWith that calls the constructor again:
cont.resume(DeviceType(
info.manufacturer,
info.marketName,
info.model
))
The Context.deviceType() return a DeviceType by itself, but you desire to use it to configure each attribute in the initialization. Each DeviceType's attribute initialization instantiate a DeviceType which each attribute instantiate another DeviceType and so on....
Using Dispatcher.IO is OK and even desired when it comes to IO operations, like network, but you are not quite using it.
The runBlocking call blocks the current thread. The way you are using is like that:
## Assume we are on Thread (A)
fun Context.deviceType(): DeviceType? = runBlocking { ## Still in thread (A)
withContext(Dispatchers.IO) { ## Execute in an IO thread pool, but (A) is waiting
DeviceName
.with(this#deviceType)
.awaitWith(this#deviceType)
} ## Returns to thread (A)
} # Resumes Thread (A)
So, although this is kinda running in an IO dispatcher, the calling thread is blocked until the execution is finished, making it synchronous and indifferent.

Actually my goal was to see the deviceType() function output in non-coroutine environment. This function will be used anyway in other suspend functions or coroutine scope.
This is DeviceType class with its public functions without additional constructor:
#IgnoreExtraProperties
data class DeviceType(
var manufacturer: String? = null,
var marketName: String? = null,
var model: String? = null
) {
override fun toString(): String {
val stringSuffix =
if (marketName == model)
""
else
" ($model)"
return model?.let { "$manufacturer $marketName$stringSuffix" }
?: "No device type recognized!"
}
}
fun Context.deviceTypeByRunBlocking(): DeviceType? = runBlocking {
withContext(Dispatchers.IO) {
DeviceName
.with(this#deviceTypeNoSuspend)
.awaitWith(this#deviceTypeNoSuspend)
}
}
suspend fun Context.deviceType(): DeviceType? =
DeviceName
.with(this#deviceType)
.awaitWith(this#deviceType)
private suspend fun DeviceName.Request.awaitWith(context: Context): DeviceType? =
suspendCoroutine { cont ->
DeviceName.with(context).request { info, error ->
if (error == null) {
cont.resume(
DeviceType(
info.manufacturer,
info.marketName,
info.model
)
//.also{Log.d("TAG","Inside awaitWith(): $it")}
)
} else
cont.resumeWithException(Throwable(error.message))
.let {
Log.e(
"TAG",
"DeviceName.Request.awaitWith(): $error.message"
)
}
}
}
Main Activity:
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
GlobalScope.launch { Log.d("MainActivity", "${this#MainActivity.deviceType()}") }
//^ this works
Log.d("MainActivity", "${this.deviceTypeByRunBlocking()}")
//^ this still does not, loops in joinBlocking(), isComplete = false
}
}
I know that using GlobalScope is not recommended, but for testing it is fine for me.

Related

Paging 3 Compose :insertSeparators not working as expected

I'm trying to insert separators to my list using the paging 3 compose library however, insertSeparators doesn't seem to indicate when we are at the beginning or end. My expectations are that before will be null at the beginning while after will be null at the end of the list. But it's never null thus hard to know when we are at the beginning or end. Here is the code:
private val filterPreferences =
MutableStateFlow(HomePreferences.FilterPreferences())
val games: Flow<PagingData<GameModel>> = filterPreferences.flatMapLatest {
useCase.execute(it)
}.map { pagingData ->
pagingData.map { GameModel.GameItem(it) }
}.map {
it.insertSeparators {before,after->
if (after == null) {
return#insertSeparators null
}
if (before == null) {
Log.i(TAG, "before is null: ") // never reach here
return#insertSeparators GameModel.SeparatorItem("title")
}
if(condition) {
GameModel.SeparatorItem("title")
}
else null
}
}
.cachedIn(viewModelScope)
GamesUseCase
class GamesUseCase #Inject constructor(
private val executionThread: PostExecutionThread,
private val repo: GamesRepo,
) : FlowUseCase<HomePreferences, PagingData<Game>>() {
override val dispatcher: CoroutineDispatcher
get() = executionThread.io
override fun execute(params: HomePreferences?): Flow<PagingData<Game>> {
val preferences = params as HomePreferences.FilterPreferences
preferences.apply {
return repo.fetchGames(query,
parentPlatforms,
platforms,
stores,
developers,
genres,
tags)
}
}
}
FlowUseCase
abstract class FlowUseCase<in Params, out T>() {
abstract val dispatcher: CoroutineDispatcher
abstract fun execute(params: Params? = null): Flow<T>
operator fun invoke(params: Params? = null) = execute(params).flowOn(dispatcher)
}
Here is the dependency :
object Pagination {
object Version {
const val pagingCompose = "1.0.0-alpha14"
}
const val pagingCompose = "androidx.paging:paging-compose:${Version.pagingCompose}"
}
I'm assuming that filterPreferences gives you Flow of some preference and useCase.execute returns Flow<PagingData<Model>>, correct?
I believe that the problem is in usage of flatMapLatest - it mixes page events of multiple useCase.execute calls together.
You should do something like this:
val games: Flow<Flow<PagingData<GameModel>>> = filterPreferences.mapLatest {
useCase.execute(it)
}.mapLatest {
it.map { pagingData -> pagingData.map { GameModel.GameItem(it) } }
}.mapLatest {
it.map { pagingData ->
pagingData.insertSeparators { before, after -> ... }
} // .cachedIn(viewModelScope)
}
This same structure works for us very well. I'm only not sure how cachedIn will work here, we are using a different caching mechanism, but you can try.

what is the equivalant of rxJava onNext and onError in coroutines

Hi I have some usecases which are written in Java which uses rxJava. I have converted them to kotlin files and instead of rxJava I have made them into couroutines suspend functions.
In my rxJava code I am making an api call from the usecase and it returns the result but at the same time onNext it does something and onError it does something.
How can I do the same thing in coroutines
here is my rxjava code
#PerApp
public class StartFuellingUseCase {
#Inject
App app;
#Inject
CurrentOrderStorage orderStorage;
#Inject
FuelOrderRepository repository;
#Inject
StartFuellingUseCase() {
// empty constructor for injection usage
}
public Observable<GenericResponse> execute(Equipment equipment) {
if (orderStorage.getFuelOrder() == null) return null;
DateTime startTime = new DateTime();
TimestampedAction action = new TimestampedAction(
app.getSession().getUser().getId(), null, startTime
);
return repository.startFuelling(orderStorage.getFuelOrder().getId(), action)
.subscribeOn(Schedulers.io())
.unsubscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.doOnNext(response -> onSuccess(startTime, equipment))
.doOnError(this::onError);
}
private void onSuccess(DateTime startTime, Equipment equipment) {
if (orderStorage.getFuelOrder() == null) return;
orderStorage.getFuelOrder().setStatus(FuelOrderData.STATUS_FUELLING);
equipment.getTimes().setStart(startTime);
app.saveState();
}
private void onError(Throwable e) {
Timber.e(e, "Error calling started fuelling! %s", e.getMessage());
}
}
I have re written the code in Kotlin using coroutines usecases
#PerApp
class StartFuellingUseCaseCoroutine #Inject constructor(
private val currentOrderStorage: CurrentOrderStorage,
private val fuelOrderRepository: FuelOrderRepository,
private val app: App
): UseCaseCoroutine<GenericResponse, StartFuellingUseCaseCoroutine.Params>() {
override suspend fun run(params: Params): GenericResponse {
val startTime = DateTime()
val action = TimestampedAction(
app.session.user.id, null, startTime
)
return fuelOrderRepository.startFuelling(
currentOrderStorage.fuelOrder!!.id,
action
)
//SHOULD RETURN THE VALUE FROM THE fuelOrderRepository.startFuelling
//AND ALSO
//ON NEXT
//CALL onSuccess PASSING startTime and equipment
//ON ERROR
//CALL onError
}
private fun onSuccess(startTime: DateTime, equipment: Equipment) {
if (currentOrderStorage.getFuelOrder() == null) return
currentOrderStorage.getFuelOrder()!!.setStatus(FuelOrderData.STATUS_FUELLING)
equipment.times.start = startTime
app.saveState()
}
private fun onError(errorMessage: String) {
Timber.e(errorMessage, "Error calling started fuelling! %s", errorMessage)
}
data class Params(val equipment: Equipment)
}
Can you please suggest how can i call onSuccess and onError similar to how we have in rxjava onnext and onError.
could you please suggest how to fix this
thanks
R
You can using Kotlin Flow like converted example below:
RxJava
private fun observable(
value: Int = 1
): Observable<Int> {
return Observable.create { emitter ->
emitter.onNext(value)
emitter.onError(RuntimeException())
}
}
Flow:
private fun myFlow(
value: Int = 1
): Flow<Int> {
return flow {
emit(value)
throw RuntimeException()
}
}
For more detail : https://developer.android.com/kotlin/flow
convert startFuelling to flow using flowOf, you can do below
return flowOf(repository
.startFuelling(orderStorage.getFuelOrder().getId(), action))
.onEach{response -> onSuccess(startTime, equipment)}
.catch{e -> onError(e) }
.flowOn(Dispatchers.IO) //this will make above statements to execute on IO
if you want to collect it on main thread, you can use launchIn
.onEach{ }
.launchIn(mainScope)//could be lifeCycleScope/viewModelScope
//or
CoroutineScope(Dispatchers.Main).launch{
flow.collect{}
}

how to resolve FATAL EXCEPTION: DefaultDispatcher-worker-4

I have written a new usecase to communicate to api which uses Flow, I am guessing I am not handing the threading properly in the Usecase between Main thread and IO thread,
This is the error I get
-01-18 02:20:40.555 26602-26870/com.xxx.xx.staging E/AndroidRuntime: FATAL EXCEPTION: DefaultDispatcher-worker-4
Process: com.xxx.xx.staging, PID: 26602
java.lang.IllegalStateException: Event bus [Bus "fill_order"] accessed from non-main thread null
at com.squareup.otto.ThreadEnforcer$2.enforce(ThreadEnforcer.java:47)
at com.squareup.otto.Bus.post(Bus.java:317)
at com.xxx.xx.fragments.filller.fillorder.BasefillOrderFragment.postFldeckStatusUpdateEvent(BasefillOrderFragment.java:117)
at com.xxx.xx.fragments.filller.fillorder.fillOrderDataFragment.postFldeckStatusUpdateEvent(fillOrderDataFragment.java:1955)
at com.xxx.xx.fragments.filller.fillorder.fillOrderDataFragment.updateView(fillOrderDataFragment.java:845)
at com.xx.xx.fragments.filller.fillorder.fillOrderDataFragment.legacyUpdateView(fillOrderDataFragment.java:2317)
at com.xxx.xx.clean.fillorder.presenter.BasefillDataPresenter.onStartfilllingSuccess(BasefillDataPresenter.kt:460)
at com.xxx.xx.clean.fillorder.presenter.BasefillDataPresenter.handleStartfilllingClicked(BasefillDataPresenter.kt:315)
at com.xxx.xx.clean.fillorder.presenter.BasefillDataPresenter.access$handleStartfilllingClicked(BasefillDataPresenter.kt:49)
The error is at handleStartfilllingClicked(view, it) in . collect
I am calling startfilllingUseCaseFlow usecase which might be the issue
#FlowPreview
fun initFlowSubscription(view: View) {
launch {
view.startfilllingObservableFlow
.conflate()
.catch {
onStartfilllingError(view)
}
.flatMapMerge {
if (!hasOpenInopIncidents()) {
equipmentProvider.get()?.let {
startfilllingUseCaseFlow(StartfilllingUseCaseFlow.Params(it))
}!!
} else {
val incidentOpenResponse = GenericResponse(false)
incidentOpenResponse.error = OPEN_INCIDENTS
flowOf(incidentOpenResponse)
}
}
.collect {
handleStartfilllingClicked(view, it) // ERROR IS HERE
}
}
}
private fun handleStartfilllingClicked(view: View, response: GenericResponse) {
if (response.success == false && response.error == OPEN_INCIDENTS) {
view.showCannotProceedInopIncidentDialog()
view.hideLoader(false)
return
}
onStartfilllingSuccess(view) // Error is here
}
StartfilllingUseCaseFlow
class StartfilllingUseCaseFlow #Inject constructor(
private val currentOrderStorage: CurrentOrderStorage,
private val fillOrderRepository: fillOrderRepository,
private val app: App
): FlowUseCase<StartfilllingUseCaseFlow.Params, GenericResponse>() {
override suspend fun run(params: Params): Flow<GenericResponse> {
val startTime = DateTime()
val action = TimestampedAction(
app.session.user.id, null, startTime
)
return flowOf(fillOrderRepository.startfilllingSuspend(
currentOrderStorage.fillOrder!!.id,
action
)).onEach { onSuccess(startTime, params.equipment) }
.catch { e -> e.message?.let { onError(it) } }
.flowOn(Dispatchers.IO)
}
private fun onSuccess(startTime: DateTime, equipment: Equipment) {
if (currentOrderStorage.getfillOrder() == null) return
currentOrderStorage.getfillOrder()!!.setStatus(fillOrderData.STATUS_fillLING)
equipment.times.start = startTime
app.saveState()
}
private fun onError(errorMessage: String) {
Timber.e(errorMessage, "Error calling started fillling! %s", errorMessage)
}
data class Params(val equipment: Equipment)
}
I am guessing I am not handing IO and Main thread properly here
abstract class FlowUseCase<in Params, out T>() {
abstract suspend fun run(params: Params): Flow<T>
suspend operator fun invoke(params: Params): Flow<T> = run(params).flowOn(Dispatchers.IO)
}
Could you suggest where I am gettig it wrong
Thanks
R
You are trying to update the view in Coroutines default thread. All views updates must be in the MainThread.
try:
fun initFlowSubscription(view: View) {
launch(Dispatchers.Main) {
//enter code here
}
}
This might give another error because you are doing too much process in the main thread. To avoid that you. could use "async" and update your view after:
Exemple:
fun initFlowSubscription(view: View) {
launch(Dispatchers.Main) {
val asyncValue = async(Dispatchers.IO) {
//Do yours suspend fun
}
val value = asyncValue.await()
}
}
This example should yours fine and avoid stopping the users UI
Coroutines sometimes consume unhandled exceptions (this seems to be more prevalent when using async/await). Anyway, add a CoroutineExceptionHandler in these cases.
CoroutineScope(IO + coroutineExceptionHandler).launch {
//perform background task
}
val coroutineExceptionHandler = CoroutineExceptionHandler{_, throwable ->
Log.d("coroutineExceptionHandler", "yes this happened")
throwable.printStackTrace()
}

How to perform call sequence to a REST API in Android App?

I'm having a hard time making a call to my api. I'm using Reactivex with kotlin and Flowables. My API returns a list of items if the date I passed by the "If-Modified_since" header is less than the last update.
If there is no update I get as an app return android app a 304 error.
I need to do the following procedure.
1-> I make a call to the api
2-> If the call is successful, save the list in Realm and return to the viewmodel
3-> If the error is 304, I perform a cache search (Realm) of the items
4-> If it is another error, I return the error normally for the ViewModel
Here is the code below, but I'm not sure if it's that way.
override fun getTickets(eventId: String): Flowable<List<Ticket>> {
return factory
.retrieveRemoteDataStore()
.getTickets(eventId)
.map {
saveTickets(it)
it
}.onErrorResumeNext { t: Throwable ->
if (t is HttpException && t.response().code() == 304) {
factory.retrieveCacheDataStore().getTickets(eventId)
} else
//Should return error
}
The question is, what is the best way to do this?
Thank you.
I'm going to assume, that you're using Retrofit. If that's the case, then you could wrap your getTickets call in Single<Response<SomeModel>>. This way, on first map you can check the errorcode, something among the lines of:
...getTickets(id)
.map{ response ->
when {
response.isSuccessful && response.body!=null -> {
saveTickets(it)
it
}
!response.isSuccessful && response.errorCode() == 304 -> {
factory.retrieveCacheDataStore().getTickets(eventId)
}
else -> throw IOException()
}
}
This could of course be made pretty using standard/extension functions but wanted to keep it simple for readability purposes.
Hope this helps!
Most of my comments are my explanations.
data class Ticket(val id:Int) {
companion object {
fun toListFrom(jsonObject: JSONObject): TICKETS {
/**do your parsing of data transformation here */
return emptyList()
}
}
}
typealias TICKETS = List<Ticket>
class ExampleViewModel(): ViewModel() {
private var error: BehaviorSubject<Throwable> = BehaviorSubject.create()
private var tickets: BehaviorSubject<TICKETS> = BehaviorSubject.create()
/**public interfaces that your activity or fragment talk to*/
fun error(): Observable<Throwable> = this.error
fun tickets(): Observable<TICKETS> = this.tickets
fun start() {
fetch("http://api.something.com/v1/tickets/")
.subscribeOn(Schedulers.io())
.onErrorResumeNext { t: Throwable ->
if (t.message == "304") {
get(3)
} else {
this.error.onNext(t)
/** this makes the chain completed gracefuly without executing flatMap or any other operations*/
Observable.empty()
}
}
.flatMap(this::insertToRealm)
.subscribe(this.tickets)
}
private fun insertToRealm(tickets: TICKETS) : Observable<TICKETS> {
/**any logic here is mainly to help you save into Realm**/
/** I think realm has the option to ignore items that are already in the db*/
return Observable.empty()
}
private fun get(id: Int): Observable<TICKETS> {
/**any logic here is mainly to help you fetch from your cache**/
return Observable.empty()
}
private fun fetch(apiRoute: String): Observable<TICKETS> {
/**
* boilerplate code
wether you're using Retrofit or Okhttp, that's the logic you
should try to have
* */
val status: Int = 0
val rawResponse = ""
val error: Throwable? = null
val jsonResponse = JSONObject(rawResponse)
return Observable.defer {
if (status == 200) {
Observable.just(Ticket.toListFrom(jsonResponse))
}
else if (status == 304) {
Observable.error<TICKETS>(Throwable("304"))
}
else {
Observable.error<TICKETS>(error)
}
}
}
override fun onCleared() {
super.onCleared()
this.error = BehaviorSubject.create()
this.tickets = BehaviorSubject.create()
}
}

multiple async-await in kotlin

obj in promoType = [list of string]
its more like 10 firebase queries are running here, looking in 10 particular set of nodes and going down further.
what i'm not sure, whether i require to put on async / await on each of the queries, but all i want is 10 of these queries to run and then result me in whether a couponKey is empty or not. All i want to do is to display whether a coupon entered was correct or not.
further, in changeUserType(couponKey, couponFoundAtKey), some database write operations occur.
fun checkPromo(promoCodeET: String) = async(UI) {
try {
val database = PersistentFirebaseUtil.getDatabase().reference
val job = async(CommonPool) {
for (obj in promoType) {
val query = database.child("promos").child(obj).orderByChild("promoCode").equalTo(promoCodeET)
query.addListenerForSingleValueEvent(object :
ValueEventListener {
override fun onDataChange(dataSnapshot: DataSnapshot) {
if (dataSnapshot.exists()) {
couponKey = dataSnapshot.key.toString()
couponFoundAtKey = dataSnapshot.children.first().key.toString()
if (couponKey.isNotEmpty())
changeUserType(couponKey, couponFoundAtKey)
flag = true
}
}
override fun onCancelled(error: DatabaseError) {
// Failed to read value
}
})
if (flag) break
}
}
job.await()
}
catch (e: Exception) {
}
finally {
if (couponKey.isEmpty()){
Toast.makeText(this#Coupon, "Invalid coupon", Toast.LENGTH_LONG).show()
}
flag = true
}
}
There are several things I find wrong with your code:
You have an outer async(UI) which doesn't make sense
Your inner async(CommonPool) doesn't make sense either, because your database call is already async
You use the antipattern where you immediately await after async, making it not really "async" (but see above, the whole thing is async with or without this)
Your fetching function has a side-effect of changing the user type
To transfer the results to the caller, you again use side-effects instead of the return value
Your code should be much simpler. You should declare a suspend fun whose return value is the pair (couponKey, coupon):
suspend fun fetchPromo(promoType: String, promoCodeET: String): Pair<String, String>? =
suspendCancellableCoroutine { cont ->
val database = PersistentFirebaseUtil.getDatabase().reference
val query = database.child("promos").child(promoType)
.orderByChild("promoCode").equalTo(promoCodeET)
query.addListenerForSingleValueEvent(object : ValueEventListener {
override fun onDataChange(dataSnapshot: DataSnapshot) {
cont.resume(
dataSnapshot
.takeIf { it.exists() }
?.let { snapshot ->
snapshot.key.toString()
.takeIf { it.isNotEmpty() }
?.let { key ->
Pair(key, snapshot.children.first().key.toString())
}
}
)
}
override fun onCancelled(error: DatabaseError?) {
if (error != null) {
cont.resumeWithException(MyException(error))
} else {
cont.cancel()
}
}
})
}
To call this function, use a launch(UI) at the call site. Change the user type once you get a non-null value:
launch(UI) {
var found = false
for (type in promoType) {
val (couponKey, coupon) = fetchPromo(type, "promo-code-et") ?: continue
found = true
withContext(CommonPool) {
changeUserType(couponKey, coupon)
}
break
}
if (!found) {
Toast.makeText(this#Coupon, "Invalid coupon", Toast.LENGTH_LONG).show()
}
}
You say that changeUserType performs some database operations, so I wrapped them in a withContext(CommonPool).
Note also that I extracted the loop over promo types outside the function. This will result in queries being performed sequentially, but you can just write different calling code to achieve parallel lookup:
var numDone = 0
var found = false
promoType.forEach { type ->
launch(UI) {
fetchPromo(type, "promo-code-et")
.also { numDone++ }
?.also { (couponKey, coupon) ->
found = true
launch(CommonPool) {
changeUserType(couponKey, coupon)
}
}
?: if (numDone == promoType.size && !found) {
Toast.makeText(this#Coupon, "Invalid coupon", Toast.LENGTH_LONG).show()
}
}
}

Categories

Resources