Enhancing User Security With Server-Side Validation

Server-side validation of SUSI.AI is required for increasing the security of user against malicious users who can bypass client-side validations easily and submit dangerous input to SUSI.AI server. SUSI.AI uses both client-side and server-side validation for various authentication purposes.

Client-side validates user input on the browser and provides a better user interaction. If the form is disabled on incorrect fields, it would save a round trip to the server, so the network traffic which will help your server perform better. Using client-side validation, an error message can be immediately shown before the user moves to the next field.

Adding Recaptcha while on new registration, change password, and more than one login attempts help to reduce abusive traffic, spam, brute force attack. For making user accounts safe from getting hacked on SUSI.AI, a strong password is supposed to be at least 8 characters, at least one special character, one number and text characters are must. The username must have 5-51 characters. So as to prevent SUSI.AI admin panel from crashing.

Code Integration

Client side reCaptcha validation:

onCaptchaSuccess = captchaResponse => {
  if (captchaResponse) {
    this.setState({
      showCaptchaErrorMessage: false,
      captchaResponse,
    });
  }
};

Auth/Login/Login.react.js

Once the user verifies captcha on client-side, a callback onCaptchaSuccess fires which receives a parameter captchaResponse. This captchaResponse is sent over to server-side with key g-recaptcha-response

Validation for User Login, ReCaptcha needs to be shown if the user has tried to log in for more than 1 time. For this, sessionStorage needs to be maintained. sessionStorage has an expiration time, which expires once the user closes the tab.

In the constructor of Login, the attempt is initialized with sessionStorage using key loginAttempts. Now, we have the correct value of loginAttempts and can update it further if the user tries to make more attempt.

constructor(props) {
    super(props);
    this.state = {
      ...
      showCaptchaErrorMessage: false,
      attempts: sessionStorage.getItem('loginAttempts') || 0,
      captchaResponse: '',
    };
  }

Auth/Login/Login.react.js

If the user closes the login dialog, we need to store the current attempts state on componentWillUnmount

componentWillUnmount() {
  sessionStorage.setItem('loginAttempts', this.state.attempts);
}

Auth/Login/Login.react.js

If attempts are less than 1, render the reCaptcha component and send g-recaptcha-response parameter to api.susi.ai/aaa/login.json API endpoint.

Server-side reCaptcha Validation for user login

The checkOneOrMoreInvalidLogins function in LoginService checks if the user has attempted to login in more than once. Access the accounting object and get identity and store it in accounting.

private boolean checkOneOrMoreInvalidLogins(Query post, Authorization authorization, JsonObjectWithDefault permissions) throws APIException {
	Accounting accounting = DAO.getAccounting(authorization.getIdentity());
	JSONObject invalidLogins = accouting.getRequests().getRequests(this.getClass().getCanonicalName());
	long period = permissions.getLong("periodSeconds", 600) * 1000; // get time period in which wrong logins are counted (e.g. the last 10 minutes)
	int counter = 0;
	for(String key : invalidLogins.keySet()){
		if(Long.parseLong(key, 10) > System.currentTimeMillis() - period) counter++;
	}
	if(counter > 0){
		return true;
	}
	return false;
}

aaa/LoginService.java

If user has attempted to login in more than once, get from g-recaptcha-response API parameter. Check if reCaptcha is verified using VerifyRecaptcha utility class.

if(checkOneOrMoreInvalidLogins(post, authorization, permissions) ){
	String gRecaptchaResponse = post.get("g-recaptcha-response", null);
	boolean isRecaptchaVerified = VerifyRecaptcha.verify(gRecaptchaResponse);
	if(!isRecaptchaVerified){
		result.put("message", "Please verify recaptcha");
		result.put("accepted", false);
		return new ServiceResponse(result);
	}
}

aaa/LoginService.java

The VerifyRecaptcha class provides with a verify method, which can be called to check if user response if correct or not. The secretKey is key given for communication between server-side and google.

URL is created and using InputStream, content(res) is directly read from a URL using openStream() function provided. The bufferedReader reads the contents line by line(using cp), and rd.read() returns a string. The result is appended to stringBuilder sb until -1 is read.

res.close() closes the stream and releases the resources for URL reading. JSONObject is created using the string created from URL, and value mapped to success is used to check if ReCaptcha is verified. If the response is correct, it returns true.

public class VerifyRecaptcha {
	public static boolean verify(String response) {
		try {
			String url = "https://www.google.com/recaptcha/api/siteverify?"
					+ "secret=6LfPZGAUAAAAAAULjaq7Rt9-7IGJoLYoz2Di6yVV&response=" + response;
			InputStream res = new URL(url).openStream();
			BufferedReader rd = new BufferedReader(new InputStreamReader(res, Charset.forName("UTF-8")));

			StringBuilder sb = new StringBuilder();
			int cp;
			while ((cp = rd.read()) != -1) {
				sb.append((char) cp);
			}
			String jsonText = sb.toString();
			res.close();

			JSONObject json = new JSONObject(jsonText);
			return json.getBoolean("success");
		} catch (Exception e) {
			e.printStackTrace();
		}
		return false;
	}
}

tools/VerifyRecaptcha.java

User name validation on server side

Changing regex in config: Regular express for username to have 5-51 characters. Setting regex and tooltip in configs:

users.username.regex=^(.{5,51})$

users.username.regex.tooltip=Enter atleast 5 character, upto 51 character

users.username.regex=^(.{5,51})$
users.username.regex.tooltip=Enter atleast 5 character, upto 51 character

conf/config.properties

If a user tries to add userName more than 51 characters or less than 5 characters, 

The regex is compiled using Pattern.compile and then matched against the value provided by the user in API parameter. If the pattern matches, it means the user provided the correct userName.

if(possibleKeys[i].equals("userName") && value != null){
    String usernamePattern = DAO.getConfig("users.username.regex", "^(.{5,51})");
    String usernamePatternTooltip = DAO.getConfig("users.username.regex.tooltip",
    "Enter atleast 5 character, upto 51 character");
    Pattern pattern = Pattern.compile(usernamePattern);
    if(!pattern.matcher(value).matches()) {
        throw new APIException(400, usernamePatternTooltip);
    }
}

aaa/ChangeUserSettings.java

With server-side validation incorporated, the SUSI.AI users accounts are much safer than only with client-side validation. Validation over both client-side and server-side complement each, server-side validation being much more robust.

Resources

Tags

SUSI.AI, FOSSASIA, GSoC19, SUSI.AI Server

Continue ReadingEnhancing User Security With Server-Side Validation

Implementing pagination with Retrofit in Eventyay Attendee

Pagination (Paging) is a common and powerful technique in Android Development when making HTTP requests or fetching data from the database. Eventyay Attendee has found many situations where data binding comes in as a great solution for our network calls with Retrofit. Let’s take a look at this technique.

  • Problems without Pagination in Android Development
  • Implementing Pagination with Kotlin with Retrofit
  • Results and GIF
  • Conclusions

PROBLEMS WITHOUT DATABINDING IN ANDROID DEVELOPMENT

Making HTTP requests to fetch data from the API is a basic work in any kind of application. With the mobile application, network data usage management is an important factor that affects the loading performance of the app. Without paging, all of the data are fetched even though most of them are not displayed on the screen. Pagination is a technique to load all the data in pages of limited items, which is much more efficient

IMPLEMENTING DATABINDING IN FRAGMENT VIEW

Step 1:  Set up dependency in build.gradle

// Paging
implementation "androidx.paging:paging-runtime:$paging_version"
implementation "androidx.paging:paging-rxjava2:$paging_version"

Step 2:  Set up retrofit to fetch events from the API

@GET("events?include=event-sub-topic,event-topic,event-type")
fun searchEventsPaged(
   @Query("sort") sort: String,
   @Query("filter") eventName: String,
   @Query("page[number]") page: Int,
   @Query("page[size]") pageSize: Int = 5
): Single<List<Event>>

Step 3: Set up the DataSource

DataSource is a base class for loading data in the paging library from Android. In Eventyay, we use PageKeyedDataSource. It will fetch the data based on the number of pages and items per page with our default parameters. With PageKeyedDataSource, three main functions loadInitial(), loadBefore(), loadAfter() are used to to load each chunks of data.

class EventsDataSource(
   private val eventService: EventService,
   private val compositeDisposable: CompositeDisposable,
   private val query: String?,
   private val mutableProgress: MutableLiveData<Boolean>

) : PageKeyedDataSource<Int, Event>() {
   override fun loadInitial(
       params: LoadInitialParams<Int>,
       callback: LoadInitialCallback<Int, Event>
   ) {
       createObservable(1, 2, callback, null)
   }

   override fun loadAfter(params: LoadParams<Int>, callback: LoadCallback<Int, Event>) {
       val page = params.key
       createObservable(page, page + 1, null, callback)
   }

   override fun loadBefore(params: LoadParams<Int>, callback: LoadCallback<Int, Event>) {
       val page = params.key
       createObservable(page, page - 1, null, callback)
   }

   private fun createObservable(
       requestedPage: Int,
       adjacentPage: Int,
       initialCallback: LoadInitialCallback<Int, Event>?,
       callback: LoadCallback<Int, Event>?
   ) {
       compositeDisposable +=
           eventService.getEventsByLocationPaged(query, requestedPage)
               .withDefaultSchedulers()
               .subscribe({ response ->
                   if (response.isEmpty()) mutableProgress.value = false
                   initialCallback?.onResult(response, null, adjacentPage)
                   callback?.onResult(response, adjacentPage)
               }, { error ->
                   Timber.e(error, "Fail on fetching page of events")
               }
           )
   }
}

Step 4: Set up the Data Source Factory

DataSourceFactory is the class responsible for creating DataSource object so that we can create PagedList (A type of List used for paging) for events.

class EventsDataSourceFactory(
   private val compositeDisposable: CompositeDisposable,
   private val eventService: EventService,
   private val query: String?,
   private val mutableProgress: MutableLiveData<Boolean>
) : DataSource.Factory<Int, Event>() {
   override fun create(): DataSource<Int, Event> {
       return EventsDataSource(eventService, compositeDisposable, query, mutableProgress)
   }
}

Step 5: Adapt the current change to the ViewModel. 

Previously, events fetched in List<Event> Object are now should be turned into PagedList<Event>.

sourceFactory = EventsDataSourceFactory(
   compositeDisposable,
   eventService,
   mutableSavedLocation.value,
   mutableProgress
)
val eventPagedList = RxPagedListBuilder(sourceFactory, config)
   .setFetchScheduler(Schedulers.io())
   .buildObservable()
   .cache()

compositeDisposable += eventPagedList
   .subscribeOn(Schedulers.io())
   .observeOn(AndroidSchedulers.mainThread())
   .distinctUntilChanged()
   .doOnSubscribe {
       mutableProgress.value = true
   }.subscribe({
       val currentPagedEvents = mutablePagedEvents.value
       if (currentPagedEvents == null) {
           mutablePagedEvents.value = it
       } else {
           currentPagedEvents.addAll(it)
           mutablePagedEvents.value = currentPagedEvents
       }
   }, {
       Timber.e(it, "Error fetching events")
       mutableMessage.value = resource.getString(R.string.error_fetching_events_message)
   })

Step 6: Turn ListAdapter into PagedListAdapter

PageListAdapter is basically the same ListAdapter to update the UI of the events item but specifically used for Pagination. In here, List objects can also be null.

class EventsListAdapter : PagedListAdapter<Event, EventViewHolder>(EventsDiffCallback()) {

   var onEventClick: EventClickListener? = null
   var onFavFabClick: FavoriteFabClickListener? = null
   var onHashtagClick: EventHashTagClickListener? = null

   override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): EventViewHolder {
       val binding = ItemCardEventsBinding.inflate(LayoutInflater.from(parent.context), parent, false)
       return EventViewHolder(binding)
   }

   override fun onBindViewHolder(holder: EventViewHolder, position: Int) {
       val event = getItem(position)
       if (event != null)
           holder.apply {
               bind(event, position)
               eventClickListener = onEventClick
               favFabClickListener = onFavFabClick
               hashTagClickListAdapter = onHashtagClick
           }
   }

   /**
    * The function to call when the adapter has to be cleared of items
    */
   fun clear() {
       this.submitList(null)
   }

AND HERE ARE THE RESULTS…

CONCLUSION

Databinding is the way to go when working with a complex UI in Android Development. This helps reducing boilerplate code and to increase the readability of the code and the performance of the UI. One problem with data-binding is that sometimes, it is pretty hard to debug with unhelpful log messages. Hopefully, you can empower your UI in your project now with data-binding. 

Pagination is the way to go for fetching items from the API and making infinite scrolling. This helps reduce network usage and improve the performance of Android applications. And that’s it. I hope you can make your application more powerful with pagination. 

RESOURCES

Open Event Codebase: https://github.com/fossasia/open-event-attendee-android/pull/2012

Documentation: https://developer.android.com/topic/libraries/architecture/paging/ 

Google Codelab: https://codelabs.developers.google.com/codelabs/android-paging/#0

Continue ReadingImplementing pagination with Retrofit in Eventyay Attendee

How to work with MPAndroidChart? – Neurolab Memory graph program mode

Memory graph mode

Overview

In Android App development, implementation of Android Charts play an integral part in developing database oriented apps, be it casual or professional apps.

Data Analysis plays an integral part in various sectors and industries in today’s world. In sports, e.g- cricket, we see the use of charts like bar charts, line charts in various stages of the game to present the game and score analysis.

In healthcare, we see the use of live real time graphs to show human health analysis like the heart rate, brain waves, etc. In the IT industry, professionals use graphical data analysis for their presentations in meetings and conferences.

I have been working with FOSSASIA on the Neurolab Android App, where I had to work on this unique program mode called “Memory Graph” wherein I had to work extensively with MPAndroidChart library. While working, I understood the various use cases of charts and graphs on the Android platform. In this blog, I want to showcase the building of one part of the app with a proper tutorial.

Tutorial

In the Android SDK, we, the developers have been given the benefit of a tool known as GraphView. But this view being a very default and basic tool in the toolkit, has limitations in terms of customization, needs a lot of boilerplate code to set up the base to work upon.

Also plotting real time graphs becomes a challenge while using the GraphView.

In this tutorial, we will be focusing on the Memory Graph program mode of the app wherein we will be using an external library – MPAndroidChart to help us achieve our aim.

                                                        Memory Graph

      1. Firstly, open the app level build.gradle and implement the library dependency in there.

                 implementation ‘com.github.PhilJay:MPAndroidChart:v3.1.0’

         Note – The library version may change depending upon the time you are reading     this blog. Refer here for the latest library updates.

XML layout

1. The layout for our chart screen should be simple without any other components on the screen so that it is perfectly comfortable for the users to understand the graph and not get overwhelmed. Here, we are going to use a RelativeLayout as the parent to the Chart. For our Memory graph program mode, I worked with the LineChart view from the MPAndroidChart library. The xml code for the layout:

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <com.github.mikephil.charting.charts.LineChart
        android:id="@+id/brain_chart"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />
</RelativeLayout>

Layout binding and Java code

1. In the onCreate method of our MemoryGraph Activity, we set the layout parameters as flags for our chart layout to use of the screen window. We find the LineChart from the layout by its id and bind it up using the private variable which is going to be used to set it up programmatically. Here is the code for the onCreate method:

private LineChart graph;
    private Thread thread;

    @Override
    public void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.fragment_memory_graph);
        getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN,
                WindowManager.LayoutParams.FLAG_FULLSCREEN);
        graph = findViewById(R.id.mem_graph);
    }

2. We are going to create a method named initGraph to set up the graph and make it ready to plot the data in real-time. Here is the code for the method:

graph.setOnChartValueSelectedListener(this);                // enable description text
        graph.getDescription().setEnabled(true);

        // enable touch gestures
        graph.setTouchEnabled(true);

        // enable scaling and dragging
        graph.setDragEnabled(true);
        graph.setScaleEnabled(true);
        graph.setDrawGridBackground(false);

        // if disabled, scaling can be done on x- and y-axis separately
        graph.setPinchZoom(true);

        // set an alternative background color
        graph.setBackgroundColor(getResources().getColor(R.color.memory_graph_background));

        LineData data = new LineData();
        data.setValueTextColor(Color.WHITE);

        // add empty data
        graph.setData(data);

        graph.getDescription().setText(getResources().getString(R.string.axis_desc_time));
        graph.getDescription().setTextColor(Color.WHITE);

        // get the legend (only possible after setting data)
        Legend l = memGraph.getLegend();

        // modify the legend ...
        l.setForm(Legend.LegendForm.LINE);
        l.setTextColor(Color.WHITE);

        XAxis xl = graph.getXAxis();
        xl.setTextColor(Color.WHITE);
        xl.setDrawGridLines(false);
        xl.setAvoidFirstLastClipping(true);
        xl.setEnabled(true);

        YAxis leftAxis = graph.getAxisLeft();
        leftAxis.setTextColor(Color.WHITE);
        leftAxis.setAxisMaximum(100f);
        leftAxis.setAxisMinimum(0f);
        leftAxis.setDrawGridLines(true);
        leftAxis.setGridColor(Color.WHITE);

        YAxis rightAxis = graph.getAxisRight();
        rightAxis.setEnabled(false);

Now, in here to use the setOnChartValueSelectedListener callback we need to implement the OnChartValueSelectedListener interface in our Activity. This will be required if the user clicks on a plot point. We need to set up the chart to respond to that user click on a particular data value on the chart.

3. Next, we are going to create a set of data for our graph to work with. As this set we create will be worked on by LineChart, hence our set will be of type LineDataSet. Using this LineDataSet, we will be able to set the axes labels, description, axes dependency (left or right), graph background, graph line colors and other necessary details. All parameters which can be tweaked through the `LineDataSet` for a LineGraph can be found here.

Here is the code for creating the LineDataSet :

private LineDataSet createSet() {

        LineDataSet set = new LineDataSet(null, "Brain waves");
        set.setAxisDependency(YAxis.AxisDependency.LEFT);
        set.setColor(Color.GREEN);
        set.setCircleColor(Color.WHITE);
        set.setLineWidth(2f);
        set.setCircleRadius(4f);
        set.setFillAlpha(65);
        set.setFillColor(Color.GREEN);
        set.setHighLightColor(Color.rgb(244, 117, 117));
        set.setValueTextColor(Color.WHITE);
        set.setValueTextSize(9f);
        set.setDrawValues(false);
        return set;
    }

4. Now, that we have set up the data for our LineChart to be used, we would be moving on to plotting the data. Since, everybody would not be able to have datasets containing brain-wave data, we would be taking the help of the ‘random’ function provided in Java. We create a function called `addEntry()`, wherein we add a new ‘Entry’ to the data set. Here, as we are working with a single dataset (the set of random values), the index for our dataset in the LineChart, will be zero.

private void addEntry() {

        LineData data = graph.getData();

        if (data != null) {

            ILineDataSet set = data.getDataSetByIndex(0);

            if (set == null) {
                set = createSet();
                data.addDataSet(set);
            }

            data.addEntry(new Entry(set.getEntryCount(), (float) (Math.random() * 40) + 30f), 0);
            data.notifyDataChanged();

            // let the graph know it's data has changed
            graph.notifyDataSetChanged();

            // limit the number of visible entries
            graph.setVisibleXRangeMaximum(120);

            // move to the latest entry
            graph.moveViewToX(data.getEntryCount());

        }
    }

5. We then create data feeding function called ‘feedMultiple’, for the chart. It contains a new runnable, which calls the ‘addEntry’ function. We then execute that runnable in a UI thread from within a for-loop, thus creating continuous values to plot. Here is the code for the ‘feedMultiple’ function;

private void feedMultiple() {

        if (thread != null)
            thread.interrupt();

        final Runnable runnable = () -> addEntry();

        thread = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {

                // Don't generate garbage runnables inside the loop.
                runOnUiThread(runnable);

                try {
                    Thread.sleep(25);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });

        thread.start();
    }

6. Now we can call our ‘feedMultiple’ method from the onCreate function of our MemoryGraph Activity java file and we are good to go!

The full code for Memory Graph Mode can be found here: https://github.com/fossasia/neurolab-android/blob/development/app/src/main/java/io/neurolab/fragments/MemoryGraphFragment.java

Hope this blog helps you strengthen your Android development skills.

Resources

  1. Author – Amar, Article – Working with MPAndroidChart, Source – Code your world, Date – July 2016, Website – https://coderzduniya.blogspot.com/2016/07/working-with-mpandroidchart-how-to.html

2. Author – PhilJay, Source – Github, Library – MPAndroidChart Library, Website – https://github.com/PhilJay/MPAndroidChart/

Tags: FOSSASIA, GSOC19, Neurolab, Android, Graphs, Open-source

Continue ReadingHow to work with MPAndroidChart? – Neurolab Memory graph program mode

Creating Common Loading Component in SUSI.AI

A circular loading component appears whenever an asynchronous event takes time on the front-end, to show the user that content is yet to be fetched and processed.

Creating Common Loading component eased the process of handling circular loading alignment. Using common loader decreased code repetition. There were major cases for loader component, mobile view, and desktop view.

Why Styled-components for SUSI.AI?

  • Painless maintenance
  • On-demand CSS injection: The components being rendered in the page are kept on track and only those component’s styles are injected into the DOM. If we combine react-loadable or some other code splitting, the app becomes really performant.
  • Styled components generate their own class name like sc-g3h4h6 and add it as an attribute to DOM, which avoids class name overlaps and misalignments.
  • Styled components are easier to detect as they can be detected by linter and husky. Whereas, static CSS files are not detected, which leads to dead code in the codebase and make them hard to manage. 
  • Simple dynamic styling: adapting the styling of a component based on its props, without having to manually manage dozens of classes.
  • Automatic vendor prefixing
  • Reuse, Reduce code written for CSS
  • Supports media queries, which makes creating a responsive PWA easier

 Let’s have a look at it’s implemented.

Code Integration

const Container = styled.div`
 display: flex;
 align-items: center;
 justify-content: center;
 ${props =>
   props.height
     ? css`
         height: ${props => props.height + 'rem'};
         @media (max-width: 512px) {
           height: ${props =>
             props.height > 20
               ? props.height - 15 + 'rem'
               : props.height + 'rem'};
         }
       `
     : css`
         margin: 19rem 0;
         height: 100%;
         @media (max-width: 512px) {
           margin: 14rem 0;
         }
       `}
`;

const CircularLoader = ({ height = 'auto', color = 'primary', size = 64 }) => {
 return (
   <Container height={height}>
     <CircularProgress color={color} size={size} />
   </Container>
 );
};

components/shared/CircularLoader.js

The CircularLoader component by default should acquire size 64 width and size, and color primary i.e. SUSI.AI blue color.

The container created using styled component, by default should be horizontally and vertically centered. This is used to handle the case where the content takes the whole width and height(100%) of page. 

If we want the container to be of specific height(lesser than the page in which the Loader is being rendered), we pass in height props.

For handling the mobile views, in case of height, 100%, margin-top and margin-bottom are reduced by 5rem.

In case of height passed in through component, check if the height is greater(than 20) so that the mobile view user can see the footer as well, reduce height by 15rem. If the height is lesser than 20rem, the footer is in the viewport.

Before PR, let’s have a look at how the Loader was rendered inside component:

{loading ? (
  <LoadingContainer>
    <CircularProgress size={64} />
  </LoadingContainer>
 ) : (
   ...
 )}

Using Common shared loader,

{loading ? <CircularLoader height={27} /> : ...

Settings/Settings.react.js

Adding styled component across the SUSI.AI web app proved to be of great help, the mobile views were easily made using media queries. Components positioning logic could be changed based on props, and most importantly, styled-components could be reused and were much easier to manage and maintain than static CSS classes or inline styles.

However, for other cases like when single or less number of styles were applied, maintaining everything using styled component proved to be additional overhead. A combination of inline styles and styled-components would be apt depending on the nature of style. 

Resources

Tags

SUSI.AI, FOSSASIA, GSoC19, styled-components, SUSI.AI Chat

Continue ReadingCreating Common Loading Component in SUSI.AI

Importing files from local storage in PSLab Android application

This blog demonstrates how a user can import log files from local storage to the PSLab Android application for various instruments and play them. This functionality is really useful as users can share their log files and import them in their app. This blog mostly consists of my work in the PSLab Android repository.

How to access local storage files?

We here use the concept of implicit intent to access the local storage of the device and then generate the file from the received data URI.

Implicit intents differ from explicit intents in a way that, they don’t give exact class or activity to be initialized through the intent, instead they provide the action to be performed and the class or activities are selected implicitly from the required action

The code block is shown below. 

private void selectFile() {
        Intent intent = new Intent(Intent.ACTION_GET_CONTENT);
        intent.setType("*/*");
        startActivityForResult(intent, 100);
}

Here the Intent.ACTION_GET_CONTENT defines implicit intent. This intent opens the activity related to the action of GETTING CONTENT. The type of content is specified in the Intent.setType(<TYPE>). Since here the type is set to “*/*”, it will open all types of files. If we want only images we can set Type to “images”.

startActivityForResult(intent, <REQUEST_CODE>) starts the file selection activity. 

How to generate a file from received URI?

Once the user selects a file from the file selection activity we can generate the selected file from the data passed in the callback function of startActivityForResult(). The data intent passed as a parameter to onActivityResult() callback contains data for the selected file. We can retrieve path, name, etc details of the selected file from this data intent. The code block for the same is given below.

@Override
    protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
        if (requestCode == 100) {
            if (resultCode == RESULT_OK) {
                Uri uri = data.getData();
                String path = uri.getPath();
                path = path.replace("/root_path/", "/");
                File file = new File(path);
                getFileData(file);
            }
            else Toast.makeText(this, this.getResources().getString(R.string.no_file_selected), Toast.LENGTH_SHORT).show();
        }
    }

Here we check for the requestCode, which we passed when calling the startActivityForResult() function. We further check if the result is valid and then generate the file from the file path we received in the data Intent. Once we get the path we can get the selected file using the following lines of code:

String path = uri.getPath();
path = path.replace("/root_path/", "/");
File file = new File(path);

How to get Data from the file?

Once the file is generated, it is passed to a function getFileData(File file) to get data in the file to add to the logs of the selected device.  The main part of the getFileData function is given below.

FileInputStream is = new FileInputStream(file);
BufferedReader reader = new BufferedReader(new InputStreamReader(is));
String line = reader.readLine();
int i = 0;
long block = 0, time = 0;
while (line != null) {
   if (i != 0) {
        String[] data = line.split(",");
        try {
              time += 1000;
              BaroData baroData = new BaroData(time, block, Float.valueOf(data[2]),                              Double.valueOf(data[3]), Double.valueOf(data[4]));
              realm.beginTransaction();
              realm.copyToRealm(baroData);
              realm.commitTransaction();
            } catch (Exception e) {
       Toast.makeText(this, getResources().getString(R.string.incorrect_import_format), Toast.LENGTH_SHORT).show();
           }
    }
    i++;
    line = reader.readLine();

Here we read the file line by line and convert the CSV data into the object of the selected device. And then this data is added to app storage using the realm. As shown in the code block above, we are parsing the data to the BarometerData class instances. We split each line by “,” and then use each field as input to the constructor of the BarometerData class. Once we create the instances of the class, we add them to the realm, so the imported file is saved in the realm and now we can access it easily from DataLoggerActivity.

The following images demonstrate the functionality of Import log 

Step 1: Select Import Log menu from 


(Figure 1: Import Log menu)

Step 2: Select the file to be imported from the local storage 


(Figure 2: Files to import from Local storage)

Step 3: Play the imported log from the DataLoggerActivity


(Figure 3: Imported logged data in DataLoggerActivity)

Resources

Tags: PSLab, Android, GSoC 19, ImportLog, Intents, Implicit Intent

Continue ReadingImporting files from local storage in PSLab Android application

Handle app links and apply unit tests in Open Event Attendee Application

The open event attendee is an android app which allows users to discover events happening around the world using the Open Event Platform. It consumes the APIs of the open event server to get a list of available events and can get detailed information about them. Users following links on devices have one goal in mind: to get to the content they want to see. As a developer, you can set up Android App Links to take users to a link’s specific content directly in your app, bypassing the app-selection dialog, also known as the disambiguation dialog. Because Android App Links leverage HTTP URLs and association with a website, users who don’t have your app installed go directly to content on your site.

A unit test generally exercises the functionality of the smallest possible unit of code (which could be a method, class, or component) in a repeatable way. You should build unit tests when you need to verify the logic of specific code in your app.

  • Why unit test cases?
  • Setup app link intent in the app
  • Apply unit test cases
  • Conclusion
  • Resources

Let’s analyze every step in detail.

Why unit test cases?

As it is already discussed what the app link intents are, and it is basically for providing a better user experience for the application. These are some following reason why unit test cases should use – 

  1. Rapid feedback on failures.
  2. Early failure detection in the development cycle.
  3. Safer code refactoring, letting you optimize code without worrying about regressions.
  4. Stable development velocity, helping you minimize technical debt.

JUnit4 library is used for unit tests.

Setup app link intent in the app

Declare the frontend host according to build flavor in the app level gradle file:

buildTypes {
        release {
            resValue "string",  "FRONTEND_HOST", "eventyay.com"
        }
        debug {
            resValue "string", "FRONTEND_HOST", "open-event-fe.netlify.com"
        }
    }

Handle the app link intent in Manifest file by adding intent filter under main activity decleartion:

<intent-filter>
    <action android:name="android.intent.action.VIEW" />
    <category android:name="android.intent.category.DEFAULT" />
    <category android:name="android.intent.category.BROWSABLE" />

    <data
        android:scheme="https"
        android:host="@string/FRONTEND_HOST"/>
</intent-filter>

Manifest will through the intent in the main activity file.

Now handle the intent in main activity:

override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        handleAppLinkIntent(intent)
    }

    override fun onNewIntent(intent: Intent?) {
        super.onNewIntent(intent)
        handleAppLinkIntent(intent)
    }

    private fun handleAppLinkIntent(intent: Intent?) {
        val uri = intent?.data ?: return
        val bundle = AppLinkUtils.getArguments(uri)
        val destinationId = AppLinkUtils.getDestinationId(uri)
        if (destinationId != null) {
            navController.navigate(destinationId, bundle)
        }
    }

Here a new class/object AppLinkUtils is defined which will return destination fragment id and the argument/data according to the intent URI.

Apply unit test cases:

First, implement the libraries in the gradle file –  1. JUnit for unit tests, 2. Robolectric for using android classes in the test class:

testImplementation 'junit:junit:4.12'
testImplementation 'org.robolectric:robolectric:3.4.2'

Create a test class for testing the app link functions and run it with RoboLectricTestRunner:

private const val EVENT = "event"
private const val RESET_PASSWORD = "resetPassword"
private const val VERIFY_EMAIL = "verifyEmail"

@RunWith(RobolectricTestRunner::class)
class AppLinkUtilsTest {

    private fun getAppLink(type: String): Uri {
        return when (type) {
            EVENT -> Uri.parse("https://eventyay.com/e/5f6d3feb")
            RESET_PASSWORD -> Uri.parse("https://eventyay.com/reset-password?token=822980340478781748445098077144")
            VERIFY_EMAIL -> Uri.parse("https://eventyay.com/verify?token=WyJsaXZlLmhhcnNoaXRAaG")
            else -> Uri.parse("")
        }
    }

    @Test
    fun `should get event link`() {
        val uri = getAppLink(EVENT)
        assertEquals(R.id.eventDetailsFragment, AppLinkUtils.getDestinationId(uri))
        assertEquals("""
            5f6d3feb
        """.trimIndent(), AppLinkUtils.getArguments(uri).getString(EVENT_IDENTIFIER))
    }// Find more test cases in the GitHub Repo.

Testing response:

GIF

In a Nutshell

So, essentially the Eventyay Attendee should have this feature to handle all links i.e. Reset password, verify user email and open event details in the app itself. So, we can provide a better user experience in-app instead of redirecting to the frontend for them.

Resources

  1. Android app links: https://developer.android.com/studio/write/app-link-indexing
  2. Developing android unit testing: https://www.vogella.com/tutorials/AndroidTesting/article.html

Tags

Eventyay, open-event, JUnit, AndroidUnitTest, AppLinks, Fossasia, GSoC, Android, Kotlin

Continue ReadingHandle app links and apply unit tests in Open Event Attendee Application

How to transfer data in files from different locations to app directory

In Android apps, we might have seen various instances in-app where we can import, export or save files. Now, where does the files go to or come from? There needs to be a specific folder or directory (maybe root) for a particular app which inturn contains the necessary files, the user has worked with from the app. In this blog, we will be learning about the backend of how to create directories and store files in them dynamically with proper content.

Context

I have been working with FOSSASIA on the project Neurolab-Android. In the app, we have various program modes, which has the option to save/record data. The saved data gets logged in a new file in the Neurolab directory/folder. Feel free to go ahead and explore the feature.

The Save/Record feature in Neurolab can be found in the app bar or as an option in the drop down menu present in the app bar itself in any program mode. The feature becomes functional, once the data is imported with the import data feature which is also present in the app bar.

                                              Figure: Demonstration of features in Neurolab

Tutorial

Now, starting off, there are apps out there wherein users can save files from different segments in the app and those saved files can be used by the app itself at other times as they belong to that app itself specifically.

First off, we need to make sure we have a directory for our app, wherein the files will get stored. If the directory or folder is not present, it needs to be created.

File directory = new File(
                Environment.getExternalStorageDirectory().getAbsolutePath() +
                        File.separator + DIRECTORY_NAME);
        if (!directory.exists()) {
            try {
                directory.mkdir();
            } catch (Exception e) {
                e.printStackTrace();
            }
        }

Now, once the directory is present, we can go ahead to keep the saved files in it. Also we will be implementing category-wise storage of the files. Simply put, we will be storing csv files in the CSV folder, xls files in the Excel folder, etc. In the below code example, we see the use case of CSV ‘category’.

private void categoryWise() {File categoryDirectory = new File(
                Environment.getExternalStorageDirectory().getAbsolutePath() +
                        File.separator + DIRECTORY_NAME + File.separator + category);
        if (!categoryDirectory.exists()) {
            try {
                categoryDirectory.mkdir();
            } catch (Exception e) {
                e.printStackTrace();
            }
        }}

For saving a file in our app, we need to get (import) the file into our app. Once the file is imported, we can get the path of the file with the help of its URI. Let’s assign the path to a variable named ‘importedFilePath’. Then we place the imported file in the required category directory within our parent app directory depending and deciding upon the extension of the imported file.

File importedFile = new File(importedFilePath);
        FilePathUtil.setupPath();
        Date currentTime = Calendar.getInstance().getTime();
        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        String fileName = sdf.format(currentTime);
        File dst = new File(Environment.getExternalStorageDirectory().getAbsolutePath() +
                File.separator + DIRECTORY_NAME + File.separator + categoryDirectory + File.separator + fileName + ".$extension");
        if (!dst.exists()) {
            try {                categoryWise()
                transfer(importedFile, dst);
            } catch (IOException e) {
                e.printStackTrace();
            }
        }

Now, we have the ‘importedFile’ and the destination file path (dst) where the file needs to be stored for our app. The ‘extension’ can be of any type you feel the file should be. Here, for the fileName we are using the current date and time together.

Then, we can come to the function ‘transfer’ which has been called above.

private static void transfer(File src, File dst) throws IOException {
        InputStream in = new FileInputStream(src);
        try {
            OutputStream out = new FileOutputStream(dst);
            try {
                // Transfer bytes from in to out
                byte[] buf = new byte[1024];
                int len;
                while ((len = in.read(buf)) > 0) {
                    out.write(buf, 0, len);
                }
            } finally {
                out.close();
            }
        } finally {
            in.close();
        }
    }

In the ‘transfer’ function, we initialize an input stream with the source file path and the output stream with the destination file path. We read the content in the form of  a certain chunk of bytes from the source file and write to the output stream (destination file).

Finally, we close the output and input streams simultaneously.

Thus, we have our code ready to be bound by UI actions/buttons. Once, the user interacts with the action in your app, the imported file will get saved in the specific directory of your app.

That’s it. Hope this blog enhanced your Android development and Java skillset. 

Resources:

  1. Author – Google Android Developers, Article – Data and File storage, Website – https://developer.android.com/guide/topics/data/data-storage
  2. Author – Rakshi and Thomas, Article – How to make a copy of file in android, Source – Stack overflow, Website – https://stackoverflow.com/questions/9292954/how-to-make-a-copy-of-a-file-in-android

Tags: FOSSASIA. Neurolab, GSOC19, Open-source, File-storage

Continue ReadingHow to transfer data in files from different locations to app directory

Audio Structure of SUSI Smart Speaker

Previously whenever a sound had to be played via the smart speaker, the subprocess python module was used to call the CVLC process and play audio via it. This puts forward a number of challenges while implementing various music features such as queuing songs, shuffling songs or handle the volume of the music. Thus, the audio structure was remade in the SUSI Smart Speaker project. The audio playing structure resides mainly in the susi_installer and the susi_linux repository. The above flow chart describes how audio is handled now in the project.

Location of related files:

Busy State: susi_linux/main/states/busy_state.py

Player: susi_linux/main/player

Sound Server: susi_installer/raspi/soundserver

VLC Player: susi_installer/pythonmods/vlcplayer

HW mixer: susi_installer/pythonmods/hwmixer

In the new structure instead of using CVLC and a youtube URL server, we use Python VLC with a sound server. The new structure as given in the flowchart is illustrated step by step below-


 Busy State

The busy state class sends and accepts responses by the server through the susi_python wrapper. Here is the code which is responsible for handling play audio from a USB thumb drive or youtube.

if ‘identifier’ in reply.keys():
    url = reply[‘identifier’]
    if url[:3] == ‘ytd’:
        player.playytb(url[4:])
    else:
        player.play(url)
    self.transition(self.allowedStateTransitions.get(‘idle’))

For example “SUSI, play audio” will have a response like : 

{‘identifier’: ‘file:///media/usb0/example.mp3’, ‘answer’: ‘Playing audio from your usb device’}


For the above response the play method of the player class will be called.

Player

The Player class checks if the soundserver is running and sends the request to it. If the soundserver is not running then the VLC Player class is used directly.

Play method:  

    def play(self, mrl, mode = None):
        self._executeArg(‘play’, ‘mrl’, mrl, mode)
    def _executeArg(self, method, key, arg, mode = None):
        if (mode == ‘server’) or ((mode is None) and (self.mode == ‘server’)):
            send_request(method + ‘?’ + key + ‘=’ + arg)
        else:
            getattr(vlcplayer, method)(arg)

Sound Server

The soundserver provides various methods of the VLC Player as endpoints. These endpoints are then used by the busy state directly or via the remote access webpage or an external application. An external application such as an android/ios app can use these endpoints to control the music playback on the device.

@app.route(‘/play’, methods=[‘POST’, ‘PUT’])
def play_route():
    if ‘ytb’ in request.args:
        vlcplayer.playytb(request.args.get(‘ytb’))
        return do_return(‘Ok’, 200)
    elif ‘mrl’ in request.args:
        vlcplayer.play(request.args.get(‘mrl’))
        return do_return(‘Ok’, 200)
    else:
        return do_return(‘Unknown play mode’, 400)

VLC Player

The VLC Player class is actually responsible for playing and handling the music. This uses python VLC module for handling audio playback and various other functionalities. We use the Media List Player class found in the VLC player module to play music, using Media list player over media player gives us the advantage of queuing the files and essentially making a playlist.

For more info on MediaListPlayer class visit – https://www.olivieraubert.net/vlc/python-ctypes/doc/vlc.MediaListPlayer-class.html

class VlcPlayer():

    def __init__(self):
        self.saved_softvolume = -1
        self.saved_hardvolume = -1
        self.instance = vlc.Instance(“–no-video”)
        self.player = self.instance.media_player_new()
        self.sayplayer = self.instance.media_player_new()
        self.list_player =  self.instance.media_list_player_new()
        self.list_player.set_media_player(self.player)

The play method in VLC player

    def play(self, mrl_string):
        self.mrl = mrl_string.split(“;”)
        media_list = self.instance.media_list_new(self.mrl)
        self.list_player.set_media_list(media_list)
        self.list_player.play()
        self.softvolume(100, self.player)

The play method receives a single MRL or multiple MRLs. If multiple MRLs are sent, they are separated via a semicolon ‘;’. The list player method of VLC Player class takes a list of MRLs as an input so if the received string has more than one MRLs it is broken down into a list of MRLs via the python’s inbuilt split method, which is then added to the list player.

At last play method of the mediaListPlayer class is used to play the music/audio.

Resources

Python VLC library – https://pypi.org/project/python-vlc/

VLC python bindings – https://wiki.videolan.org/Python_bindings

Tags

SUSI Smart Speaker, SUSI.AI, FOSSASIA, GSoC19

Continue ReadingAudio Structure of SUSI Smart Speaker

Dialog Component in SUSI.AI

Dialog Component in SUSI.AI is rendered in App.js to remove code redundancy. Redux is integrated in the Dialog component which allows us to open/close the dialog from any component by altering the modal states. This implementation allows us to get rid of the need of having dialog component in different components.

Redux Code

There are two actions and reducers which control the dialog component. Default state of isModalOpen is false and modalType is an empty string. To open a dialog modal the action openModal is dispatched, which sets isModalOpen to true and the modalType. To close a dialog modal the action closeModal is dispatched, which sets isModalOpen to default state i.e. false.

import { handleActions } from 'redux-actions';
import actionTypes from '../actionTypes';

const defaultState = {
 modalProps: {
   isModalOpen: false,
   modalType: '',
 },
};

export default handleActions(
 {
   [actionTypes.UI_OPEN_MODAL](state, { payload }) {
     return {
       ...state,
       modalProps: {
         isModalOpen: true,
         ...payload,
       },
     };
   },
   [actionTypes.UI_CLOSE_MODAL](state) {
     return {
       ...state,
       modalProps: defaultState.modalProps,
     };
   },
 }
 defaultState,
);

Shared Dialog Component

Dialog Modal can be opened from any component by dispatching an action. 

To open a Dialog Modal: this.props.actions.openModal({modalType: [modal name]});

To close a Dialog Modal: this.props.actions.closeModal();

Shared Dialog Component has a DialogData object which contains objects with two main properties : Dialog component and Dialog size. Other props can also be passed along with these two properties such as fullScreen. Dialog Content of different Dialogs are present in their respective folders. Each Dialog Content has a Title, Content and Actions.Different Dialog types present are:

  1. Confirm Delete with Input: This dialog modal is used when a user deletes account, device and skill. 
  2. Confirm Dialog: This dialog modal is used where confirmation is required from the user/admin such as on changing skill status, on password reset,etc.
  3. Share Dialog: This dialog modal opens up when the share icon is clicked in the chat.
  4. Standard Action Dialog: This dialog modal opens up on restore skill, delete feedback, system settings and bot.
  5. Tour Dialog: This dialog modal opens up SUSI.AI tour.

To add a new Dialog to DialogSection, the steps are:

  1. Import the Dialog Content Component
  2. Add the Dialog Component to DialogData object in the following manner:
const DialogData = {
[dialog componet name]: { Component : [imported dialog component name], size : [size of the Dialog Component]},
}

Code (Reduced)

const DialogData = {
  login: { Component: Login, size: 'sm' },
}
const DialogSection = props => {
 const {
   actions,
   modalProps: { isModalOpen, modalType, ...otherProps },
   visited,
 } = props;

 const getDialog = () => {
   if (isModalOpen) {
     return DialogData[modalType];
   }
   return DialogData.noComponent;
 };

 const { size, Component, fullScreen = false } = getDialog();

return (
   <Dialog
      maxWidth={size}
      fullWidth={true}
      open={isModalOpen || !visited}
      onClose={isModalOpen ? actions.closeModal : actions.setVisited}
      fullScreen={fullScreen}
    >
     <DialogContainer>
       {Component ? <Component {...otherProps} /> : null}
     </DialogContainer>
  </Dialog>
)
};

In conclusion, having a shared dialog component reduces redundant code and allows to have a similar Dialog UI across the repo. Also having one component managing all the dialogs removes the possibility of  two dialogs being fired up at once.

Resources

Continue ReadingDialog Component in SUSI.AI

My Devices in SUSI.AI

In this blog I’ll be explaining how to view, edit and delete connected devices from SUSI.AI webclient. To connect a device open up the SUSI.AI android app, and fill the details accordingly. Device can also be connected by logging in to your raspberry pi. Once the devices is connected you can edit, delete and access specific features for the device from the web client.

My Devices

All the connected devices can be viewed in My Devices tab in the Dashboard. In this tab all the devices connected to your account are listed in a table along with their locations on the map. Each device table row has three action buttons – view, edit and delete. Clicking on the view button takes to device specific page. Clicking on the edit button makes the fields name and room editable in table row. Clicking on the delete button opens a confirm with input dialog. Device can be deleted by entering the device name and clicking on delete.

To fetch all the device getUserDevices action is dispatched on component mounting which sets the reducer state devices in settings reducer. initialiseDevices function is called after all the devices are fetched from the server. This function creates an array of objects of devices with name, room, macId, latitude, longitude and location.

 componentDidMount() {
   const { accessToken, actions } = this.props;
   if (accessToken) {
     actions
       .getUserDevices()
       .then(({ payload }) => {
         this.initialiseDevices();
         this.setState({
           loading: false,
           emptyText: 'You do not have any devices connected yet!',
         });
       })
       .catch(error => {
         this.setState({
           loading: false,
           emptyText: 'Some error occurred while fetching the devices!',
         });
         console.log(error);
       });
   }
   document.title =
     'My Devices - SUSI.AI - Open Source Artificial Intelligence for Personal  Assistants, Robots, Help Desks and Chatbots';
 }
 initialiseDevices = () => {
   const { devices } = this.props;
 
   if (devices) {
     let devicesData = [];
     let deviceIds = Object.keys(devices);
     let invalidLocationDevices = 0;
 
     deviceIds.forEach(eachDevice => {
       const {
         name,
         room,
         geolocation: { latitude, longitude },
       } = devices[eachDevice];
 
       let deviceObj = {
         macId: eachDevice,
         deviceName: name,
         room,
         latitude,
         longitude,
         location: `${latitude}, ${longitude}`,
       };
 
       if (
         deviceObj.latitude === 'Latitude not available.' ||
         deviceObj.longitude === 'Longitude not available.'
       ) {
         deviceObj.location = 'Not found';
         invalidLocationDevices++;
       } else {
         deviceObj.latitude = parseFloat(latitude);
         deviceObj.longitude = parseFloat(longitude);
       }
       devicesData.push(deviceObj);
     });
 
     this.setState({
       devicesData,
       invalidLocationDevices,
     });
   }
 };

Device Page

Clicking on the view icon button in my devices redirects to mydevices/:macId. This page consists of device information in tabular format, local configuration settings and location of the device on the map. User can edit and delete the device from actions present in table. Local configuration settings can be accessed only if the user is logged in the local server.

Edit Device

To edit a device click on the edit icon button in the actions column of the table. The name and room field become editable.On changing the values handleChange function is called which updates the devicesData state. Clicking on the tick icon saves the new details by calling the onDeviceSave function. This function class the addUserDevice api which takes in the new device details.

startEditing = rowIndex => {
   this.setState({ editIdx: rowIndex });
 };
 
 handleChange = (e, fieldName, rowIndex) => {
   const value = e.target.value;
   let data = this.state.devicesData;
   this.setState({
     devicesData: data.map((row, index) =>
       index === rowIndex ? { ...row, [fieldName]: value } : row,
     ),
   });
 };

 handleDeviceSave = rowIndex => {
   this.setState({
     editIdx: -1,
   });
   const deviceData = this.state.devicesData[rowIndex];
 
   addUserDevice({ ...deviceData })
     .then(payload => {})
     .catch(error => {
       console.log(error);
     });
 };

Delete Device

To delete a device click on the delete icon button under the actions column in the table. Clicking on the delete device button opens up the confirm with input dialog modal. Type in the name of the device and click on delete. Clicking on delete calls the handeRemoveDevice function which calls the removeUserDevice api which takes in the macId. On deleting the device user is redirected to the My Devices in Dashboard.

 handleRemoveConfirmation = () => {
   this.props.actions.openModal({
     modalType: 'deleteDevice',
     name: this.state.devicesData[0].deviceName,
     handleConfirm: this.handleRemoveDevice,
     handleClose: this.props.actions.closeModal,
   });
 };
 handleRemoveDevice = () => {
   const macId = this.macId;
   removeUserDevice({ macId })
     .then(payload => {
       this.props.actions.closeModal();
       window.location.replace('/mydevices);
     })
     .catch(error => {
       console.log(error);
     });
 };

In conclusion, My Devices tab in dashboard helps you manage the devices connected with your account along with specific device configuration. Now the users can edit, view and delete their connected devices.

Resources

Continue ReadingMy Devices in SUSI.AI