Sandwich was invented for constructing the standardized response interface from the network response

Overview

sandwich

Google
License API Build Status Javadoc Medium Profile

Why Sandwich?

Sandwich was invented for constructing the standardized response interface from the network response. We can handle successful data, error response, and an exceptional case intuitively using useful extensions of the interface. So we don't need to design and implement wrapper classes like Resource or Result, and it helps to reduce our work time and makes focus on only business codes. Sandwich supports handling error responses globally, Mapper, Operator, and great compatibilities like toLiveData or toFlow. Also, we can implement great harmony with coroutines and flow in our projects using this library.

Download

Maven Central Jitpack

🥪 Sandwich has been downloaded in more than 70k Android projects all over the world!

Gradle

Add below codes to your root build.gradle file (not your module build.gradle file).

allprojects {
    repositories {
        mavenCentral()
    }
}

And add a dependency code to your module's build.gradle file.

dependencies {
    implementation "com.github.skydoves:sandwich:1.2.1"
}

SNAPSHOT

Sandwich
Snapshots of the current development version of Sandwich are available, which track the latest versions.

repositories {
   maven { url 'https://oss.sonatype.org/content/repositories/snapshots/' }
}

Usecase

You can reference the good use cases of this library in the below repositories.

  • Pokedex - 🗡️ Android Pokedex using Hilt, Motion, Coroutines, Flow, Jetpack (Room, ViewModel, LiveData) based on MVVM architecture.
  • DisneyMotions - 🦁 A Disney app using transformation motions based on MVVM (ViewModel, Coroutines, LiveData, Room, Repository, Koin) architecture.
  • MarvelHeroes - ❤️ A sample Marvel heroes application based on MVVM (ViewModel, Coroutines, LiveData, Room, Repository, Koin) architecture.
  • TheMovies2 - 🎬 A demo project using The Movie DB based on Kotlin MVVM architecture and material design & animations.

Table of contents

Usage

ApiResponse

ApiResponse is an interface for constructing standard responses from the response of the retrofit call. It provides useful extensions for handling successful data and error responses. We can get ApiResponse using the scope extension request from the Call. The below example is the basic of getting an ApiResponse from an instance of the Call.

> } val disneyService = retrofit.create(DisneyService::class.java) // fetches a model list from the network and getting [ApiResponse] asynchronously. disneyService.fetchDisneyPosterList().request { response -> when (response) { // handles the success case when the API request gets a successful response. is ApiResponse.Success -> { posterDao.insertPosterList(response.data) livedata.post(response.data) } // handles error cases when the API request gets an error response. // e.g., internal server error. is ApiResponse.Failure.Error -> { // stub error case Timber.d(message()) // handles error cases depending on the status code. when (statusCode) { StatusCode.InternalServerError -> toastLiveData.postValue("InternalServerError") StatusCode.BadGateway -> toastLiveData.postValue("BadGateway") else -> toastLiveData.postValue("$statusCode(${statusCode.code}): ${message()}") } } // handles exceptional cases when the API request gets an exception response. // e.g., network connection error, timeout. is ApiResponse.Failure.Exception -> { // stub exception case } } } ">
interface DisneyService {
  @GET("/")
  fun fetchDisneyPosterList(): Call<List<Poster>>
}

val disneyService = retrofit.create(DisneyService::class.java)
// fetches a model list from the network and getting [ApiResponse] asynchronously.
disneyService.fetchDisneyPosterList().request { response ->
      when (response) {
        // handles the success case when the API request gets a successful response.
        is ApiResponse.Success -> {
          posterDao.insertPosterList(response.data)
          livedata.post(response.data)
        }
        // handles error cases when the API request gets an error response.
        // e.g., internal server error.
        is ApiResponse.Failure.Error -> {
          // stub error case
          Timber.d(message())

          // handles error cases depending on the status code.
          when (statusCode) {
            StatusCode.InternalServerError -> toastLiveData.postValue("InternalServerError")
            StatusCode.BadGateway -> toastLiveData.postValue("BadGateway")
            else -> toastLiveData.postValue("$statusCode(${statusCode.code}): ${message()}")
          }
        }
        // handles exceptional cases when the API request gets an exception response.
        // e.g., network connection error, timeout.
        is ApiResponse.Failure.Exception -> {
          // stub exception case
        }
      }
    }

ApiResponse.Success

A standard Success response interface from Retrofit network responses.
We can get the successful body data of the response, StatusCode, Headers and etc from the ApiResponse.Success.

val data: List<Poster>? = response.data
val statusCode: StatusCode = response.statusCode
val headers: Headers = response.headers

ApiResponse.Failure.Error

A standard failure response interface from Retrofit network responses.
API communication conventions do not match or applications need to handle errors. e.g., internal server error.

val errorBody: ResponseBody? = response.errorBody
val statusCode: StatusCode = response.statusCode
val headers: Headers = response.headers

ApiResponse.Failure.Exception

An unexpected exception occurs while creating requests or processing an response in the client side. e.g., Network connection error.

ApiResponse Extensions

We can handle response cases conveniently using extensions.

onSuccess, onError, onException

We can use these scope functions to the ApiResponse, we handle the response cases without using the if-else/when clause.
Each scope will be executed or not depending on the type of the ApiResponse. (success, error, exception)

disneyService.fetchDisneyPosterList().request { response ->
    response.onSuccess {
     // this scope will be only executed if the request would successful.
     // handle the success case
    }.onError {
      // this scope will be only executed when the request would get errors.
      // handle the error case
    }.onException {
     // this scope will be only executed when the request would get exceptions.
     // handle the exception case
    }
  }

ApiResponse for coroutines

We can use the suspend keyword in our Retrofit services and gets ApiResponse<*> as a response type.
Build your Retrofit using the CoroutinesResponseCallAdapterFactory call adapter factory.

.addCallAdapterFactory(CoroutinesResponseCallAdapterFactory.create())

We should make normal service functions as suspension functions using the suspend keyword. And we can get the ApiResponse<*> as a response type. So we can get the ApiResponse from the Retrofit service call, and handle them right away using extensions.

> } ">
interface DisneyCoroutinesService {

  @GET("DisneyPosters.json")
  suspend fun fetchDisneyPosterList(): ApiResponse<List<Poster>>
}

We can use like the below.

class MainCoroutinesViewModel constructor(disneyService: DisneyCoroutinesService) : ViewModel() {

  val posterListLiveData: MutableLiveData<List<Poster>>

  init {
     val response = disneyService.fetchDisneyPosterList()
     response.onSuccess {
       // handles the success case when the API request gets a successful response.
       posterDao.insertPosterList(data)
       posterListLiveData.post(data)
      }.onError {
       // handles error cases when the API request gets an error response.
      }.onException {
       // handles exceptional cases when the API request gets an exception response.
      }
    }
  }
}

suspendOnSuccess, suspendOnError, suspendOnException

We can use suspension extensions for invoking suspension related functions inside scopes. These extensions are not functionally different from the onSuccess, onError, and onException extensions.
Generally, we can use this way on the repository pattern.

flow {
  val response = disneyService.fetchDisneyPosterList()
  response.suspendOnSuccess {
    posterDao.insertPosterList(data)
    emit(data)
  }.suspendOnError {
    // handles error cases
  }.suspendOnFailure {
    // handles exceptional cases
  }
}.flowOn(Dispatchers.IO)

Retrieve success data

If we want to retrieve the encapsulated success data from the ApiResponse directly, we can use the below functionalities.

getOrNull

Returns the encapsulated data if this instance represents ApiResponse.Success or returns null if this is failed.

val data: List<Poster>? = disneyService.fetchDisneyPosterList().getOrNull()

getOrElse

Returns the encapsulated data if this instance represents ApiResponse.Success or returns a default value if this is failed.

val data: List<Poster>? = disneyService.fetchDisneyPosterList().getOrElse(emptyList())

getOrThrow

Returns the encapsulated data if this instance represents ApiResponse.Success or throws the encapsulated Throwable exception if this is failed.

try {
  val data: List<Poster>? = disneyService.fetchDisneyPosterList().getOrThrow()
} catch (e: Exception) {
  e.printStackTrace()
}

Mapper

Mapper is useful when we want to transform the ApiResponse.Success or ApiResponse.Failure.Error to our custom model in our ApiResponse extension scopes.

ApiSuccessModelMapper

We can map the ApiResponse.Success model to our custom model using the SuccessPosterMapper and map extension.

object SuccessPosterMapper : ApiSuccessModelMapper<List<Poster>, Poster?> {

  override fun map(apiErrorResponse: ApiResponse.Success<List<Poster>>): Poster? {
    return apiErrorResponse.data.first()
  }
}

// Maps the success response data.
val poster: Poster? = map(SuccessPosterMapper)

We can use the map extension with a lambda.

// Maps the success response data using a lambda.
map(SuccessPosterMapper) { poster ->
  emit(poster) // we can use the `this` keyword instead of the poster.
}

If we want to get the transformed data from the start in the lambda, we can give the mapper as a parameter for the onSuccess or suspendOnSuccess.

.suspendOnSuccess(SuccessPosterMapper) {
    val poster = this
}

ApiErrorModelMapper

We can map the ApiResponse.Failure.Error model to our custom error model using the ApiErrorModelMapper and map extension.

// Create your custom error model.
data class ErrorEnvelope(
  val code: Int,
  val message: String
)

// An error response mapper.
// Create an instance of your custom model using the `ApiResponse.Failure.Error` in the `map`.
object ErrorEnvelopeMapper : ApiErrorModelMapper<ErrorEnvelope> {

  override fun map(apiErrorResponse: ApiResponse.Failure.Error<*>): ErrorEnvelope {
    return ErrorEnvelope(apiErrorResponse.statusCode.code, apiErrorResponse.message())
  }
}

// Maps an error response.
response.onError {
  // Maps an ApiResponse.Failure.Error to a custom error model using the mapper.
  map(ErrorEnvelopeMapper) {
     val code = this.code
     val message = this.message
  }
}

If we want to get the transformed data from the start in the lambda, we can give the mapper as a parameter for the onError or suspendOnError.

.suspendOnError(ErrorEnvelopeMapper) {
    val message = this.message
}

Operator

We can delegate the onSuccess, onError, onException using the operator extension and ApiResponseOperator. Operator is very useful if we want to handle ApiResponses standardly or reduce the role of the ViewModel and Repository. Here is an example of standardized error and exception handing.

ViewModel

We can delegate and operate the CommonResponseOperator using the operate extension.

disneyService.fetchDisneyPosterList().operator(
      CommonResponseOperator(
        success = {
          emit(data)
          Timber.d("success data: $data")
        },
        application = getApplication()
      )
    )

CommonResponseOperator

The CommonResponseOperator extends ApiResponseOperator with the onSuccess, onError, onException override methods. They will be executed depending on the type of the ApiResponse.

) { apiResponse.run { Timber.d(message()) toast(message()) } } } ">
/** A common response operator for handling [ApiResponse]s regardless of its type. */
class CommonResponseOperator<T> constructor(
  private val success: suspend (ApiResponse.Success<T>) -> Unit,
  private val application: Application
) : ApiResponseOperator() {

  // handles error cases when the API request gets an error response.
  override fun onSuccess(apiResponse: ApiResponse.Success<T>) = success(apiResponse)

  // handles error cases depending on the status code.
  // e.g., internal server error.
  override fun onError(apiResponse: ApiResponse.Failure.Error<T>) {
    apiResponse.run {
      Timber.d(message())
      
      // map the ApiResponse.Failure.Error to a customized error model using the mapper.
      map(ErrorEnvelopeMapper) {
        Timber.d("[Code: $code]: $message")
      }
    }
  }

  // handles exceptional cases when the API request gets an exception response.
  // e.g., network connection error, timeout.
  override fun onException(apiResponse: ApiResponse.Failure.Exception<T>) {
    apiResponse.run {
      Timber.d(message())
      toast(message())
    }
  }
}

Operator for coroutines

If we want to operate and delegate a suspension lambda to the operator, we can use the suspendOperator extension and ApiResponseSuspendOperator class.

ViewModel

We can use suspension functions like emit in the success lambda.

flow {
  disneyService.fetchDisneyPosterList().suspendOperator(
      CommonResponseOperator(
        success = {
          emit(data)
          Timber.d("success data: $data")
        },
        application = getApplication()
      )
    )
}.flowOn(Dispatchers.IO)

CommonResponseOperator

The CommonResponseOperator extends ApiResponseSuspendOperator with suspend override methods.

class CommonResponseOperator<T> constructor(
  private val success: suspend (ApiResponse.Success<T>) -> Unit,
  private val application: Application
) : ApiResponseSuspendOperator() {

  // handles the success case when the API request gets a successful response.
  override suspend fun onSuccess(apiResponse: ApiResponse.Success<T>) = success(apiResponse)

  // ... //

Global operator

We can operate an operator globally all ApiResponses in our application using the SandwichInitializer. So we don't need to create every instance of the Operators or use dependency injection for handling common operations. Here is an example of handling a global operator about the ApiResponse.Failure.Error and ApiResponse.Failure.Exception. In this example, We will handle ApiResponse.Success manually.

Application class

We can initialize the global operator on the SandwichInitializer.sandwichOperator. It is recommended to initialize it in the Application class.

class SandwichDemoApp : Application() {

  override fun onCreate() {
    super.onCreate()
    
    // We will handle only the error and exceptional cases,
    // so we don't need to mind the generic type of the operator.
    SandwichInitializer.sandwichOperator = GlobalResponseOperator<Any>(this)

    // ... //

GlobalResponseOperator

The GlobalResponseOperator can extend any operator (ApiResponseSuspendOperator or ApiResponseOperator)

toast("BadGateway") else -> toast("$statusCode(${statusCode.code}): ${message()}") } // map the ApiResponse.Failure.Error to a customized error model using the mapper. map(ErrorEnvelopeMapper) { Timber.d("[Code: $code]: $message") } } } } // handles exceptional cases when the API request gets an exception response. // e.g., network connection error, timeout. override suspend fun onException(apiResponse: ApiResponse.Failure.Exception) { withContext(Dispatchers.Main) { apiResponse.run { Timber.d(message()) toast(message()) } } } private fun toast(message: String) { Toast.makeText(application, message, Toast.LENGTH_SHORT).show() } } ">
class GlobalResponseOperator<T> constructor(
  private val application: Application
) : ApiResponseSuspendOperator() {

  // The body is empty, because we will handle the success case manually.
  override suspend fun onSuccess(apiResponse: ApiResponse.Success<T>) { }

  // handles error cases when the API request gets an error response.
  // e.g., internal server error.
  override suspend fun onError(apiResponse: ApiResponse.Failure.Error<T>) {
    withContext(Dispatchers.Main) {
      apiResponse.run {
        Timber.d(message())

        // handling error based on status code.
        when (statusCode) {
          StatusCode.InternalServerError -> toast("InternalServerError")
          StatusCode.BadGateway -> toast("BadGateway")
          else -> toast("$statusCode(${statusCode.code}): ${message()}")
        }

        // map the ApiResponse.Failure.Error to a customized error model using the mapper.
        map(ErrorEnvelopeMapper) {
          Timber.d("[Code: $code]: $message")
        }
      }
    }
  }

  // handles exceptional cases when the API request gets an exception response.
  // e.g., network connection error, timeout.
  override suspend fun onException(apiResponse: ApiResponse.Failure.Exception<T>) {
    withContext(Dispatchers.Main) {
      apiResponse.run {
        Timber.d(message())
        toast(message())
      }
    }
  }

  private fun toast(message: String) {
    Toast.makeText(application, message, Toast.LENGTH_SHORT).show()
  }
}

ViewModel

We don't need to use the operator expression. The global operator will be operated automatically, so we should handle only the ApiResponse.Success.

flow {
  disneyService.fetchDisneyPosterList().
    suspendOnSuccess {
      emit(data)
    }
}.flowOn(Dispatchers.IO).asLiveData()

Merge

We can merge multiple ApiResponses as one ApiResponse depending on the policy.
The below example is merging three ApiResponse as one if every three ApiResponses are successful.

disneyService.fetchDisneyPosterList(page = 0).merge(
   disneyService.fetchDisneyPosterList(page = 1),
   disneyService.fetchDisneyPosterList(page = 2),
   mergePolicy = ApiResponseMergePolicy.PREFERRED_FAILURE
).onSuccess { 
  // handles the success case when the API request gets a successful response.
}.onError { 
  // handles error cases when the API request gets an error response.
}

ApiResponseMergePolicy

ApiResponseMergePolicy is a policy for merging response data depend on the success or not.

  • IGNORE_FAILURE: Regardless of the merging order, ignores failure responses in the responses.
  • PREFERRED_FAILURE (default): Regardless of the merging order, prefers failure responses in the responses.

toLiveData

We can get a LiveData that contains successful data if the response is an ApiResponse.Success. If our goal is only getting a LiveData that holds successful data, we can emit the onSuccess extension.

posterListLiveData = liveData(viewModelScope.coroutineContext + Dispatchers.IO) {
  emitSource(
    disneyService.fetchDisneyPosterList()
     .onError {
      // handles error cases when the API request gets an error response.
     }.onException {
      // handles exceptional cases when the API request gets an exception response.
     }.toLiveData()) // returns an observable LiveData
}

If we want to transform the original data and get a LiveData that contains transformed data using successful data if the response is an ApiResponse.Success.

posterListLiveData = liveData(viewModelScope.coroutineContext + Dispatchers.IO) {
  emitSource(
   disneyService.fetchDisneyPosterList()
    .onError {
      // handles error cases when the API request gets an error response.
    }.onException {
      // handles exceptional cases when the API request gets an exception response.
    }.toLiveData {
      this.onEach { poster -> poster.date = SystemClock.currentThreadTimeMillis() }
    }) // returns an observable LiveData
    }

toFlow

We can get a Flow that emits successful data if the response is an ApiResponse.Success and the data is not null.

disneyService.fetchDisneyPosterList()
  .onError {
    // handles error cases when the API request gets an error response.
  }.onException {
    // handles exceptional cases when the API request gets an exception response.
  }.toFlow() // returns a coroutines flow
  .flowOn(Dispatchers.IO)

If we want to transform the original data and get a flow that contains transformed data using successful data if the response is an ApiResponse.Success and the data is not null.

val response = pokedexClient.fetchPokemonList(page = page)
response.toFlow { pokemons ->
  pokemons.forEach { pokemon -> pokemon.page = page }
  pokemonDao.insertPokemonList(pokemons)
  pokemonDao.getAllPokemonList(page)
}.flowOn(Dispatchers.IO)

ResponseDataSource

ResponseDataSource is an implementation of the DataSource interface.

  • Asynchronously send requests.
  • A temporarily response data holder from the REST API call for caching data on memory.
  • Observable for every response.
  • Retry fetching data when the request gets failure.
  • Concat another DataSource and request sequentially.
  • Disposable of executing works.

Combine

Combine a Call and lambda scope for constructing the DataSource.

val disneyService = retrofit.create(DisneyService::class.java)

val dataSource = ResponseDataSource<List<Poster>>()
dataSource.combine(disneyService.fetchDisneyPosterList()) { response ->
    // stubs
}

Request

Request API network call asynchronously.
If the request is successful, this data source will hold the success response model.
In the next request after the success, request() returns the cached API response.
If we need to fetch a new response data or refresh, we can use invalidate().

dataSource.request()

Retry

Retry fetching data (re-request) if your request got failure.

// retry fetching data 3 times with 5000 milli-seconds time interval when the request gets failure.
dataSource.retry(3, 5000L)

ObserveResponse

Observes every response data ApiResponse from the API call request.

dataSource.observeResponse {
   Timber.d("observeResponse: $it")
}

RetainPolicy

We can limit the policy for retaining data on the temporarily internal storage.
The default policy is no retaining any fetched data from the network, but we can set the policy using dataRetainPolicy method.

// Retain fetched data on the memory storage temporarily.
// If request again, returns the retained data instead of re-fetching from the network.
dataSource.dataRetainPolicy(DataRetainPolicy.RETAIN)

Invalidate

Invalidate a cached (holding) data and re-fetching the API request.

dataSource.invalidate()

Concat

Concat an another DataSource and request API call sequentially if the API call getting successful.

val dataSource2 = ResponseDataSource<List<PosterDetails>>()
dataSource2.retry(3, 5000L).combine(disneyService.fetchDetails()) {
    // stubs handling dataSource2 response
}

dataSource1
   .request() // request() must be called before concat. 
   .concat(dataSource2) // request dataSource2's API call after the success of the dataSource1.
   .concat(dataSource3) // request dataSource3's API call after the success of the dataSource2.

asLiveData

we can observe fetched data via DataSource as a LiveData.

val posterListLiveData: LiveData<List<Poster>>

init {
    posterListLiveData = disneyService.fetchDisneyPosterList().toResponseDataSource()
      .retry(3, 5000L)
      .dataRetainPolicy(DataRetainPolicy.RETAIN)
      .request {
        // ... //
      }.asLiveData()
}

Disposable

We can make it joins onto CompositeDisposable as a disposable using the joinDisposable function. It must be called before request() method. The below example is using in ViewModel. We can clear the CompositeDisposable in the onCleared() override method.

private val disposable = CompositeDisposable()

init {
    disneyService.fetchDisneyPosterList().toResponseDataSource()
      // retry fetching data 3 times with 5000L interval when the request gets failure.
      .retry(3, 5000L)
      // joins onto CompositeDisposable as a disposable and dispose onCleared().
      .joinDisposable(disposable)
      .request {
        // ... //
      }
}

override fun onCleared() {
    super.onCleared()
    if (!disposable.disposed) {
      disposable.clear()
    }
  }

Here is the example of the ResponseDataSource in the MainViewModel.

toastLiveData.postValue("InternalServerError") StatusCode.BadGateway -> toastLiveData.postValue("BadGateway") else -> toastLiveData.postValue("$statusCode(${statusCode.code}): ${message()}") } // map the ApiResponse.Failure.Error to a customized error model using the mapper. map(ErrorEnvelopeMapper) { Timber.d(this.toString()) } } // handles exceptional cases when the API request gets an exception response. // e.g. network connection error, timeout. .onException { Timber.d(message()) toastLiveData.postValue(message()) } } // observe every API request responses. .observeResponse { Timber.d("observeResponse: $it") } // request API network call asynchronously. // if the request is successful, the data source will hold the success data. // in the next request after success, returns the cached API response. // if you want to fetch a new response data, use invalidate(). .request() } override fun onCleared() { super.onCleared() if (!disposable.disposed) { disposable.clear() } } } ">
class MainViewModel constructor(
  private val disneyService: DisneyService
) : ViewModel() {

  // request API call Asynchronously and holding successful response data.
  private val dataSource = ResponseDataSource<List<Poster>>()

  val posterListLiveData = MutableLiveData<List<Poster>>()
  val toastLiveData = MutableLiveData<String>()
  private val disposable = CompositeDisposable()

  /** fetch poster list data from the network. */
  fun fetchDisneyPosters() {
    dataSource
      // retry fetching data 3 times with 5000 time interval when the request gets failure.
      .retry(3, 5000L)
      // joins onto CompositeDisposable as a disposable and dispose onCleared().
      .joinDisposable(disposable)
      // combine network service to the data source.
      .combine(disneyService.fetchDisneyPosterList()) { response ->
        // handles the success case when the API request gets a successful response.
        response.onSuccess {
          Timber.d("$data")
          posterListLiveData.postValue(data)
        }
          // handles error cases when the API request gets an error response.
          // e.g. internal server error.
          .onError {
            Timber.d(message())

            // handling error based on status code.
            when (statusCode) {
              StatusCode.InternalServerError -> toastLiveData.postValue("InternalServerError")
              StatusCode.BadGateway -> toastLiveData.postValue("BadGateway")
              else -> toastLiveData.postValue("$statusCode(${statusCode.code}): ${message()}")
            }

            // map the ApiResponse.Failure.Error to a customized error model using the mapper.
            map(ErrorEnvelopeMapper) {
              Timber.d(this.toString())
            }
          }
          // handles exceptional cases when the API request gets an exception response.
          // e.g. network connection error, timeout.
          .onException {
            Timber.d(message())
            toastLiveData.postValue(message())
          }
      }
      // observe every API request responses.
      .observeResponse {
        Timber.d("observeResponse: $it")
      }
      // request API network call asynchronously.
      // if the request is successful, the data source will hold the success data.
      // in the next request after success, returns the cached API response.
      // if you want to fetch a new response data, use invalidate().
      .request()
  }

  override fun onCleared() {
    super.onCleared()
    if (!disposable.disposed) {
      disposable.clear()
    }
  }
}

DataSourceCallAdapterFactory

We can get the DataSource directly from the Retrofit service.
Add a call adapter factory DataSourceCallAdapterFactory to your Retrofit builder.
And change the return type of your service Call to DataSource.

> } ">
Retrofit.Builder()
    ...
    .addCallAdapterFactory(DataSourceCallAdapterFactory.create())
    .build()

interface DisneyService {
  @GET("DisneyPosters.json")
  fun fetchDisneyPosterList(): DataSource<List<Poster>>
}

Here is an example of the DataSource in the MainViewModel.

> { override fun observe(response: ApiResponse>) { // handle the case when the API request gets a success response. response.onSuccess { Timber.d("$data") posterListLiveData.postValue(data) } } }) .request() // must call request() ">
class MainViewModel constructor(disneyService: DisneyService) : ViewModel() {

  // request API call Asynchronously and holding successful response data.
  private val dataSource: DataSource<List<Poster>>

    init {
    Timber.d("initialized MainViewModel.")

    dataSource = disneyService.fetchDisneyPosterList()
      // retry fetching data 3 times with 5000L interval when the request gets failure.
      .retry(3, 5000L)
      .observeResponse(object : ResponseObserver<List<Poster>> {
        override fun observe(response: ApiResponse<List<Poster>>) {
          // handle the case when the API request gets a success response.
          response.onSuccess {
            Timber.d("$data")
            posterListLiveData.postValue(data)
          }
        }
      })
      .request() // must call request()

CoroutinesDataSourceCallAdapterFactory

We can get the DataSource directly from the Retrofit service using with suspend.

> } ">
Retrofit.Builder()
    ...
    .addCallAdapterFactory(CoroutinesDataSourceCallAdapterFactory.create())
    .build()

interface DisneyService {
  @GET("DisneyPosters.json")
  fun fetchDisneyPosterList(): DataSource<List<Poster>>
}

Here is an exmaple of the DataSource in the MainViewModel.

class MainCoroutinesViewModel constructor(disneyService: DisneyCoroutinesService) : ViewModel() {

  val posterListLiveData: LiveData<List<Poster>>

  init {
    Timber.d("initialized MainViewModel.")

    posterListLiveData = liveData(viewModelScope.coroutineContext + Dispatchers.IO) {
      emitSource(disneyService.fetchDisneyPosterList().toResponseDataSource()
        // retry fetching data 3 times with 5000L interval when the request gets failure.
        .retry(3, 5000L)
        // a retain policy for retaining data on the internal storage
        .dataRetainPolicy(DataRetainPolicy.RETAIN)
        // request API network call asynchronously.
        .request {
          // handle the case when the API request gets a success response.
          onSuccess {
            Timber.d("$data")
          }.onError { // handle the case when the API request gets a error response.
              Timber.d(message())
            }.onException {  // handle the case when the API request gets a exception response.
              Timber.d(message())
            }
        }.asLiveData())
    }
  }
}

toResponseDataSource

We can change DataSource to ResponseDataSource after getting instance from network call using the below method.

private val dataSource: ResponseDataSource<List<Poster>>

  init {
    dataSource = disneyService.fetchDisneyPosterList().toResponseDataSource()

    //...
  }

Find this library useful? ❤️

Support it by joining stargazers for this repository.
And follow me for my next creations! 🤩

License

Copyright 2020 skydoves (Jaewoong Eum)

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

   http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Comments
  • crash on 401 unauthorized status

    crash on 401 unauthorized status

    Please complete the following information:

    • Library Version [e.g. v1.0.0]
    • Affected Device(s) [e.g. Samsung Galaxy s10 with Android 9.0]

    Describe the Bug: when I send A user pass to my api which unauthorized and throws 401 status code. the apiresponse call adapter factory crashed.

    --------- beginning of crash
    

    2020-09-12 14:16:55.564 7771-7771/com.skydoves.pokedex E/AndroidRuntime: FATAL EXCEPTION: main Process: com.skydoves.pokedex, PID: 7771 retrofit2.HttpException: HTTP 401 Response.error() at retrofit2.KotlinExtensions$await$2$2.onResponse(KotlinExtensions.kt:53) at com.skydoves.sandwich.coroutines.ApiResponseCallDelegate$enqueueImpl$1.onResponse(ApiResponseCallDelegate.kt:33) at retrofit2.OkHttpCall$1.onResponse(OkHttpCall.java:161) at okhttp3.internal.connection.RealCall$AsyncCall.run(RealCall.kt:504) at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1167) at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:641) at java.lang.Thread.run(Thread.java:919) Add a clear description about the problem.

    Expected Behavior:

    A clear description of what you expected to happen.

    onError expected to be fired

    bug Released 
    opened by iceberg1369 11
  • Not nullable body on success

    Not nullable body on success

    In ApiResponse.Success the data property is nullable. And i understand why it is - it's just a wrapper around retrofit response logic. But, when i'm using clean suspend fun something(): Type i will get an exception if the returned type is null. Which is good, i do expect that!

    If i wanted null to be possible in my response - i would use a nullable type as a generic parameter. If i don't - my assumption is that in case of success it will always be properly created. The need for another null check does make the whole Success type pointless in my opinion. Because i'm still not sure if it really was a success, i have to do another check..

    Have you considered handling that?

    opened by jakoss 6
  • How to get error message when onError

    How to get error message when onError

        data class Error<T>(val response: Response<T>) : ApiResponse<T>() {
          val statusCode: StatusCode = getStatusCodeFromResponse(response)
          val headers: Headers = response.headers()
          val raw: okhttp3.Response = response.raw()
          val errorBody: ResponseBody? = response.errorBody()
          override fun toString(): String = "[ApiResponse.Failure.Error-$statusCode](errorResponse=$response)"
        }
    

    how to get error message? because when i used apiResponse.message() it will return like in toString() function. what i want just message only. thank you

    opened by fjr619 6
  • [ASK] How to create sequential or parallel request?

    [ASK] How to create sequential or parallel request?

    right now i'm using CoroutinesResponseCallAdapterFactory, how to create sequential request and parallel request with sandwich? Example for sequential = i have API for getConfig and getBanner, frist getConfig, when succeed it will call getBanner Example for parallel = API getProductDetail, getUserLimit. both API will parralel then result of 2 api will combine into 1 result

    thank you

    opened by fjr619 6
  • On Failure should return the Failure Sealed class

    On Failure should return the Failure Sealed class

    Since 1.2.2 on failure returns the actual message for example

    [ApiResponse.Failure.Error-BadRequest](errorResponse=Response{protocol=h2, code=400, message=, url=urlItriedtodosomethingWith})

    It would be nice if this was the actual sealed class instead of the message. This would allow a user to just use onFailure and check the Exception/Error types themselves or do whatever with the actual sealed object. As it stands currently you lose information if it was an error, and logging from this string requires a bunch of parsing.

    For example I have an extension function that logs based off the ApiResponse<*> type.

    fun ApiResponse<*>.log(type: String) {
        when (this) {
            is ApiResponse.Failure.Exception -> {
                XLog.enableStackTrace(10).e("Exception $type ${this.message}", this.exception)
            }
            is ApiResponse.Failure.Error -> {
                XLog.e("error $type ${this.errorBody?.string()}")
                XLog.e("error response code ${this.statusCode.code}")
            }
            else -> {
                XLog.e("error $type")
            }
        }
    }
    

    I would like to do something like

      val loginResponseDto = authService.login(loginRequest).onFailure { 
                    this.log("Trying to login")
                }
    

    but currently have to do something like this

      val loginResponseDto = authService.login(loginRequest).onError{ 
                    this.log("Trying to login")
                }.onException{ 
                    this.log("Trying to login")
                }
    
    opened by CarlosEsco 5
  • How do I use Sandwich if the json is not a standard?

    How do I use Sandwich if the json is not a standard?

    How do I use Sandwich if the json is not a standard? The response json is .... success {"code":0,"msg":"success", "data": {}}

    fail {"code":-1,"msg":"fail"}

    opened by StrikeZXA 5
  • java.lang.RuntimeException: Failed to invoke private com.skydoves.sandwich.ApiResponse() with no args

    java.lang.RuntimeException: Failed to invoke private com.skydoves.sandwich.ApiResponse() with no args

    Please complete the following information:

    • Library Version 1.2.1
    • Affected Device(s) Pixel 4

    Describe the Bug: image response that used Sandwich always failed, with exception like above

    Expected Behavior: Response should be success because implementation without Sandwich had no problem

    Implementation:

    @GET("api/v3/rekening/detail")
        suspend fun getRekeningDetail(
            @Header("Authorization") auth: String,
            @Query("id") id: String,
            @Query("type") type: String
        ): ApiResponse<ResponseResult<Rekening>>
    
    
    data class ResponseResult<T>(
        @field:SerializedName("data")
        val data: T? = null,
    
        @field:SerializedName("message")
        val message: String? = null,
    
        @field:SerializedName("status")
        val status: Int? = null
    )
    
    fun getRekeningById(rekeningId: String, rekeningType: RekeningType) = flow {
            emit(State.Single.Loading())
            var rekeningData: Rekening? = null
            if (NetworkUtils.isConnected()) {
                val response =
                    service.getRekeningDetail(
                        mSecuredPreferences.accessToken,
                        rekeningId,
                        rekeningType.value
                    )
                response.suspendOnSuccess {
                    dao.insertSyncData(listOf(data.data))
                    rekeningData = dao.getRekeningById(rekeningId)
                }.suspendOnError {
                    rekeningData = dao.getRekeningById(rekeningId)
                }.suspendOnException {
                    rekeningData = dao.getRekeningById(rekeningId)
                }
            } else {
                rekeningData = dao.getRekeningById(rekeningId)
            }
            emit(State.Single.Success(rekeningData))
        }.flowOn(Dispatchers.IO)
    
    opened by JosephSanjaya 4
  • Support usage in vanilla JVM projects

    Support usage in vanilla JVM projects

    Is your feature request related to a problem?

    I wanted to experiment with sandwich in a backend project, so a regular Kotlin/JVM project, not an Android one. Using the dependency declaration in the build file described in the readme results in resolve errors of the like

    Execution failed for task ':common:compileKotlin'. Error while evaluating property 'filteredArgumentsMap' of task ':common:compileKotlin' Could not resolve all files for configuration ':common:compileClasspath'. > Could not resolve com.github.skydoves:sandwich:1.2.7. Required by: project :common > No matching variant of com.github.skydoves:sandwich:1.2.7 was found. The consumer was configured to find an API of a library compatible with Java 11, preferably in the form of class files, preferably optimized for standard JVMs, and its dependencies declared externally, as well as attribute 'org.jetbrains.kotlin.platform.type' with value 'jvm' but: - Variant 'releaseApiElements-published' capability com.github.skydoves:sandwich:1.2.7 declares an API of a library: - Incompatible because this component declares a component, as well as attribute 'org.jetbrains.kotlin.platform.type' with value 'androidJvm' and the consumer needed a component, as well as attribute 'org.jetbrains.kotlin.platform.type' with value 'jvm'

    Describe the solution you'd like:

    I would like to be able to resolve a variant of sandwich for a vanilla Kotlin/JVM project. I expect that the variant also has only the dependencies that are necessary for that variant - for example no dependency on android libraries or utilities.

    Describe alternatives you've considered:

    I found a closed issue: https://github.com/skydoves/sandwich/issues/4 where the user chose that moving everything to the android module is an alternative. However, I don't have an android module, so I don't see any alternatives.

    Thanks for the great project! Hannes

    opened by hannomalie 3
  • Crash on HTTP 403 when api type is Call<T>

    Crash on HTTP 403 when api type is Call

    Please complete the following information:

    • Library Version: v1.0.8
    • Affected Device(s): Pixel 3a with Android 11

    Describe the Bug:

    The problem looks similar with crash on 401 unauthorized status #5

    Crash log:

    2021-01-04 18:14:25.391 14244-14534/net.hikingbook.hikingbook E/AndroidRuntime: FATAL EXCEPTION: DefaultDispatcher-worker-2
        Process: net.hikingbook.hikingbook, PID: 14244
        retrofit2.HttpException: HTTP 403 
            at retrofit2.KotlinExtensions$await$2$2.onResponse(KotlinExtensions.kt:53)
            at retrofit2.OkHttpCall$1.onResponse(OkHttpCall.java:161)
            at com.google.firebase.perf.network.InstrumentOkHttpEnqueueCallback.onResponse(InstrumentOkHttpEnqueueCallback.java:69)
            at okhttp3.internal.connection.RealCall$AsyncCall.run(RealCall.kt:519)
            at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1167)
            at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:641)
            at java.lang.Thread.run(Thread.java:923)
    

    Here is my code. I try to use the Call to get the response.

    Service:

    @POST("/login")
        suspend fun postLogin(
                @Body body: RequestBody
        ): Call<User>
    

    Repository:

       suspend fun login(email: String, password: String) {
            val params: MutableMap<String, String> = mutableMapOf(
                    "email" to email,
                    "password" to password
            )
            router?.let {
                val request = it.postLogin(body = createRequestBody(params))
                request.callback(
                        onSuccess = { user ->
                            handleLoginResponse(user = user)
                        },
                        onError = {}
                )
            }
        }
    
       suspend fun <T> Call<T>.callback(
                onSuccess: (T?) -> Unit,
                onError: (String) -> Unit
        ) {
            this.request { response ->
                runBlocking {
                    response.suspendOnSuccess {
                        onSuccess(data)
                    }.suspendOnError {
                        when (statusCode) {
                            StatusCode.Forbidden -> {
                                // do somting...
                            }
                            else -> {
                                onError(message())
                            }
                        }
                    }.suspendOnException {
                        onError(message())
                    }
                }
            }
        }
    

    Expected Behavior: If the api is type is ApiResponse then the suspendOnError() method can handle the 403. But Call cannot handle by onError. Am I do something wrong? Thanks~

    opened by RobinChienHikingbook 2
  • Unresolved reference when adding dependency to a Kotlin module

    Unresolved reference when adding dependency to a Kotlin module

    Hi @skydoves, and thanks for this nice library

    When adding this dependency to the build.gradle file of an Android module, it works fine. But doing the same in the build.gradle of a pure Kotlin module, I'm not able to see the library (Unresolved reference). The IDE suggests "Add library 'Gradle: com.github.skydoves:sandwich:1.0.4@aar' to classpath"; and even doing that doesn't solve the problem. This is my build.gradle file:

    apply plugin: 'kotlin'
    
    dependencies {
        // some other dependencies
        implementation "com.github.skydoves:sandwich:1.0.4"
    }
    

    Could you explain how to handle this issue? Thanks

    opened by FredNM 2
  • Change parameter for map in ApiSuccessModelMapper

    Change parameter for map in ApiSuccessModelMapper

    Sandwich exposes an interfaceApiSuccessModelMapper. The map parameter method should be named apiSuccessResponse instead of apiErrorResponse

    package com.skydoves.sandwich
    
    /**
     * @author skydoves (Jaewoong Eum)
     *
     * A mapper interface for mapping [ApiResponse.Success] response as a custom [V] instance model.
     *
     * @see [ApiSuccessModelMapper](https://github.com/skydoves/sandwich#apierrormodelmapper)
     */
    public fun interface ApiSuccessModelMapper<T, V> {
    
      /**
       * maps the [ApiResponse.Success] to the [V] using the mapper.
       *
       * @param apiErrorResponse The [ApiResponse.Success] error response from the network request.
       * @return A custom [V] success response model.
       */
      public fun map(apiErrorResponse: ApiResponse.Success<T>): V
    }
    

    Describe the solution you'd like:

    Rename map parameter apiErrorResponse into apiSuccessResponse.

    opened by renaudmathieu 1
Releases(1.3.2)
  • 1.3.2(Oct 9, 2022)

    What's Changed

    • Implemented messageOrNull extension by @skydoves in https://github.com/skydoves/sandwich/pull/75
    • Implemented a new sandwichOperators property in SandwichInitializer by @skydoves in https://github.com/skydoves/sandwich/pull/76
    • Deprecated sandwichOperator property in SandwichInitializer by @skydoves in https://github.com/skydoves/sandwich/pull/76

    Full Changelog: https://github.com/skydoves/sandwich/compare/1.3.1...1.3.2

    Source code(tar.gz)
    Source code(zip)
  • 1.3.1(Oct 6, 2022)

    What's Changed

    • Migrate serialization module to pure Kotlin module by @skydoves in https://github.com/skydoves/sandwich/pull/72
    • Update AGP to 7.3.0 and Kotlin to 1.7.20 by @skydoves in https://github.com/skydoves/sandwich/pull/73
    • Update compile and target SDK to 33 by @skydoves in https://github.com/skydoves/sandwich/pull/74

    Full Changelog: https://github.com/skydoves/sandwich/compare/1.3.0...1.3.1

    Source code(tar.gz)
    Source code(zip)
  • 1.3.0(Sep 9, 2022)

    What's Changed

    • Update OkHttp to 4.10.0 and kotlin JSON serialization to 1.4.0 by @skydoves in https://github.com/skydoves/sandwich/pull/66
    • Implement ApiResponse property extensions by @skydoves in https://github.com/skydoves/sandwich/pull/67
    • Add JSON parameter to custom Json instances by @skydoves in https://github.com/skydoves/sandwich/pull/68
    • Move datasource relevant classes into the sandwich-datasource module by @skydoves in https://github.com/skydoves/sandwich/pull/70
    • Migrate sandwich module into pure Kotlin module and remove LiveData features by @skydoves in https://github.com/skydoves/sandwich/pull/71

    Full Changelog: https://github.com/skydoves/sandwich/compare/1.2.7...1.3.0

    Source code(tar.gz)
    Source code(zip)
  • 1.2.7(Jul 30, 2022)

    What's Changed

    • Introduce sandwich-serialization module by @skydoves in https://github.com/skydoves/sandwich/pull/64
    • Bump AGP to 7.2.1 and spotless, binary validator plugin versions by @skydoves in https://github.com/skydoves/sandwich/pull/65

    Full Changelog: https://github.com/skydoves/sandwich/compare/1.2.6...1.2.7

    Source code(tar.gz)
    Source code(zip)
  • 1.2.6(Jul 10, 2022)

    What's Changed

    • Deprecated sandwichOperatorContext and introduced sandwichScope by @skydoves in https://github.com/skydoves/sandwich/pull/60
    • Injecting a CoroutineCcope into the ApiResponseCallAdapterFactory by @skydoves in https://github.com/skydoves/sandwich/pull/62
    • Updated AGP to 7.2.0 and Kotlin to 1.7.10 by @skydoves in https://github.com/skydoves/sandwich/pull/61
    • Added unit test cases for ApiResponse with Coroutines by @skydoves in https://github.com/skydoves/sandwich/pull/63

    Full Changelog: https://github.com/skydoves/sandwich/compare/1.2.5...1.2.6

    Source code(tar.gz)
    Source code(zip)
  • 1.2.5(May 31, 2022)

    What's Changed

    • Implement mapSuccess extension by @skydoves in https://github.com/skydoves/sandwich/pull/51
    • Bump Kotlin to 1.6.21 and Coroutines 1.6.1 by @skydoves in https://github.com/skydoves/sandwich/pull/52
    • Support Deferred for ApiResponseCallAdapterFactory by @skydoves in https://github.com/skydoves/sandwich/pull/53
    • Deprecate static create methods for coroutines factories by @skydoves in https://github.com/skydoves/sandwich/pull/54
    • Add nonTransitiveRClass option for the sandwich module by @skydoves in https://github.com/skydoves/sandwich/pull/55
    • Add message extension for ApiResponse.Failure and change onFailure/suspendOnFailure extensions to receive its instance by @skydoves in https://github.com/skydoves/sandwich/pull/57
    • Fix minor typo. by @johnjohndoe in https://github.com/skydoves/sandwich/pull/58
    • Change compile and target SDK to 32 by @skydoves in https://github.com/skydoves/sandwich/pull/59

    New Contributors

    • @johnjohndoe made their first contribution in https://github.com/skydoves/sandwich/pull/58

    Full Changelog: https://github.com/skydoves/sandwich/compare/1.2.4...1.2.5

    Source code(tar.gz)
    Source code(zip)
  • 1.2.4(Apr 13, 2022)

    🎉 1.2.4 has been released! 🎉

    What's Changed

    • Deprecated CoroutinesResponseCallAdapterFactory and CoroutinesDataSourceCallAdapterFactory @skydoves in https://github.com/skydoves/sandwich/pull/50
    • Added ApiResponseCallAdapterFactory and DataSourceCallAdapterFactory @skydoves in https://github.com/skydoves/sandwich/pull/50
    • Bump AGP to 7.1.2 and coroutines 1.6.0, kotlin to 1.5.32 by @skydoves in https://github.com/skydoves/sandwich/pull/44 https://github.com/skydoves/sandwich/pull/47
    • Added retry extension by @skydoves in https://github.com/skydoves/sandwich/pull/45
    • Refactored CallAdapters and CallAdapterFactories by @skydoves in https://github.com/skydoves/sandwich/pull/50
    • Migrated Coroutines Test to 1.6.0 by @skydoves in https://github.com/skydoves/sandwich/pull/48
    • Migrated maven publish scripts and add GitHub workflows by @skydoves in https://github.com/skydoves/sandwich/pull/49

    Full Changelog: https://github.com/skydoves/sandwich/compare/1.2.3...1.2.4

    Source code(tar.gz)
    Source code(zip)
  • 1.2.3(Jan 16, 2022)

    🎉 1.2.3 has been released! 🎉

    What's Changed

    • Improved documentation for Dokka and README.
    • Updated OkHttp to 4.9.3 by @skydoves in #43
    • Updated Gradle dependencies by @skydoves in #43

    Full Changelog: https://github.com/skydoves/Sandwich/compare/1.2.2...1.2.3

    Source code(tar.gz)
    Source code(zip)
  • 1.2.2(Nov 30, 2021)

    🥪 A new 1.2.2 stable has been released!

    What's Changed

    • Fix: receiver and validation of the onFailure and suspendOnFailure by @skydoves in https://github.com/skydoves/Sandwich/pull/42
    • Integrate: Kotlin Binary validator plugin by @skydoves in https://github.com/skydoves/Sandwich/pull/31
    • Update: GitHub Actions workflow by @skydoves in https://github.com/skydoves/Sandwich/pull/32, https://github.com/skydoves/Sandwich/pull/33, https://github.com/skydoves/Sandwich/pull/35
    • Update: APG to 7.0.2 internally by @skydoves in https://github.com/skydoves/Sandwich/pull/37

    Full Changelog: https://github.com/skydoves/Sandwich/compare/1.2.1...1.2.2

    Source code(tar.gz)
    Source code(zip)
  • 1.2.1(Jul 26, 2021)

    🥪 Released a new version 1.2.1! 🥪

    What's New?

    • Added new extensions map for the ApiResponse.Success and ApiResponse.Failure.Error using a lambda receiver. (#26)
    • Added new functions suspendCombine and suspendRequest for the DataSourceResponse. (#27)
    • Added a sandwichGlobalContext for operating the sandwichOperator when it extends the [ApiResponseSuspendOperator]. (#28)
    • Updated coroutines to 1.5.0
    • Added explicit modifiers based on the strict Kotlin API mode internally.
    Source code(tar.gz)
    Source code(zip)
  • 1.2.0(Jun 25, 2021)

    🥪 Released a new version 1.2.0! 🥪

    You can check the migration codes here Pokedex(#35).

    What's New?

    Now the data property is a non-nullable type

    The data property in the ApiResponse.Success is non-nullable from this release. Previously, the data property in the ApiResponse would be null-able if the response has been succeeded but there is an empty-body response regardless of the status code. It will throw NoContentException if we try to access the data property for the 204 and 205 cases (succeeded but empty body). Thanks, @jakoss for discussing this (#20).

    EmptyBodyInterceptor

    If we want to bypass the NoContentException and handle it as an empty body response, we can use the EmptyBodyInterceptor. Then we will not get the NoContentException if we try to access the data property for the 204 and 205 response code.

     OkHttpClient.Builder()
       .addInterceptor(EmptyBodyInterceptor())
       .build()
    

    create factories

    Now we should create the factory classes using the create() method.

    .addCallAdapterFactory(CoroutinesResponseCallAdapterFactory.create())
    

    getOrElse

    We can get the data or default value based on the success or failed response. Returns the encapsulated data if this instance represents ApiResponse.Success or returns a default value if this is failed.

    val data: List<Poster> = disneyService.fetchDisneyPosterList().getOrElse(emptyList())
    
    Source code(tar.gz)
    Source code(zip)
  • 1.1.0(Mar 23, 2021)

    🎉 Released a new version 1.1.0! 🎉

    What's New?

    • Now we can retrieve the encapsulated success data from the ApiResponse directly using the below functionalities.

    getOrNull

    Returns the encapsulated data if this instance represents ApiResponse.Success or returns null if this is failed.

    val data: List<Poster>? = disneyService.fetchDisneyPosterList().getOrNull()
    

    getOrElse

    Returns the encapsulated data if this instance represents ApiResponse.Success or returns a default value if this is failed.

    val data: List<Poster>? = disneyService.fetchDisneyPosterList().getOrElse(emptyList())
    

    getOrThrow

    Returns the encapsulated data if this instance represents ApiResponse.Success or throws the encapsulated Throwable exception if this is failed.

    try {
      val data: List<Poster>? = disneyService.fetchDisneyPosterList().getOrThrow()
    } catch (e: Exception) {
      e.printStackTrace()
    }
    
    Source code(tar.gz)
    Source code(zip)
  • 1.0.9(Jan 18, 2021)

    🎉 Released a new version 1.0.9! 🎉

    What's New?

    - onSuccess and suspendOnSuccess can receive ApiSuccessModelMapper as a parameter.

    If we want to get the transformed data from the start in the lambda, we can pass the mapper as a parameter for the suspendOnSuccess.

    .suspendOnSuccess(SuccessPosterMapper) {
        val poster = this
    }
    

    - onError and suspendOnError can receive ApiErrorModelMapper as a parameter.

    // Maps the ApiResponse.Failure.Error to a custom error model using the mapper.
    response.onError(ErrorEnvelopeMapper) {
        val code = this.code
        val message = this.message
    }
    

    - Added a new extension toLiveData and toSuspendLiveData with a transformer lambda.

    If we want to transform the original data and get a LiveData which contains transformed data using successful data if the response is a ApiResponse.Success.

    posterListLiveData = liveData(viewModelScope.coroutineContext + Dispatchers.IO) {
      emitSource(
       disneyService.fetchDisneyPosterList()
        .onError {
          // handle the error case
        }.onException {
          // handle the exception case
        }.toLiveData {
          this.onEach { poster -> poster.date = SystemClock.currentThreadTimeMillis() }
        }) // returns an observable LiveData
       }
    

    - Added a new extension toFlow and toSsuspendFlow with a transformer lambda.

    We can get a Flow that emits successful data if the response is an ApiResponse.Success and the data is not null.

    disneyService.fetchDisneyPosterList()
      .onError {
        // stub error case
      }.onException {
        // stub exception case
      }.toFlow() // returns a coroutines flow
      .flowOn(Dispatchers.IO)
    

    If we want to transform the original data and get a flow that contains transformed data using successful data if the response is an ApiResponse.Success and the data is not null.

    val response = pokedexClient.fetchPokemonList(page = page)
    response.toFlow { pokemons ->
      pokemons.forEach { pokemon -> pokemon.page = page }
      pokemonDao.insertPokemonList(pokemons)
      pokemonDao.getAllPokemonList(page)
    }.flowOn(Dispatchers.IO)
    

    - Added a new transformer onProcedure and suspendOnProcedure expressions.

    We can pass onSuccess, onError, and onException as arguments.

    .suspendOnProcedure(
                  // handle the case when the API request gets a successful response.
                  onSuccess = {
                    Timber.d("$data")
    
                    data?.let { emit(it) }
                  },
                  // handle the case when the API request gets an error response.
                  // e.g., internal server error.
                  onError = {
                    Timber.d(message())
    
                    // handling error based on status code.
                    when (statusCode) {
                      StatusCode.InternalServerError -> toastLiveData.postValue("InternalServerError")
                      StatusCode.BadGateway -> toastLiveData.postValue("BadGateway")
                      else -> toastLiveData.postValue("$statusCode(${statusCode.code}): ${message()}")
                    }
    
                    // map the ApiResponse.Failure.Error to a customized error model using the mapper.
                    map(ErrorEnvelopeMapper) {
                      Timber.d("[Code: $code]: $message")
                    }
                  },
                  // handle the case when the API request gets a exception response.
                  // e.g., network connection error.
                  onException = {
                    Timber.d(message())
                    toastLiveData.postValue(message())
                  }
                )
    
    Source code(tar.gz)
    Source code(zip)
  • 1.0.8(Dec 29, 2020)

    🎉 Released a new version 1.0.8! 🎉

    What's New?

    • Added ApiResponseOperator and ApiResponseSuspendOperator.

    Operator

    We can delegate the onSuccess, onError, onException using the operator extension and ApiResponseOperator. Operators are very useful when we want to handle ApiResponses standardly or reduce the role of the ViewModel and Repository. Here is an example of standardized error and exception handing.

    ViewModel

    We can delegate and operate the CommonResponseOperator using the operate extension.

    disneyService.fetchDisneyPosterList().operator(
          CommonResponseOperator(
            success = { success ->
              success.data?.let {
                posterListLiveData.postValue(it)
              }
              Timber.d("$success.data")
            },
            application = getApplication()
          )
        )
    

    CommonResponseOperator

    The CommonResponseOperator extends ApiResponseOperator with the onSuccess, onError, onException override methods. They will be executed depending on the type of the ApiResponse.

    /** A common response operator for handling [ApiResponse]s regardless of its type. */
    class CommonResponseOperator<T> constructor(
      private val success: suspend (ApiResponse.Success<T>) -> Unit,
      private val application: Application
    ) : ApiResponseOperator<T>() {
    
      // handle the case when the API request gets a success response.
      override fun onSuccess(apiResponse: ApiResponse.Success<T>) = success(apiResponse)
    
      // handle the case when the API request gets a error response.
      // e.g., internal server error.
      override fun onError(apiResponse: ApiResponse.Failure.Error<T>) {
        apiResponse.run {
          Timber.d(message())
          
          // map the ApiResponse.Failure.Error to a customized error model using the mapper.
          map(ErrorEnvelopeMapper) {
            Timber.d("[Code: $code]: $message")
          }
        }
      }
    
      // handle the case when the API request gets a exception response.
      // e.g., network connection error.
      override fun onException(apiResponse: ApiResponse.Failure.Exception<T>) {
        apiResponse.run {
          Timber.d(message())
          toast(message())
        }
      }
    }
    

    Operator with coroutines

    If we want to operate and delegate a suspending lambda to the operator, we can use the suspendOperator extension and ApiResponseSuspendOperator class.

    ViewModel

    We can use suspending function like emit in the success lambda.

    flow {
      disneyService.fetchDisneyPosterList().suspendOperator(
          CommonResponseOperator(
            success = { success ->
              success.data?.let { emit(it) }
              Timber.d("$success.data")
            },
            application = getApplication()
          )
        )
    }.flowOn(Dispatchers.IO).asLiveData()
    

    CommonResponseOperator

    The CommonResponseOperator extends ApiResponseSuspendOperator with suspend override methods.

    class CommonResponseOperator<T> constructor(
      private val success: suspend (ApiResponse.Success<T>) -> Unit,
      private val application: Application
    ) : ApiResponseSuspendOperator<T>() {
    
      // handle the case when the API request gets a success response.
      override suspend fun onSuccess(apiResponse: ApiResponse.Success<T>) = success(apiResponse)
    
      // skip //
    

    Global operator

    We can operate an operator globally on each ApiResponse using the SandwichInitializer. So we don't need to create every instance of the Operators or use dependency injection for handling common operations. Here is an example of handling globally about the ApiResponse.Failure.Error and ApiResponse.Failure.Exception. We will handle ApiResponse.Success manually.

    Application class

    We can initialize the global operator on the SandwichInitializer.sandwichOperator. It is recommended to initialize it in the Application class.

    class SandwichDemoApp : Application() {
    
      override fun onCreate() {
        super.onCreate()
        
        // We will handle only the error and exception cases, 
        // so we don't need to mind the generic type of the operator.
        SandwichInitializer.sandwichOperator = GlobalResponseOperator<Any>(this)
    
        // skipp //
    

    GlobalResponseOperator

    The GlobalResponseOperator can extend any operator (ApiResponseSuspendOperator or ApiResponseOperator)

    class GlobalResponseOperator<T> constructor(
      private val application: Application
    ) : ApiResponseSuspendOperator<T>() {
    
      // The body is empty, because we will handle the success case manually.
      override suspend fun onSuccess(apiResponse: ApiResponse.Success<T>) { }
    
      // handle the case when the API request gets a error response.
      // e.g., internal server error.
      override suspend fun onError(apiResponse: ApiResponse.Failure.Error<T>) {
        withContext(Dispatchers.Main) {
          apiResponse.run {
            Timber.d(message())
    
            // handling error based on status code.
            when (statusCode) {
              StatusCode.InternalServerError -> toast("InternalServerError")
              StatusCode.BadGateway -> toast("BadGateway")
              else -> toast("$statusCode(${statusCode.code}): ${message()}")
            }
    
            // map the ApiResponse.Failure.Error to a customized error model using the mapper.
            map(ErrorEnvelopeMapper) {
              Timber.d("[Code: $code]: $message")
            }
          }
        }
      }
    
      // handle the case when the API request gets a exception response.
      // e.g., network connection error.
      override suspend fun onException(apiResponse: ApiResponse.Failure.Exception<T>) {
        withContext(Dispatchers.Main) {
          apiResponse.run {
            Timber.d(message())
            toast(message())
          }
        }
      }
    
      private fun toast(message: String) {
        Toast.makeText(application, message, Toast.LENGTH_SHORT).show()
      }
    }
    

    ViewModel

    We don't need to use the operator expression. The global operator will be operated automatically, so we should handle only the ApiResponse.Success.

    flow {
      disneyService.fetchDisneyPosterList().
        suspendOnSuccess {
          data?.let { emit(it) }
        }
    }.flowOn(Dispatchers.IO).asLiveData()
    
    Source code(tar.gz)
    Source code(zip)
  • 1.0.7(Dec 21, 2020)

    🎉 Released a new version 1.0.7! 🎉

    What's New?

    • Changed non-inline functions to inline classes.
    • Removed generating BuildConfig class.
    • Added ApiSuccessModelMapper for mapping data of the ApiResponse.Success to the custom model. We can map the ApiResponse.Success model to our custom model using the mapper extension.
    object SuccessPosterMapper : ApiSuccessModelMapper<List<Poster>, Poster?> {
    
      override fun map(apiErrorResponse: ApiResponse.Success<List<Poster>>): Poster? {
        return apiErrorResponse.data?.first()
      }
    }
    
    // Maps the success response data.
    val poster: Poster? = map(SuccessPosterMapper)
    
    or
    
    // Maps the success response data using a lambda.
    map(SuccessPosterMapper) { poster ->
      livedata.post(poster) // we can use the `this` keyword instead.
    }
    
    • Added mapOnSuccess and mapOnError extensions for mapping success/error model to the custom model in their scope.
    • Added merge extension for ApiResponse for merging multiple ApiResponses as one ApiResponse depending on the policy. The below example is merging three ApiResponse as one if every three ApiResponses are successful.
    disneyService.fetchDisneyPosterList(page = 0).merge(
       disneyService.fetchDisneyPosterList(page = 1),
       disneyService.fetchDisneyPosterList(page = 2),
       mergePolicy = ApiResponseMergePolicy.PREFERRED_FAILURE
    ).onSuccess { 
      // handle response data..
    }.onError { 
      // handle error..
    }
    

    ApiResponseMergePolicy

    ApiResponseMergePolicy is a policy for merging response data depend on the success or not.

    • IGNORE_FAILURE: Regardless of the merging order, ignores failure responses in the responses.
    • PREFERRED_FAILURE (default): Regardless of the merging order, prefers failure responses in the responses.
    Source code(tar.gz)
    Source code(zip)
  • 1.0.6(Oct 25, 2020)

    🎉 Released a new version 1.0.6! 🎉

    What's New?

    • Added a Disposable interface and disposable() extension for canceling tasks when we want.
    • Added DisposableComposite is that a disposable container that can hold onto multiple other disposables.
    • Added joinDisposable extensions to DataSource and Call for creating a disposable and add easily.

    Disposable in Call

    We can cancel the executing works using a disposable() extension.

    val disposable = call.request { response ->
      // skip handling a response //
    }.disposable()
    
    // dispose the executing works
    disposable.dispose()
    

    And we can use CompositeDisposable for canceling multiple resources at once.

    class MainViewModel constructor(disneyService: DisneyService) : ViewModel() {
    
      private val disposables = CompositeDisposable()
    
      init {
        disneyService.fetchDisneyPosterList()
          .joinDisposable(disposables) // joins onto [CompositeDisposable] as a disposable.
          .request {response ->
          // skip handling a response //
        }
      }
    
      override fun onCleared() {
        super.onCleared()
        if (!disposables.disposed) {
          disposables.clear()
        }
      }
    }
    

    Disposable in DataSource

    We can make it joins onto CompositeDisposable as a disposable using the joinDisposable function. It must be called before request() method. The below example is using in ViewModel. We can clear the CompositeDisposable in the onCleared() override method.

    private val disposable = CompositeDisposable()
    
    init {
        disneyService.fetchDisneyPosterList().toResponseDataSource()
          // retry fetching data 3 times with 5000L interval when the request gets failure.
          .retry(3, 5000L)
          // joins onto CompositeDisposable as a disposable and dispose onCleared().
          .joinDisposable(disposable)
          .request {
            // ... //
          }
    }
    
    override fun onCleared() {
        super.onCleared()
        if (!disposable.disposed) {
          disposable.clear()
        }
      }
    
    Source code(tar.gz)
    Source code(zip)
    sandwich-1.0.6.aar(75.07 KB)
  • 1.0.5(Sep 18, 2020)

    🎉 Released a new version 1.0.5! 🎉

    What's New?

    • Fixed: crashes on 400~500 error happened with using CoroutinesResponseCallAdapterFactory and CoroutinesDataSourceCallAdapterFactory. (#5)
    • Used kotlin 1.4.0 stable internally.
    • Used single abstract method conversions for interfaces.
    Source code(tar.gz)
    Source code(zip)
    sandwich-1.0.5.aar(71.45 KB)
  • 1.0.4(Jul 17, 2020)

    🎉 Released a new version 1.0.4! 🎉

    What's New?

    Added suspendOnSuccess, suspendOnFailure, suspendOnException extensions of the ApiResponse.

    We can use them for handling suspend functions inside the lambda. In this case, we should use with CoroutinesResponseCallAdapterFactory.

    flow {
          val response = disneyService.fetchDisneyPosterList()
          response.suspendOnSuccess {
            emit(data)
          }.suspendOnError {
            // stub error case
          }.suspendOnFailure {
            // stub exception case
          }
        }
    
    Source code(tar.gz)
    Source code(zip)
    sandwich-1.0.4.aar(72.06 KB)
  • 1.0.3(Jun 26, 2020)

    🎉 Released a new version 1.0.3! 🎉

    What's New?

    Now Sandwich supports using with coroutines. We can use the suspend function on services.

    CoroutinesResponseCallAdapterFactory

    We can build the Retrofit using with CoroutinesResponseCallAdapterFactory() call adapter factory.

    addCallAdapterFactory(CoroutinesResponseCallAdapterFactory())
    

    And we can use the suspend keyword in our service.

    interface DisneyCoroutinesService {
    
      @GET("DisneyPosters.json")
      suspend fun fetchDisneyPosterList(): ApiResponse<List<Poster>>
    }
    

    And now we can use like this; An example of using toLiveData.

    class MainCoroutinesViewModel constructor(disneyService: DisneyCoroutinesService) : ViewModel() {
    
      val posterListLiveData: LiveData<List<Poster>>
    
      init {
        posterListLiveData = liveData(viewModelScope.coroutineContext + Dispatchers.IO) {
          emitSource(disneyService.fetchDisneyPosterList()
            .onSuccess {
              Timber.d("$data")
            }
            .onError {
              Timber.d(message())
            }
            .onException {
              Timber.d(message())
            }.toLiveData())
        }
    

    CoroutinesDataSourceCallAdapterFactory

    We can build the Retrofit using with CoroutinesDataSourceCallAdapterFactory() call adapter factory.

    addCallAdapterFactory(CoroutinesDataSourceCallAdapterFactory())
    

    And we can get the response data as the DataSource type with suspend keyword.

    interface DisneyCoroutinesService {
    
      @GET("DisneyPosters.json")
      suspend fun fetchDisneyPosterList(): DataSource<List<Poster>>
    }
    

    Here is an example of the using asLiveData.

    class MainCoroutinesViewModel constructor(disneyService: DisneyCoroutinesService) : ViewModel() {
    
     val posterListLiveData: LiveData<List<Poster>>
    
     init {
        Timber.d("initialized MainViewModel.")
    
        posterListLiveData = liveData(viewModelScope.coroutineContext + Dispatchers.IO) {
          emitSource(disneyService.fetchDisneyPosterList().toResponseDataSource()
            // retry fetching data 3 times with 5000L interval when the request gets failure.
           .retry(3, 5000L)
           .dataRetainPolicy(DataRetainPolicy.RETAIN)
           .request {
              // handle the case when the API request gets a success response.
              onSuccess {
                Timber.d("$data")
              }
        // -- skip --
        }.asLiveData())
     }
    
    Source code(tar.gz)
    Source code(zip)
    sandwich-1.0.3.aar(65.72 KB)
  • 1.0.2(Jun 14, 2020)

    🎉 Released a new version 1.0.2! 🎉

    RetainPolicy

    We can limit the policy for retaining data on the temporarily internal storage. The default policy is no retaining any fetched data from the network, but we can set the policy using the dataRetainPolicy method.

    // Retain fetched data on the memory storage temporarily.
    // If request again, returns the retained data instead of re-fetching from the network.
    dataSource.dataRetainPolicy(DataRetainPolicy.RETAIN)
    

    asLiveData

    we can observe fetched data via DataSource as a LiveData.

    • if the response is successful, it returns a [LiveData] which contains response data.
    • if the response is failure or exception, it returns an empty [LiveData].
    val posterListLiveData: LiveData<List<Poster>>
    
    init {
        posterListLiveData = disneyService.fetchDisneyPosterList().toResponseDataSource()
          .retry(3, 5000L)
          .dataRetainPolicy(DataRetainPolicy.RETAIN)
          .request {
            // ...
          }.asLiveData()
      }
    

    toResponseDataSource

    If we use DataSourceCallAdapterFactory, we can only get the DataSource interface instead of ResponseDataSource. So in this release, there is a new way to change DataSource to ResponseDataSource. We can change DataSource to ResponseDataSource after getting instance from network call using the below method.

    private val dataSource: ResponseDataSource<List<Poster>>
    
      init {
        dataSource = disneyService.fetchDisneyPosterList().toResponseDataSource()
    
        //...
      }
    
    Source code(tar.gz)
    Source code(zip)
    sandwich-1.0.2.aar(55.65 KB)
  • 1.0.1(May 7, 2020)

    Released a new version 1.0.1!

    What's new?

    • Added DataSourceCallAdapterFactory.

    We can get the DataSource directly from the Retrofit service. Add call adapter factory DataSourceCallAdapterFactory to your Retrofit builder. And change the return type of your service Call to DataSource.

    Retrofit.Builder()
        ...
        .addCallAdapterFactory(DataSourceCallAdapterFactory())
        .build()
    
    interface DisneyService {
      @GET("DisneyPosters.json")
      fun fetchDisneyPosterList(): DataSource<List<Poster>>
    }
    

    Here is the example of the DataSource in the MainViewModel.

    class MainViewModel constructor(disneyService: DisneyService) : ViewModel() {
    
      // request API call Asynchronously and holding successful response data.
      private val dataSource: DataSource<List<Poster>>
    
        init {
        Timber.d("initialized MainViewModel.")
    
        dataSource = disneyService.fetchDisneyPosterList()
          // retry fetching data 3 times with 5000L interval when the request gets failure.
          .retry(3, 5000L)
          .observeResponse(object : ResponseObserver<List<Poster>> {
            override fun observe(response: ApiResponse<List<Poster>>) {
              // handle the case when the API request gets a success response.
              response.onSuccess {
                Timber.d("$data")
                posterListLiveData.postValue(data)
              }
            }
          })
          .request() // must call request()
    
    Source code(tar.gz)
    Source code(zip)
    sandwich-1.0.1.aar(51.98 KB)
  • 1.0.0(May 5, 2020)

Owner
Jaewoong Eum
Android and open source software engineer.❤️ Digital Nomad. Love coffee, music, magic tricks, and writing poems. Coffee Driven Development
Jaewoong Eum
Cli lightning network server, based on LDK (rust-lightning). Provides DUMB-RPC interface (telnet friendly).

Hello Lightning Cli lightning network server, based on LDK (rust-lightning). Provides DUMB-RPC interface (telnet friendly). Example: Build it run it:

null 9 Mar 28, 2022
LiteHttp is a simple, intelligent and flexible HTTP framework for Android. With LiteHttp you can make HTTP request with only one line of code! It could convert a java model to the parameter and rander the response JSON as a java model intelligently.

Android network framework: LiteHttp Tags : litehttp2.x-tutorials Website : http://litesuits.com QQgroup : 42960650 , 47357508 Android网络通信为啥子选 lite-htt

马天宇 829 Dec 29, 2022
TART: Tracing Action Response Times

Square Tart Tracing Action Response Times! This library is not stable for usage beyond Square, the APIs and internals might change anytime. Tart... ?

Square 246 Nov 24, 2022
Write your asynchronous Network / IO call painlessly in Kotlin !!

Asynkio : Write asynced IO/ Network calls painlessly on android | | | Documentation Write your network requests, IO calls in android with Kotlin seaml

Nikhil Chaudhari 82 Jan 26, 2022
Compact and easy to use, 'all-in-one' android network solution

Deprecated Unfortunately due to many reasons including maintenance cost, this library is deprecated. I recommend to use Retrofit/OkHttp instead. Curre

Orhan Obut 585 Dec 30, 2022
Android library listening network connection state and change of the WiFi signal strength with event bus

NetworkEvents Android library listening network connection state and change of the WiFi signal strength with event bus. It works with any implementati

Piotr Wittchen 452 Nov 21, 2022
A library that observes your network status.

A library that observes your network status. Update in progress ... Download Using gradle In your root build.gradle at the end of repositories add all

Moses Wangira 9 Apr 18, 2022
Android network client based on Cronet. This library let you easily use QUIC protocol in your Android projects

Android network client based on Cronet. This library let you easily use QUIC protocol in your Android projects

VK.com 104 Dec 12, 2022
Operations are performed by calling api endpoints over the network

##About The app Operations are performed by calling api endpoints over the network. Local data is in effect immutable, the client just downloads updat

Marc Daniel Registre 1 Oct 22, 2021
SMNetworkChecker aims to help check if the network is connected or if the wifi is connected.

Network Checker for Android SMNetworkChecker aims to help check if the network is connected or if the wifi is connected. Preview Network connection O

Sangmin Kim 3 Aug 5, 2022
SimpleApiCalls is a type-safe REST client for Android. The library provides the ability to interact with APIs and send network requests with HttpURLConnection.

SimpleApiCalls ?? SimpleApiCalls is a type-safe REST client for Android. The library provides the ability to interact with APIs and send network reque

null 4 Nov 28, 2022
A small Android project to practice executing network requests and parsing the network response

InspirationalQuotesExercise A small Android project to practice executing network requests and parsing the network response This app uses the ZenQuote

Caren 0 Oct 13, 2021
DSL for constructing the drawables in Kotlin instead of in XML

Android Drawable Kotlin DSL DSL for constructing the drawables in Kotlin instead of in XML Examples Shape drawables <?xml version="1.0" encoding="utf-

Infotech Group 178 Dec 4, 2022
Cli lightning network server, based on LDK (rust-lightning). Provides DUMB-RPC interface (telnet friendly).

Hello Lightning Cli lightning network server, based on LDK (rust-lightning). Provides DUMB-RPC interface (telnet friendly). Example: Build it run it:

null 9 Mar 28, 2022
An OkHttp interceptor which has pretty logger for request and response. +Mock support

LoggingInterceptor - Interceptor for OkHttp3 with pretty logger Usage val client = OkHttpClient.Builder() client.addInterceptor(LoggingInterceptor

ihsan BAL 1.3k Dec 26, 2022
LiteHttp is a simple, intelligent and flexible HTTP framework for Android. With LiteHttp you can make HTTP request with only one line of code! It could convert a java model to the parameter and rander the response JSON as a java model intelligently.

Android network framework: LiteHttp Tags : litehttp2.x-tutorials Website : http://litesuits.com QQgroup : 42960650 , 47357508 Android网络通信为啥子选 lite-htt

马天宇 829 Dec 29, 2022
A view that makes it easy to debug response data.(一个可以方便调试响应数据的视图。)

JSONRecyclerView 该控件可以方便调试响应数据,如下图所示: 控件的样式参考以下这个网站: JSON在线解析解析及格式化验证 项目的GitHub:JSONRecyclerView 项目Demo的GitHub:JSONRecyclerViewDemo 概述 控件是以RecyclerVie

TanJiaJun 28 Oct 7, 2022