Blog Infos
Author
Published
Topics
Author
Published
Topics

Posted by: Satya Pavan Kantamani

Introduction

SharedPreferences the common way used for storing key-value pairs in local storage. Datastore is a replacement to SharedPreferences to overcome its shortcomings. Datastore is an advanced data storage solution that was built using Kotlin coroutines and Flow to store data asynchronously, consistently, and transactionally. There are two ways to store data in DataStore. Those are Preferences DataStore and Proto DataStore. Please check out my previous post about introduction to Datastore and Preferences DataStore implementation

In this post let’s see we will know more about the following:
– Proto DataStore
– Protobufs
– Basic implementation of Proto DataStore

Familiarity with coroutines and Kotlin Flow is needed for understanding this.

If you want to jump directly to the code base, check out the GitHub repo.

What is Proto DataStore?

We are habituated with the system of a key-value pair for storing data in Shared preferences and Preferences DataStore. However, Proto DataStore stores the data as instances of a custom data type rather than key-value pairs. Proto DataStore needs a pre-defined schema with data types and instances that need to be stored. This schema approach helps in providing the type-safety with predefined data types. We need to Define a schema using Protocol buffers.

  • Stores data as instances of a custom data type
  • Defines the schema using Protocol buffers. Using Protobufs allows persisting strongly typed data.
  • They are faster, smaller, simpler, and less ambiguous than XML and other similar data formats.

We need to follow a new serialization mechanism for Proto DataStore which we will see later in this post. Before jumping to the implementation let’s know a little bit about Protocol buffers.

What are Protocol buffers?

Protocol buffers mostly referred to as Protobufs are language and platform-neutral mechanisms of serializing data. Protobufs can be best suited for scenarios where faster communication over the network is needed or for storing data. Google created the ProtoBuf format in 2008. It’s an alternative solution to JSON, XML to serialize and deserialize data as fast as possible.

The current version of Protobuf is proto3. We will use this proto3 version later to create our proto datastore. It is important to know about the mechanism of Protobufs. In this mechanism, we use proto files with .protoextensions where we write the data to be serialized. The proto files contain message types in which we define our data.

Let’s create a simple proto file with a message type in comparison with JSON type for better understanding. The simpler JSON file will be looking as below

{
 is_logged_in: true,
 user_name: "Android"
}

Now let’s create a proto file format for this

syntax = "proto3";message UserData{
  bool is_logged_in = 1;
  string user_name = 2;  
}

For each field, we need to define Field Types , Field Numbers , Field Rules ,etc. To learn more about Protobufs checkout Google guide for Protobufs

Note: The proto files can be compiled to generate the code as per the user’s programming language.

Enough of talks let’s move to the coding part…

Implementation

Let’s create an UserDataStore where we store data related to the user.

Step 1

Adding a simple dependency is not sufficient here. We need to

  • add the Protobuf plugin and configure the Protobuf
  • add dependencies of Protobuf and Proto DataStore.
plugins {
...
id "com.google.protobuf" version "0.8.12"
}
dependencies {
implementation "androidx.datastore:datastore-core:1.0.0-rc01"
implementation "com.google.protobuf:protobuf-javalite:3.14.0"
...
}
protobuf {
protoc {
artifact = "com.google.protobuf:protoc:3.14.0"
}
// Generates the java Protobuf-lite code for the Protobufs in this project. See
// https://github.com/google/protobuf-gradle-plugin#customizing-protobuf-compilation
// for more information.
generateProtoTasks {
all().each { task ->
task.builtins {
java {
option 'lite'
}
}
}
}
}
view raw buildp.gradle hosted with ❤ by GitHub
Step 2

As we are done with Gradle set-up let’s move to the creation of a proto file with required fields. Create a proto directory under app/src/main/. Let’s add the proto file to the proto directory with the name user_store with the extension of .proto .

Step 3: Adding the content in the proto file.

We need to define the version, package, java_multiple_files options and define our message object with the required fields. Let’s store two fields is_logged_in of type Boolean and user_name of type string. As we have already seen in the Protobuf section the syntax of the proto file let’s follow the same

syntax = "proto3";

option java_package = "com.sample.android_sample_preference_datastore";
option java_multiple_files = true;

message UserStore {
    bool is_logged_in = 1;
    string user_name = 2;
}

We can add multiple messages where each individual message has a class generated. If we have app-related data we can create AppData and for user-related stuff UserStore, etc

Step 4

Rebuild your project and check that UserStore.java should be generated under app/build/generated/source/proto or double shift and check with the name UserStore . Just have an overview look-up of UserStore where it has different methods for storing, retrieving data, creating instances of UserStore and other stuff.

Note: It would be best practice to run the blocks of readFrom and writeTo inside withContext(Dispatchers.IO) { } and handle exceptions in both the case as the methods readFrom and writeTo may throw IO Exception. We need to define multiple serializers if have multiple message types

// Generated by the protocol buffer compiler. DO NOT EDIT!
// source: user_store.proto
package com.sample.android_sample_preference_datastore;
/**
* Protobuf type {@code UserStore}
*/
public final class UserStore extends
com.google.protobuf.GeneratedMessageLite<
UserStore, UserStore.Builder> implements
// @@protoc_insertion_point(message_implements:UserStore)
UserStoreOrBuilder {
private UserStore() {
userName_ = "";
}
public static final int IS_LOGGED_IN_FIELD_NUMBER = 1;
private boolean isLoggedIn_;
/**
* <code>bool is_logged_in = 1;</code>
* @return The isLoggedIn.
*/
@java.lang.Override
public boolean getIsLoggedIn() {
return isLoggedIn_;
}
/**
* <code>bool is_logged_in = 1;</code>
* @param value The isLoggedIn to set.
*/
private void setIsLoggedIn(boolean value) {
isLoggedIn_ = value;
}
/**
* <code>bool is_logged_in = 1;</code>
*/
private void clearIsLoggedIn() {
isLoggedIn_ = false;
}
public static final int USER_NAME_FIELD_NUMBER = 2;
private java.lang.String userName_;
/**
* <code>string user_name = 2;</code>
* @return The userName.
*/
@java.lang.Override
public java.lang.String getUserName() {
return userName_;
}
/**
* <code>string user_name = 2;</code>
* @return The bytes for userName.
*/
@java.lang.Override
public com.google.protobuf.ByteString
getUserNameBytes() {
return com.google.protobuf.ByteString.copyFromUtf8(userName_);
}
/**
* <code>string user_name = 2;</code>
* @param value The userName to set.
*/
private void setUserName(
java.lang.String value) {
value.getClass();
userName_ = value;
}
/**
* <code>string user_name = 2;</code>
*/
private void clearUserName() {
userName_ = getDefaultInstance().getUserName();
}
/**
* <code>string user_name = 2;</code>
* @param value The bytes for userName to set.
*/
private void setUserNameBytes(
com.google.protobuf.ByteString value) {
checkByteStringIsUtf8(value);
userName_ = value.toStringUtf8();
}
public static com.sample.android_sample_preference_datastore.UserStore parseFrom(
java.nio.ByteBuffer data)
throws com.google.protobuf.InvalidProtocolBufferException {
return com.google.protobuf.GeneratedMessageLite.parseFrom(
DEFAULT_INSTANCE, data);
}
public static com.sample.android_sample_preference_datastore.UserStore parseFrom(
java.nio.ByteBuffer data,
com.google.protobuf.ExtensionRegistryLite extensionRegistry)
throws com.google.protobuf.InvalidProtocolBufferException {
return com.google.protobuf.GeneratedMessageLite.parseFrom(
DEFAULT_INSTANCE, data, extensionRegistry);
}
public static com.sample.android_sample_preference_datastore.UserStore parseFrom(
com.google.protobuf.ByteString data)
throws com.google.protobuf.InvalidProtocolBufferException {
return com.google.protobuf.GeneratedMessageLite.parseFrom(
DEFAULT_INSTANCE, data);
}
public static com.sample.android_sample_preference_datastore.UserStore parseFrom(
com.google.protobuf.ByteString data,
com.google.protobuf.ExtensionRegistryLite extensionRegistry)
throws com.google.protobuf.InvalidProtocolBufferException {
return com.google.protobuf.GeneratedMessageLite.parseFrom(
DEFAULT_INSTANCE, data, extensionRegistry);
}
public static com.sample.android_sample_preference_datastore.UserStore parseFrom(byte[] data)
throws com.google.protobuf.InvalidProtocolBufferException {
return com.google.protobuf.GeneratedMessageLite.parseFrom(
DEFAULT_INSTANCE, data);
}
public static com.sample.android_sample_preference_datastore.UserStore parseFrom(
byte[] data,
com.google.protobuf.ExtensionRegistryLite extensionRegistry)
throws com.google.protobuf.InvalidProtocolBufferException {
return com.google.protobuf.GeneratedMessageLite.parseFrom(
DEFAULT_INSTANCE, data, extensionRegistry);
}
public static com.sample.android_sample_preference_datastore.UserStore parseFrom(java.io.InputStream input)
throws java.io.IOException {
return com.google.protobuf.GeneratedMessageLite.parseFrom(
DEFAULT_INSTANCE, input);
}
public static com.sample.android_sample_preference_datastore.UserStore parseFrom(
java.io.InputStream input,
com.google.protobuf.ExtensionRegistryLite extensionRegistry)
throws java.io.IOException {
return com.google.protobuf.GeneratedMessageLite.parseFrom(
DEFAULT_INSTANCE, input, extensionRegistry);
}
public static com.sample.android_sample_preference_datastore.UserStore parseDelimitedFrom(java.io.InputStream input)
throws java.io.IOException {
return parseDelimitedFrom(DEFAULT_INSTANCE, input);
}
public static com.sample.android_sample_preference_datastore.UserStore parseDelimitedFrom(
java.io.InputStream input,
com.google.protobuf.ExtensionRegistryLite extensionRegistry)
throws java.io.IOException {
return parseDelimitedFrom(DEFAULT_INSTANCE, input, extensionRegistry);
}
public static com.sample.android_sample_preference_datastore.UserStore parseFrom(
com.google.protobuf.CodedInputStream input)
throws java.io.IOException {
return com.google.protobuf.GeneratedMessageLite.parseFrom(
DEFAULT_INSTANCE, input);
}
public static com.sample.android_sample_preference_datastore.UserStore parseFrom(
com.google.protobuf.CodedInputStream input,
com.google.protobuf.ExtensionRegistryLite extensionRegistry)
throws java.io.IOException {
return com.google.protobuf.GeneratedMessageLite.parseFrom(
DEFAULT_INSTANCE, input, extensionRegistry);
}
public static Builder newBuilder() {
return (Builder) DEFAULT_INSTANCE.createBuilder();
}
public static Builder newBuilder(com.sample.android_sample_preference_datastore.UserStore prototype) {
return (Builder) DEFAULT_INSTANCE.createBuilder(prototype);
}
/**
* Protobuf type {@code UserStore}
*/
public static final class Builder extends
com.google.protobuf.GeneratedMessageLite.Builder<
com.sample.android_sample_preference_datastore.UserStore, Builder> implements
// @@protoc_insertion_point(builder_implements:UserStore)
com.sample.android_sample_preference_datastore.UserStoreOrBuilder {
// Construct using com.sample.android_sample_preference_datastore.UserStore.newBuilder()
private Builder() {
super(DEFAULT_INSTANCE);
}
/**
* <code>bool is_logged_in = 1;</code>
* @return The isLoggedIn.
*/
@java.lang.Override
public boolean getIsLoggedIn() {
return instance.getIsLoggedIn();
}
/**
* <code>bool is_logged_in = 1;</code>
* @param value The isLoggedIn to set.
* @return This builder for chaining.
*/
public Builder setIsLoggedIn(boolean value) {
copyOnWrite();
instance.setIsLoggedIn(value);
return this;
}
/**
* <code>bool is_logged_in = 1;</code>
* @return This builder for chaining.
*/
public Builder clearIsLoggedIn() {
copyOnWrite();
instance.clearIsLoggedIn();
return this;
}
/**
* <code>string user_name = 2;</code>
* @return The userName.
*/
@java.lang.Override
public java.lang.String getUserName() {
return instance.getUserName();
}
/**
* <code>string user_name = 2;</code>
* @return The bytes for userName.
*/
@java.lang.Override
public com.google.protobuf.ByteString
getUserNameBytes() {
return instance.getUserNameBytes();
}
/**
* <code>string user_name = 2;</code>
* @param value The userName to set.
* @return This builder for chaining.
*/
public Builder setUserName(
java.lang.String value) {
copyOnWrite();
instance.setUserName(value);
return this;
}
/**
* <code>string user_name = 2;</code>
* @return This builder for chaining.
*/
public Builder clearUserName() {
copyOnWrite();
instance.clearUserName();
return this;
}
/**
* <code>string user_name = 2;</code>
* @param value The bytes for userName to set.
* @return This builder for chaining.
*/
public Builder setUserNameBytes(
com.google.protobuf.ByteString value) {
copyOnWrite();
instance.setUserNameBytes(value);
return this;
}
// @@protoc_insertion_point(builder_scope:UserStore)
}
@java.lang.Override
@java.lang.SuppressWarnings({"unchecked", "fallthrough"})
protected final java.lang.Object dynamicMethod(
com.google.protobuf.GeneratedMessageLite.MethodToInvoke method,
java.lang.Object arg0, java.lang.Object arg1) {
switch (method) {
case NEW_MUTABLE_INSTANCE: {
return new com.sample.android_sample_preference_datastore.UserStore();
}
case NEW_BUILDER: {
return new Builder();
}
case BUILD_MESSAGE_INFO: {
java.lang.Object[] objects = new java.lang.Object[] {
"isLoggedIn_",
"userName_",
};
java.lang.String info =
"\u0000\u0002\u0000\u0000\u0001\u0002\u0002\u0000\u0000\u0000\u0001\u0007\u0002\u0208" +
"";
return newMessageInfo(DEFAULT_INSTANCE, info, objects);
}
// fall through
case GET_DEFAULT_INSTANCE: {
return DEFAULT_INSTANCE;
}
case GET_PARSER: {
com.google.protobuf.Parser<com.sample.android_sample_preference_datastore.UserStore> parser = PARSER;
if (parser == null) {
synchronized (com.sample.android_sample_preference_datastore.UserStore.class) {
parser = PARSER;
if (parser == null) {
parser =
new DefaultInstanceBasedParser<com.sample.android_sample_preference_datastore.UserStore>(
DEFAULT_INSTANCE);
PARSER = parser;
}
}
}
return parser;
}
case GET_MEMOIZED_IS_INITIALIZED: {
return (byte) 1;
}
case SET_MEMOIZED_IS_INITIALIZED: {
return null;
}
}
throw new UnsupportedOperationException();
}
// @@protoc_insertion_point(class_scope:UserStore)
private static final com.sample.android_sample_preference_datastore.UserStore DEFAULT_INSTANCE;
static {
UserStore defaultInstance = new UserStore();
// New instances are implicitly immutable so no need to make
// immutable.
DEFAULT_INSTANCE = defaultInstance;
com.google.protobuf.GeneratedMessageLite.registerDefaultInstance(
UserStore.class, defaultInstance);
}
public static com.sample.android_sample_preference_datastore.UserStore getDefaultInstance() {
return DEFAULT_INSTANCE;
}
private static volatile com.google.protobuf.Parser<UserStore> PARSER;
public static com.google.protobuf.Parser<UserStore> parser() {
return DEFAULT_INSTANCE.getParserForType();
}
}
view raw UserStore.java hosted with ❤ by GitHub
Step 5

Now it’s time to create the serializer. Let’s create a class that implements Serializer<T>, where T is the message type defined in the proto file. This Serializertells the datastore how to read and write the data type we defined in the proto file. Let’s create UserStoreSerializer

package com.sample.android_sample_preference_datastore.proto
import androidx.datastore.core.CorruptionException
import androidx.datastore.core.Serializer
import com.google.protobuf.InvalidProtocolBufferException
import com.sample.android_sample_preference_datastore.UserStore
import java.io.InputStream
import java.io.OutputStream
object UserStoreSerializer : Serializer<UserStore> {
override val defaultValue: UserStore = UserStore.getDefaultInstance()
override suspend fun readFrom(input: InputStream): UserStore {
try {
return UserStore.parseFrom(input)
} catch (exception: InvalidProtocolBufferException) {
throw CorruptionException("Cannot read proto.", exception)
}
}
override suspend fun writeTo(t: UserStore, output: OutputStream) = t.writeTo(output)
}
Step 6: Creating the Proto DataStore instance

We can use the dataStore delegate for the creation of Datastore instance. The delegate needs 2 mandatory inputs those are name and the serializer

import androidx.datastore.core.DataStore
import androidx.datastore.dataStore
private val USER_DATA_STORE_FILE_NAME = "user_store.pb"
val Context.userDataStore: DataStore<UserStore> by dataStore(
fileName = USER_DATA_STORE_FILE_NAME,
serializer = UserStoreSerializer
)

The dataStore delegate ensures that we have a single instance of DataStore with that name in our application.

Step 7: Read operation from the proto data store instance

Same as we did in the case of Preference Datastore we can make use of DataStore.data to expose a Flow of the specific property from the stored instance state.

override suspend fun getUserLoggedInState(): Flow<Boolean> {
return protoDataStore.data.map { protoBuilder ->
protoBuilder.isLoggedIn
}
}

To handle exceptions while reading wrap with a catch block

override suspend fun getUserLoggedInState(): Flow<Boolean> {
return protoDataStore.data
.catch { exception ->
// dataStore.data throws an IOException when an error is encountered when reading data
if (exception is IOException) {
emit(UserStore.getDefaultInstance())
} else {
throw exception
}
}.map { protoBuilder ->
protoBuilder.isLoggedIn
}
}
Step 8: Wite operation on the proto data store instance

We have updatedata() functions that update the data transactionally in an atomic read-modify-write operation. We fetch the current state of the property then write on it and save it.

override suspend fun saveUserLoggedInState(state: Boolean) {
protoDataStore.updateData {store ->
store.toBuilder()
.setIsLoggedIn(state)
.build()
}
}
view raw wrteProto.kt hosted with ❤ by GitHub

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

Jobs

Example

Now let’s check them together in a simple example as we have done in the case of preference data store. Let’s store and fetch a boolean(user logged-in state)using a repository pattern inside an activity. In this example repository pattern is nothing but a simple interface and class implementing the interface where we do store and fetch operations on protoDataStore instance. For keeping this simple let’s do a manual injection of protoDataStore instance to repository implementation. As we keep observing the flowable emitted by the preference data store the data will be automatically changed

Step 1

Let’s create a simple XML with two buttons for log-in and log-out.

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/parent_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<TextView
android:id="@+id/txt_login_status"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text=""
android:gravity="center"
android:textSize="40sp"
android:layout_marginBottom="30dp"
android:textColor="@color/white"
app:layout_constraintBottom_toTopOf="@+id/btn_login"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
/>
<Button
android:id="@+id/btn_login"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Login"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>
<Button
android:id="@+id/btn_logout"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Button"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>
view raw main_pd.xml hosted with ❤ by GitHub
Step 2

Let’s create an Activity where we inflate the above layout and with one click of the Login button we save the state for user log-in as true and whereas on click of other we save false. Also, we create an instance of proto DataStore and will keep observing the user log-in state. Based on the state we append text and background-color

package com.sample.android_sample_preference_datastore.proto
import android.content.Context
import android.os.Bundle
import android.view.View
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.datastore.core.DataStore
import androidx.datastore.dataStore
import androidx.lifecycle.lifecycleScope
import com.sample.android_sample_preference_datastore.R
import com.sample.android_sample_preference_datastore.UserStore
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class ProtoSampleActivity: AppCompatActivity() {
private val DATA_STORE_FILE_NAME = "user_store.pb"
val Context.userDataStore: DataStore<UserStore> by dataStore(
fileName = DATA_STORE_FILE_NAME,
serializer = UserStoreSerializer
)
private var userRepo : ProtoUserRepo?=null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
userRepo = ProtoUserRepoImpl( userDataStore)
initListeners()
setDataToUI()
}
private fun setDataToUI() {
lifecycleScope.launch {
userRepo?.getUserLoggedInState()?.collect {state->
withContext(Dispatchers.Main) {
updateUI(state)
}
}
}
}
private fun updateUI(state: Boolean) {
findViewById<TextView>(R.id.txt_login_status)?.text =
"User Logged-in state ${state}"
if(state){
findViewById<View>(R.id.parent_layout)?.setBackgroundColor(ContextCompat.getColor(this,R.color.purple_200))
}else{
findViewById<View>(R.id.parent_layout)?.setBackgroundColor(ContextCompat.getColor(this,R.color.design_default_color_secondary))
}
}
private fun initListeners() {
findViewById<View>(R.id.btn_login)?.setOnClickListener {
lifecycleScope.launch {
userRepo?.saveUserLoggedInState(true)
}
}
findViewById<View>(R.id.btn_logout)?.setOnClickListener {
lifecycleScope.launch {
userRepo?.saveUserLoggedInState(false)
}
}
}
}

We need to add lifecycle-runtime-ktx dependency to make use of lifecycleScope

implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.3.1"
Step 3

Let’s create ProtoUserRepo with two methods for save and fetch of user-login state

package com.sample.android_sample_preference_datastore.proto
import kotlinx.coroutines.flow.Flow
interface ProtoUserRepo {
suspend fun saveUserLoggedInState(state:Boolean)
suspend fun getUserLoggedInState(): Flow<Boolean>
}
Step 4

Let’s provide ProtoUserRepoImpl an implementation to ProtoUserRepo

package com.sample.android_sample_preference_datastore.proto
import androidx.datastore.core.DataStore
import com.sample.android_sample_preference_datastore.UserStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
import java.io.IOException
class ProtoUserRepoImpl(private val protoDataStore: DataStore<UserStore>) :ProtoUserRepo {
override suspend fun saveUserLoggedInState(state: Boolean) {
protoDataStore.updateData {store ->
store.toBuilder()
.setIsLoggedIn(state)
.build()
}
}
override suspend fun getUserLoggedInState(): Flow<Boolean> {
return protoDataStore.data
.catch { exception ->
// dataStore.data throws an IOException when an error is encountered when reading data
if (exception is IOException) {
emit(UserStore.getDefaultInstance())
} else {
throw exception
}
}.map { protoBuilder ->
protoBuilder.isLoggedIn
}
}
}

That’s all we are done now run the app and check the output

Output

If you have any issues while executing the code snippets please check out the GitHub repo for handy access.

Summary

Datastores are advancement solutions for storage. Proto Datastore uses proto buffers and offers type safety. Protobufs are for serializing and de-serializing the data. As it doesn’t have a stable release yet think twice before using it in apps. . So give it a try…

Thank you for reading…

Resources

 

More Android Articles

 

Tags: Android, AndroidDev, Programming, Kotlin

 

View original article at:


Originally published: July 11, 2021

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
It’s one of the common UX across apps to provide swipe to dismiss so…
READ MORE
blog
Hi, today I come to you with a quick tip on how to update…
READ MORE
blog
Automation is a key point of Software Testing once it make possible to reproduce…
READ MORE
blog
Drag and Drop reordering in Recyclerview can be achieved with ItemTouchHelper (checkout implementation reference).…
READ MORE

Leave a Reply

Your email address will not be published. Required fields are marked *

Fill out this field
Fill out this field
Please enter a valid email address.

Menu